mirror of
https://github.com/smittix/intercept.git
synced 2026-06-14 08:43:33 -07:00
Compare commits
123 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 7ed039564b | |||
| 8adfb3a40a | |||
| 9a9b1e9856 | |||
| 8aeb52380e | |||
| 05141b9a1b | |||
| dc0850d339 |
@@ -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
|
||||||
@@ -2,6 +2,48 @@
|
|||||||
|
|
||||||
All notable changes to iNTERCEPT will be documented in this file.
|
All notable changes to iNTERCEPT will be documented in this file.
|
||||||
|
|
||||||
|
## [2.26.11] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **APRS map ignores configured observer position** — The APRS map always fell back to the centre of the US (39.8°N, 98.6°W) when no live GPS fix was available, ignoring the observer position configured in `.env` (`INTERCEPT_DEFAULT_LAT` / `INTERCEPT_DEFAULT_LON`). Now seeds the APRS user location from the shared observer location on page load, so the map centres correctly and distance calculations work. (#193)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.10] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **APRS stop timeout and inverted SDR device status** — The APRS stop endpoint terminated two processes sequentially (up to 4s) while the frontend timed out at 2.2s, causing console errors and the SDR status panel to show stale state (active after stop, idle during use). Now releases the SDR device immediately and terminates processes in a background thread so the response returns instantly. (#194)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.9] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **ADS-B bias-t support for RTL-SDR Blog V4** — When dump1090 lacks native `--enable-biast` support, the system now falls back to `rtl_biast` (from RTL-SDR Blog drivers) to enable bias-t power before starting dump1090. The Blog V4's built-in LNA requires bias-t to receive ADS-B signals. (#195)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.8] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **acarsdec build failure on macOS** — `HOST_NAME_MAX` is Linux-specific (`<limits.h>`) and undefined on macOS, causing 3 compile errors in `acarsdec.c`. Now patched with `#define HOST_NAME_MAX 255` before building. Also fixed deprecated `-Ofast` flag warning on all macOS architectures (was only patched for arm64). (#187)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.7] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Health check SDR detection on macOS** — `timeout` (GNU coreutils) is not available on macOS, causing `rtl_test` to silently fail and report "No RTL-SDR device found" even when one is connected. Now tries `timeout`, then `gtimeout` (Homebrew coreutils), then falls back to a background process with manual kill. (#188)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## [2.26.6] - 2026-03-14
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- **Oversized branded 'i' logo on dashboards** — `.logo span { display: inline }` in dashboard CSS had higher specificity (0,1,1) than `.brand-i { display: inline-block }` (0,1,0), forcing the branded "i" SVG to render as inline which ignores width/height. Added `.logo .brand-i` selector (0,2,0) to retain `inline-block` display. (#189)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## [2.26.5] - 2026-03-14
|
## [2.26.5] - 2026-03-14
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -274,6 +274,9 @@ dsc_lock = threading.Lock()
|
|||||||
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
tscm_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
tscm_lock = threading.Lock()
|
tscm_lock = threading.Lock()
|
||||||
|
|
||||||
|
# Ground Station automation
|
||||||
|
ground_station_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
|
||||||
# SubGHz Transceiver (HackRF)
|
# SubGHz Transceiver (HackRF)
|
||||||
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
subghz_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
subghz_lock = threading.Lock()
|
subghz_lock = threading.Lock()
|
||||||
@@ -477,6 +480,9 @@ def login():
|
|||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
def index() -> str:
|
def index() -> str:
|
||||||
|
if request.args.get('mode') == 'satellite':
|
||||||
|
return redirect(url_for('satellite.satellite_dashboard'))
|
||||||
|
|
||||||
tools = {
|
tools = {
|
||||||
'rtl_fm': check_tool('rtl_fm'),
|
'rtl_fm': check_tool('rtl_fm'),
|
||||||
'multimon': check_tool('multimon-ng'),
|
'multimon': check_tool('multimon-ng'),
|
||||||
@@ -1125,28 +1131,35 @@ def _init_app() -> None:
|
|||||||
try:
|
try:
|
||||||
from routes.audio_websocket import init_audio_websocket
|
from routes.audio_websocket import init_audio_websocket
|
||||||
init_audio_websocket(app)
|
init_audio_websocket(app)
|
||||||
except ImportError:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Initialize KiwiSDR WebSocket audio proxy
|
# Initialize KiwiSDR WebSocket audio proxy
|
||||||
try:
|
try:
|
||||||
from routes.websdr import init_websdr_audio
|
from routes.websdr import init_websdr_audio
|
||||||
init_websdr_audio(app)
|
init_websdr_audio(app)
|
||||||
except ImportError:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Initialize WebSocket for waterfall streaming
|
# Initialize WebSocket for waterfall streaming
|
||||||
try:
|
try:
|
||||||
from routes.waterfall_websocket import init_waterfall_websocket
|
from routes.waterfall_websocket import init_waterfall_websocket
|
||||||
init_waterfall_websocket(app)
|
init_waterfall_websocket(app)
|
||||||
except ImportError:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Initialize WebSocket for meteor scatter monitoring
|
# Initialize WebSocket for meteor scatter monitoring
|
||||||
try:
|
try:
|
||||||
from routes.meteor_websocket import init_meteor_websocket
|
from routes.meteor_websocket import init_meteor_websocket
|
||||||
init_meteor_websocket(app)
|
init_meteor_websocket(app)
|
||||||
except ImportError:
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Initialize WebSocket for ground station live waterfall
|
||||||
|
try:
|
||||||
|
from routes.ground_station import init_ground_station_websocket
|
||||||
|
init_ground_station_websocket(app)
|
||||||
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Defer heavy/network operations so the worker can serve requests immediately
|
# Defer heavy/network operations so the worker can serve requests immediately
|
||||||
@@ -1188,6 +1201,30 @@ def _init_app() -> None:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
logger.warning(f"Failed to initialize TLE auto-refresh: {e}")
|
||||||
|
|
||||||
|
# Pre-warm SatNOGS transmitter cache so first dashboard load is instant
|
||||||
|
try:
|
||||||
|
if not os.environ.get('TESTING'):
|
||||||
|
from utils.satnogs import prefetch_transmitters
|
||||||
|
prefetch_transmitters()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"SatNOGS prefetch failed: {e}")
|
||||||
|
|
||||||
|
# Wire ground station scheduler event → SSE queue
|
||||||
|
try:
|
||||||
|
import app as _self
|
||||||
|
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||||
|
gs_scheduler = get_ground_station_scheduler()
|
||||||
|
|
||||||
|
def _gs_event_to_sse(event: dict) -> None:
|
||||||
|
try:
|
||||||
|
_self.ground_station_queue.put_nowait(event)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
gs_scheduler.set_event_callback(_gs_event_to_sse)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Ground station scheduler init failed: {e}")
|
||||||
|
|
||||||
threading.Thread(target=_deferred_init, daemon=True).start()
|
threading.Thread(target=_deferred_init, daemon=True).start()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -7,10 +7,45 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.26.5"
|
VERSION = "2.26.12"
|
||||||
|
|
||||||
# Changelog - latest release notes (shown on welcome screen)
|
# Changelog - latest release notes (shown on welcome screen)
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
"highlights": [
|
||||||
|
"APRS map now centres on configured observer position from .env",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.8",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix acarsdec build failure on macOS (HOST_NAME_MAX undefined)",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.7",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix health check SDR detection on macOS (timeout command not available)",
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"version": "2.26.6",
|
||||||
|
"date": "March 2026",
|
||||||
|
"highlights": [
|
||||||
|
"Fix oversized branded 'i' logo on Aircraft & Vessel dashboards",
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "2.26.5",
|
"version": "2.26.5",
|
||||||
"date": "March 2026",
|
"date": "March 2026",
|
||||||
|
|||||||
@@ -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
-1
@@ -3502,7 +3502,7 @@ class ModeManager:
|
|||||||
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
|
stations_url = 'https://celestrak.org/NORAD/elements/gp.php?GROUP=weather&FORMAT=tle'
|
||||||
satellites = load.tle_file(stations_url)
|
satellites = load.tle_file(stations_url)
|
||||||
|
|
||||||
ts = load.timescale()
|
ts = load.timescale(builtin=True)
|
||||||
observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
|
observer = Topos(latitude_degrees=lat, longitude_degrees=lon)
|
||||||
|
|
||||||
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")
|
logger.info(f"Satellite predictor: {len(satellites)} satellites loaded")
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "intercept"
|
name = "intercept"
|
||||||
version = "2.26.5"
|
version = "2.26.11"
|
||||||
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
description = "Signal Intelligence Platform - Pager/433MHz/ADS-B/Satellite/WiFi/Bluetooth"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.9"
|
requires-python = ">=3.9"
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ cryptography>=41.0.0
|
|||||||
# mypy>=1.0.0
|
# mypy>=1.0.0
|
||||||
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
# WebSocket support for in-app audio streaming (KiwiSDR, Listening Post)
|
||||||
flask-sock
|
flask-sock
|
||||||
|
simple-websocket>=0.5.1
|
||||||
websocket-client>=1.6.0
|
websocket-client>=1.6.0
|
||||||
|
|
||||||
# System health monitoring (optional - graceful fallback if unavailable)
|
# System health monitoring (optional - graceful fallback if unavailable)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ def register_blueprints(app):
|
|||||||
from .correlation import correlation_bp
|
from .correlation import correlation_bp
|
||||||
from .dsc import dsc_bp
|
from .dsc import dsc_bp
|
||||||
from .gps import gps_bp
|
from .gps import gps_bp
|
||||||
|
from .ground_station import ground_station_bp
|
||||||
from .listening_post import receiver_bp
|
from .listening_post import receiver_bp
|
||||||
from .meshtastic import meshtastic_bp
|
from .meshtastic import meshtastic_bp
|
||||||
from .meteor_websocket import meteor_bp
|
from .meteor_websocket import meteor_bp
|
||||||
@@ -89,6 +90,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
app.register_blueprint(radiosonde_bp) # Radiosonde weather balloon tracking
|
||||||
app.register_blueprint(system_bp) # System health monitoring
|
app.register_blueprint(system_bp) # System health monitoring
|
||||||
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
app.register_blueprint(ook_bp) # Generic OOK signal decoder
|
||||||
|
app.register_blueprint(ground_station_bp) # Ground station automation
|
||||||
|
|
||||||
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
# Exempt all API blueprints from CSRF (they use JSON, not form tokens)
|
||||||
if _csrf:
|
if _csrf:
|
||||||
|
|||||||
+11
-13
@@ -40,6 +40,8 @@ from config import (
|
|||||||
ADSB_DB_PORT,
|
ADSB_DB_PORT,
|
||||||
ADSB_DB_USER,
|
ADSB_DB_USER,
|
||||||
ADSB_HISTORY_ENABLED,
|
ADSB_HISTORY_ENABLED,
|
||||||
|
DEFAULT_LATITUDE,
|
||||||
|
DEFAULT_LONGITUDE,
|
||||||
SHARED_OBSERVER_LOCATION_ENABLED,
|
SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
)
|
)
|
||||||
from utils import aircraft_db
|
from utils import aircraft_db
|
||||||
@@ -765,23 +767,14 @@ def check_adsb_tools():
|
|||||||
has_readsb = shutil.which('readsb') is not None
|
has_readsb = shutil.which('readsb') is not None
|
||||||
has_rtl_adsb = shutil.which('rtl_adsb') is not None
|
has_rtl_adsb = shutil.which('rtl_adsb') is not None
|
||||||
|
|
||||||
# Check what SDR hardware is detected
|
|
||||||
devices = SDRFactory.detect_devices()
|
|
||||||
has_rtlsdr = any(d.sdr_type == SDRType.RTL_SDR for d in devices)
|
|
||||||
has_soapy_sdr = any(d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY) for d in devices)
|
|
||||||
soapy_types = [d.sdr_type.value for d in devices if d.sdr_type in (SDRType.HACKRF, SDRType.LIME_SDR, SDRType.AIRSPY)]
|
|
||||||
|
|
||||||
# Determine if readsb is needed but missing
|
|
||||||
needs_readsb = has_soapy_sdr and not has_readsb
|
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'dump1090': has_dump1090,
|
'dump1090': has_dump1090,
|
||||||
'readsb': has_readsb,
|
'readsb': has_readsb,
|
||||||
'rtl_adsb': has_rtl_adsb,
|
'rtl_adsb': has_rtl_adsb,
|
||||||
'has_rtlsdr': has_rtlsdr,
|
'has_rtlsdr': None,
|
||||||
'has_soapy_sdr': has_soapy_sdr,
|
'has_soapy_sdr': None,
|
||||||
'soapy_types': soapy_types,
|
'soapy_types': [],
|
||||||
'needs_readsb': needs_readsb
|
'needs_readsb': False
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
@@ -1165,6 +1158,9 @@ def stream_adsb():
|
|||||||
|
|
||||||
def generate():
|
def generate():
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
# Send immediate keepalive so Werkzeug dev server flushes response
|
||||||
|
# headers right away (it buffers until first body byte is written).
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -1197,6 +1193,8 @@ def adsb_dashboard():
|
|||||||
'adsb_dashboard.html',
|
'adsb_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
adsb_auto_start=ADSB_AUTO_START,
|
adsb_auto_start=ADSB_AUTO_START,
|
||||||
|
default_latitude=DEFAULT_LATITUDE,
|
||||||
|
default_longitude=DEFAULT_LONGITUDE,
|
||||||
embedded=embedded,
|
embedded=embedded,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -15,7 +15,7 @@ import time
|
|||||||
from flask import Blueprint, Response, jsonify, render_template, request
|
from flask import Blueprint, Response, jsonify, render_template, request
|
||||||
|
|
||||||
import app as app_module
|
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 (
|
from utils.constants import (
|
||||||
AIS_RECONNECT_DELAY,
|
AIS_RECONNECT_DELAY,
|
||||||
AIS_SOCKET_TIMEOUT,
|
AIS_SOCKET_TIMEOUT,
|
||||||
@@ -542,5 +542,7 @@ def ais_dashboard():
|
|||||||
return render_template(
|
return render_template(
|
||||||
'ais_dashboard.html',
|
'ais_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
|
default_latitude=DEFAULT_LATITUDE,
|
||||||
|
default_longitude=DEFAULT_LONGITUDE,
|
||||||
embedded=embedded,
|
embedded=embedded,
|
||||||
)
|
)
|
||||||
|
|||||||
+30
-16
@@ -1924,7 +1924,13 @@ def start_aprs() -> Response:
|
|||||||
|
|
||||||
@aprs_bp.route('/stop', methods=['POST'])
|
@aprs_bp.route('/stop', methods=['POST'])
|
||||||
def stop_aprs() -> Response:
|
def stop_aprs() -> Response:
|
||||||
"""Stop APRS decoder."""
|
"""Stop APRS decoder.
|
||||||
|
|
||||||
|
Releases the SDR device immediately so the status panel updates
|
||||||
|
without waiting for process termination. Process cleanup runs in a
|
||||||
|
background thread to avoid blocking the HTTP response (which caused
|
||||||
|
frontend timeout errors when two processes each took up to 2s to die).
|
||||||
|
"""
|
||||||
global aprs_active_device, aprs_active_sdr_type
|
global aprs_active_device, aprs_active_sdr_type
|
||||||
|
|
||||||
with app_module.aprs_lock:
|
with app_module.aprs_lock:
|
||||||
@@ -1939,6 +1945,28 @@ def stop_aprs() -> Response:
|
|||||||
if not processes_to_stop:
|
if not processes_to_stop:
|
||||||
return api_error('APRS decoder not running', 400)
|
return api_error('APRS decoder not running', 400)
|
||||||
|
|
||||||
|
# Release SDR device immediately so status panel reflects the
|
||||||
|
# change without waiting for process termination.
|
||||||
|
if aprs_active_device is not None:
|
||||||
|
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
||||||
|
aprs_active_device = None
|
||||||
|
aprs_active_sdr_type = None
|
||||||
|
|
||||||
|
# Capture refs to clear before releasing the lock
|
||||||
|
master_fd = getattr(app_module, 'aprs_master_fd', None)
|
||||||
|
app_module.aprs_process = None
|
||||||
|
if hasattr(app_module, 'aprs_rtl_process'):
|
||||||
|
app_module.aprs_rtl_process = None
|
||||||
|
app_module.aprs_master_fd = None
|
||||||
|
|
||||||
|
# Terminate processes in background so the response returns fast.
|
||||||
|
# Each proc.wait() can block up to PROCESS_TERMINATE_TIMEOUT (2s),
|
||||||
|
# which previously caused the frontend 2200ms fetch to abort.
|
||||||
|
def _cleanup():
|
||||||
|
# Close PTY master fd first — this unblocks the stream thread
|
||||||
|
if master_fd is not None:
|
||||||
|
with contextlib.suppress(OSError):
|
||||||
|
os.close(master_fd)
|
||||||
for proc in processes_to_stop:
|
for proc in processes_to_stop:
|
||||||
try:
|
try:
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
@@ -1948,21 +1976,7 @@ def stop_aprs() -> Response:
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error stopping APRS process: {e}")
|
logger.error(f"Error stopping APRS process: {e}")
|
||||||
|
|
||||||
# Close PTY master fd
|
threading.Thread(target=_cleanup, daemon=True).start()
|
||||||
if hasattr(app_module, 'aprs_master_fd') and app_module.aprs_master_fd is not None:
|
|
||||||
with contextlib.suppress(OSError):
|
|
||||||
os.close(app_module.aprs_master_fd)
|
|
||||||
app_module.aprs_master_fd = None
|
|
||||||
|
|
||||||
app_module.aprs_process = None
|
|
||||||
if hasattr(app_module, 'aprs_rtl_process'):
|
|
||||||
app_module.aprs_rtl_process = None
|
|
||||||
|
|
||||||
# Release SDR device
|
|
||||||
if aprs_active_device is not None:
|
|
||||||
app_module.release_sdr_device(aprs_active_device, aprs_active_sdr_type or 'rtlsdr')
|
|
||||||
aprs_active_device = None
|
|
||||||
aprs_active_sdr_type = None
|
|
||||||
|
|
||||||
return jsonify({'status': 'stopped'})
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|||||||
+22
-4
@@ -43,6 +43,8 @@ from utils.trilateration import (
|
|||||||
logger = logging.getLogger('intercept.controller')
|
logger = logging.getLogger('intercept.controller')
|
||||||
|
|
||||||
controller_bp = Blueprint('controller', __name__, url_prefix='/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).
|
# Multi-agent SSE fanout state (per-client queues).
|
||||||
_agent_stream_subscribers: set[queue.Queue] = set()
|
_agent_stream_subscribers: set[queue.Queue] = set()
|
||||||
@@ -81,7 +83,11 @@ def get_agents():
|
|||||||
if refresh:
|
if refresh:
|
||||||
for agent in agents:
|
for agent in agents:
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = AgentClient(
|
||||||
|
agent['base_url'],
|
||||||
|
api_key=agent.get('api_key'),
|
||||||
|
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
agent['healthy'] = client.health_check()
|
agent['healthy'] = client.health_check()
|
||||||
except Exception:
|
except Exception:
|
||||||
agent['healthy'] = False
|
agent['healthy'] = False
|
||||||
@@ -328,7 +334,11 @@ def check_all_agents_health():
|
|||||||
}
|
}
|
||||||
|
|
||||||
try:
|
try:
|
||||||
client = create_client_from_agent(agent)
|
client = AgentClient(
|
||||||
|
agent['base_url'],
|
||||||
|
api_key=agent.get('api_key'),
|
||||||
|
timeout=AGENT_HEALTH_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
|
||||||
# Time the health check
|
# Time the health check
|
||||||
start_time = time.time()
|
start_time = time.time()
|
||||||
@@ -344,7 +354,12 @@ def check_all_agents_health():
|
|||||||
|
|
||||||
# Also fetch running modes
|
# Also fetch running modes
|
||||||
try:
|
try:
|
||||||
status = client.get_status()
|
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'] = status.get('running_modes', [])
|
||||||
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
result['running_modes_detail'] = status.get('running_modes_detail', {})
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -673,6 +688,7 @@ def stream_all_agents():
|
|||||||
def generate() -> Generator[str, None, None]:
|
def generate() -> Generator[str, None, None]:
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
keepalive_interval = 30.0
|
keepalive_interval = 30.0
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -713,7 +729,9 @@ def agent_management_page():
|
|||||||
def network_monitor_page():
|
def network_monitor_page():
|
||||||
"""Render the network monitor page for multi-agent aggregated view."""
|
"""Render the network monitor page for multi-agent aggregated view."""
|
||||||
from flask import render_template
|
from flask import render_template
|
||||||
return render_template('network_monitor.html')
|
|
||||||
|
from config import VERSION
|
||||||
|
return render_template('network_monitor.html', version=VERSION)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
@@ -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))}")
|
||||||
+134
-19
@@ -11,6 +11,7 @@ import contextlib
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import queue
|
import queue
|
||||||
|
import shlex
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
@@ -45,6 +46,7 @@ from utils.validation import (
|
|||||||
logger = get_logger('intercept.radiosonde')
|
logger = get_logger('intercept.radiosonde')
|
||||||
|
|
||||||
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
radiosonde_bp = Blueprint('radiosonde', __name__, url_prefix='/radiosonde')
|
||||||
|
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
# Track radiosonde state
|
# Track radiosonde state
|
||||||
radiosonde_running = False
|
radiosonde_running = False
|
||||||
@@ -83,6 +85,119 @@ def find_auto_rx() -> str | None:
|
|||||||
return None
|
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(
|
def generate_station_cfg(
|
||||||
freq_min: float = 400.0,
|
freq_min: float = 400.0,
|
||||||
freq_max: float = 406.0,
|
freq_max: float = 406.0,
|
||||||
@@ -547,30 +662,29 @@ def start_radiosonde():
|
|||||||
# Build command - auto_rx -c expects the path to station.cfg
|
# Build command - auto_rx -c expects the path to station.cfg
|
||||||
cfg_abs = os.path.abspath(cfg_path)
|
cfg_abs = os.path.abspath(cfg_path)
|
||||||
if auto_rx_path.endswith('.py'):
|
if auto_rx_path.endswith('.py'):
|
||||||
cmd = [sys.executable, auto_rx_path, '-c', cfg_abs]
|
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:
|
else:
|
||||||
cmd = [auto_rx_path, '-c', cfg_abs]
|
cmd = [auto_rx_path, '-c', cfg_abs]
|
||||||
|
|
||||||
# Set cwd to the auto_rx directory so 'from autorx.scan import ...' works
|
# 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_dir = os.path.dirname(os.path.abspath(auto_rx_path))
|
||||||
|
auto_rx_env = _build_auto_rx_env(auto_rx_dir)
|
||||||
# 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:
|
try:
|
||||||
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
logger.info(f"Starting radiosonde_auto_rx: {' '.join(cmd)}")
|
||||||
@@ -580,6 +694,7 @@ def start_radiosonde():
|
|||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
start_new_session=True,
|
start_new_session=True,
|
||||||
cwd=auto_rx_dir,
|
cwd=auto_rx_dir,
|
||||||
|
env=auto_rx_env,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Wait briefly for process to start
|
# Wait briefly for process to start
|
||||||
|
|||||||
+452
-152
@@ -3,11 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
import urllib.request
|
import urllib.request
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
import requests
|
import requests
|
||||||
from flask import Blueprint, jsonify, render_template, request
|
from flask import Blueprint, Response, jsonify, make_response, render_template, request
|
||||||
|
|
||||||
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
from config import SHARED_OBSERVER_LOCATION_ENABLED
|
||||||
from data.satellites import TLE_SATELLITES
|
from data.satellites import TLE_SATELLITES
|
||||||
@@ -20,6 +22,7 @@ from utils.database import (
|
|||||||
)
|
)
|
||||||
from utils.logging import satellite_logger as logger
|
from utils.logging import satellite_logger as logger
|
||||||
from utils.responses import api_error
|
from utils.responses import api_error
|
||||||
|
from utils.sse import sse_stream_fanout
|
||||||
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
|
from utils.validation import validate_elevation, validate_hours, validate_latitude, validate_longitude
|
||||||
|
|
||||||
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
satellite_bp = Blueprint('satellite', __name__, url_prefix='/satellite')
|
||||||
@@ -32,7 +35,8 @@ def _get_timescale():
|
|||||||
global _cached_timescale
|
global _cached_timescale
|
||||||
if _cached_timescale is None:
|
if _cached_timescale is None:
|
||||||
from skyfield.api import load
|
from skyfield.api import load
|
||||||
_cached_timescale = load.timescale()
|
# Use bundled timescale data so the first request does not block on network I/O.
|
||||||
|
_cached_timescale = load.timescale(builtin=True)
|
||||||
return _cached_timescale
|
return _cached_timescale
|
||||||
|
|
||||||
# Maximum response size for external requests (1MB)
|
# Maximum response size for external requests (1MB)
|
||||||
@@ -44,6 +48,26 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
# Local TLE cache (can be updated via API)
|
# Local TLE cache (can be updated via API)
|
||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
|
# Ground track cache: key=(sat_name, tle_line1[:20]) -> (track_data, computed_at_timestamp)
|
||||||
|
# TTL is 1800 seconds (30 minutes)
|
||||||
|
_track_cache: dict = {}
|
||||||
|
_TRACK_CACHE_TTL = 1800
|
||||||
|
|
||||||
|
# Thread pool for background ground-track computation (non-blocking from 1Hz tracker loop)
|
||||||
|
from concurrent.futures import ThreadPoolExecutor as _ThreadPoolExecutor
|
||||||
|
|
||||||
|
_track_executor = _ThreadPoolExecutor(max_workers=2, thread_name_prefix='sat-track')
|
||||||
|
_track_in_progress: set = set() # cache keys currently being computed
|
||||||
|
_pass_cache: dict = {}
|
||||||
|
_PASS_CACHE_TTL = 300
|
||||||
|
|
||||||
|
_BUILTIN_NORAD_TO_KEY = {
|
||||||
|
25544: 'ISS',
|
||||||
|
40069: 'METEOR-M2',
|
||||||
|
57166: 'METEOR-M2-3',
|
||||||
|
59051: 'METEOR-M2-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def _load_db_satellites_into_cache():
|
def _load_db_satellites_into_cache():
|
||||||
"""Load user-tracked satellites from DB into the TLE cache."""
|
"""Load user-tracked satellites from DB into the TLE cache."""
|
||||||
@@ -64,9 +88,251 @@ def _load_db_satellites_into_cache():
|
|||||||
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_satellite_name(value: object) -> str:
|
||||||
|
"""Normalize satellite identifiers for loose name matching."""
|
||||||
|
return str(value or '').strip().replace(' ', '-').upper()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tracked_satellite_maps() -> tuple[dict[int, dict], dict[str, dict]]:
|
||||||
|
"""Return tracked satellites indexed by NORAD ID and normalized name."""
|
||||||
|
by_norad: dict[int, dict] = {}
|
||||||
|
by_name: dict[str, dict] = {}
|
||||||
|
try:
|
||||||
|
for sat in get_tracked_satellites():
|
||||||
|
try:
|
||||||
|
norad_id = int(sat['norad_id'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
continue
|
||||||
|
by_norad[norad_id] = sat
|
||||||
|
by_name[_normalize_satellite_name(sat.get('name'))] = sat
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to read tracked satellites for lookup: {e}")
|
||||||
|
return by_norad, by_name
|
||||||
|
|
||||||
|
|
||||||
|
def _resolve_satellite_request(sat: object, tracked_by_norad: dict[int, dict], tracked_by_name: dict[str, dict]) -> tuple[str, int | None, tuple[str, str, str] | None]:
|
||||||
|
"""Resolve a satellite request to display name, NORAD ID, and TLE data."""
|
||||||
|
norad_id: int | None = None
|
||||||
|
sat_key: str | None = None
|
||||||
|
tracked: dict | None = None
|
||||||
|
|
||||||
|
if isinstance(sat, int):
|
||||||
|
norad_id = sat
|
||||||
|
elif isinstance(sat, str):
|
||||||
|
stripped = sat.strip()
|
||||||
|
if stripped.isdigit():
|
||||||
|
norad_id = int(stripped)
|
||||||
|
else:
|
||||||
|
sat_key = stripped
|
||||||
|
|
||||||
|
if norad_id is not None:
|
||||||
|
tracked = tracked_by_norad.get(norad_id)
|
||||||
|
sat_key = _BUILTIN_NORAD_TO_KEY.get(norad_id) or (tracked.get('name') if tracked else str(norad_id))
|
||||||
|
else:
|
||||||
|
normalized = _normalize_satellite_name(sat_key)
|
||||||
|
tracked = tracked_by_name.get(normalized)
|
||||||
|
if tracked:
|
||||||
|
try:
|
||||||
|
norad_id = int(tracked['norad_id'])
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
norad_id = None
|
||||||
|
sat_key = tracked.get('name') or sat_key
|
||||||
|
|
||||||
|
tle_data = None
|
||||||
|
candidate_keys: list[str] = []
|
||||||
|
if sat_key:
|
||||||
|
candidate_keys.extend([
|
||||||
|
sat_key,
|
||||||
|
_normalize_satellite_name(sat_key),
|
||||||
|
])
|
||||||
|
if tracked and tracked.get('name'):
|
||||||
|
candidate_keys.extend([
|
||||||
|
tracked['name'],
|
||||||
|
_normalize_satellite_name(tracked['name']),
|
||||||
|
])
|
||||||
|
|
||||||
|
seen: set[str] = set()
|
||||||
|
for key in candidate_keys:
|
||||||
|
norm = _normalize_satellite_name(key)
|
||||||
|
if norm in seen:
|
||||||
|
continue
|
||||||
|
seen.add(norm)
|
||||||
|
if key in _tle_cache:
|
||||||
|
tle_data = _tle_cache[key]
|
||||||
|
break
|
||||||
|
if norm in _tle_cache:
|
||||||
|
tle_data = _tle_cache[norm]
|
||||||
|
break
|
||||||
|
|
||||||
|
if tle_data is None and tracked and tracked.get('tle_line1') and tracked.get('tle_line2'):
|
||||||
|
display_name = tracked.get('name') or sat_key or str(norad_id or 'UNKNOWN')
|
||||||
|
tle_data = (display_name, tracked['tle_line1'], tracked['tle_line2'])
|
||||||
|
_tle_cache[_normalize_satellite_name(display_name)] = tle_data
|
||||||
|
|
||||||
|
if tle_data is None and sat_key:
|
||||||
|
normalized = _normalize_satellite_name(sat_key)
|
||||||
|
for key, value in _tle_cache.items():
|
||||||
|
if key == normalized or _normalize_satellite_name(value[0]) == normalized:
|
||||||
|
tle_data = value
|
||||||
|
break
|
||||||
|
|
||||||
|
display_name = _BUILTIN_NORAD_TO_KEY.get(norad_id or -1)
|
||||||
|
if not display_name:
|
||||||
|
display_name = (tracked.get('name') if tracked else None) or (tle_data[0] if tle_data else None) or (sat_key if sat_key else str(norad_id or 'UNKNOWN'))
|
||||||
|
return display_name, norad_id, tle_data
|
||||||
|
|
||||||
|
|
||||||
|
def _make_pass_cache_key(
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
hours: int,
|
||||||
|
min_el: float,
|
||||||
|
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]],
|
||||||
|
) -> tuple:
|
||||||
|
"""Build a stable cache key for predicted passes."""
|
||||||
|
return (
|
||||||
|
round(lat, 4),
|
||||||
|
round(lon, 4),
|
||||||
|
int(hours),
|
||||||
|
round(float(min_el), 1),
|
||||||
|
tuple(
|
||||||
|
(
|
||||||
|
sat_name,
|
||||||
|
norad_id,
|
||||||
|
tle_data[1][:32],
|
||||||
|
tle_data[2][:32],
|
||||||
|
)
|
||||||
|
for sat_name, norad_id, tle_data in resolved_satellites
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_satellite_tracker():
|
||||||
|
"""Background thread: push live satellite positions to satellite_queue every second."""
|
||||||
|
import app as app_module
|
||||||
|
|
||||||
|
try:
|
||||||
|
from skyfield.api import EarthSatellite, wgs84
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("skyfield not installed; satellite tracker thread will not run")
|
||||||
|
return
|
||||||
|
|
||||||
|
ts = _get_timescale()
|
||||||
|
logger.info("Satellite tracker thread started")
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
now = ts.now()
|
||||||
|
now_dt = now.utc_datetime()
|
||||||
|
|
||||||
|
tracked = get_tracked_satellites(enabled_only=True)
|
||||||
|
positions = []
|
||||||
|
|
||||||
|
for sat_rec in tracked:
|
||||||
|
sat_name = sat_rec['name']
|
||||||
|
norad_id = sat_rec.get('norad_id', 0)
|
||||||
|
tle1 = sat_rec.get('tle_line1')
|
||||||
|
tle2 = sat_rec.get('tle_line2')
|
||||||
|
if not tle1 or not tle2:
|
||||||
|
# Fall back to TLE cache. Try the builtin NORAD-ID key first
|
||||||
|
# (e.g. 'ISS'), then the name-derived key as a last resort.
|
||||||
|
try:
|
||||||
|
norad_int = int(norad_id)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
norad_int = 0
|
||||||
|
builtin_key = _BUILTIN_NORAD_TO_KEY.get(norad_int)
|
||||||
|
cache_key = builtin_key if (builtin_key and builtin_key in _tle_cache) else sat_name.replace(' ', '-').upper()
|
||||||
|
if cache_key not in _tle_cache:
|
||||||
|
continue
|
||||||
|
tle_entry = _tle_cache[cache_key]
|
||||||
|
tle1 = tle_entry[1]
|
||||||
|
tle2 = tle_entry[2]
|
||||||
|
|
||||||
|
try:
|
||||||
|
satellite = EarthSatellite(tle1, tle2, sat_name, ts)
|
||||||
|
geocentric = satellite.at(now)
|
||||||
|
subpoint = wgs84.subpoint(geocentric)
|
||||||
|
|
||||||
|
# SSE stream is server-wide and cannot know per-client observer
|
||||||
|
# location. Observer-relative fields (elevation, azimuth, distance,
|
||||||
|
# visible) are intentionally omitted here — the per-client HTTP poll
|
||||||
|
# at /satellite/position owns those using the client's actual location.
|
||||||
|
pos = {
|
||||||
|
'satellite': sat_name,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'lat': float(subpoint.latitude.degrees),
|
||||||
|
'lon': float(subpoint.longitude.degrees),
|
||||||
|
'altitude': float(subpoint.elevation.km),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ground track with caching (90 points, TTL 1800s).
|
||||||
|
# If the cache is stale, kick off background computation so the
|
||||||
|
# 1Hz tracker loop is not blocked. The client retains the previous
|
||||||
|
# track via SSE merge until the new one arrives next tick.
|
||||||
|
cache_key_track = (sat_name, tle1[:20])
|
||||||
|
cached = _track_cache.get(cache_key_track)
|
||||||
|
if cached and (time.time() - cached[1]) < _TRACK_CACHE_TTL:
|
||||||
|
pos['groundTrack'] = cached[0]
|
||||||
|
elif cache_key_track not in _track_in_progress:
|
||||||
|
_track_in_progress.add(cache_key_track)
|
||||||
|
_sat_ref = satellite
|
||||||
|
_ts_ref = ts
|
||||||
|
_now_dt_ref = now_dt
|
||||||
|
|
||||||
|
def _compute_track(_sat=_sat_ref, _ts=_ts_ref, _now_dt=_now_dt_ref, _key=cache_key_track):
|
||||||
|
try:
|
||||||
|
track = []
|
||||||
|
for minutes_offset in range(-45, 46, 1):
|
||||||
|
t_point = _ts.utc(_now_dt + timedelta(minutes=minutes_offset))
|
||||||
|
try:
|
||||||
|
geo = _sat.at(t_point)
|
||||||
|
sp = wgs84.subpoint(geo)
|
||||||
|
track.append({
|
||||||
|
'lat': float(sp.latitude.degrees),
|
||||||
|
'lon': float(sp.longitude.degrees),
|
||||||
|
'past': minutes_offset < 0,
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
_track_cache[_key] = (track, time.time())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
_track_in_progress.discard(_key)
|
||||||
|
|
||||||
|
_track_executor.submit(_compute_track)
|
||||||
|
# groundTrack omitted this tick; frontend retains prior value
|
||||||
|
|
||||||
|
positions.append(pos)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if positions:
|
||||||
|
msg = {
|
||||||
|
'type': 'positions',
|
||||||
|
'positions': positions,
|
||||||
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
app_module.satellite_queue.put_nowait(msg)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Satellite tracker error: {e}")
|
||||||
|
|
||||||
|
time.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
|
_TLE_REFRESH_INTERVAL_SECONDS = 24 * 60 * 60 # 24 hours
|
||||||
|
|
||||||
|
|
||||||
def init_tle_auto_refresh():
|
def init_tle_auto_refresh():
|
||||||
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
"""Initialize TLE auto-refresh. Called by app.py after initialization."""
|
||||||
import threading
|
def _schedule_next_tle_refresh(delay: float = _TLE_REFRESH_INTERVAL_SECONDS) -> None:
|
||||||
|
t = threading.Timer(delay, _auto_refresh_tle)
|
||||||
|
t.daemon = True
|
||||||
|
t.start()
|
||||||
|
|
||||||
def _auto_refresh_tle():
|
def _auto_refresh_tle():
|
||||||
try:
|
try:
|
||||||
@@ -76,10 +342,22 @@ def init_tle_auto_refresh():
|
|||||||
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
logger.info(f"Auto-refreshed TLE data for: {', '.join(updated)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning(f"Auto TLE refresh failed: {e}")
|
logger.warning(f"Auto TLE refresh failed: {e}")
|
||||||
|
finally:
|
||||||
|
# Schedule next refresh regardless of success/failure
|
||||||
|
_schedule_next_tle_refresh()
|
||||||
|
|
||||||
# Start auto-refresh in background
|
# First refresh 2 seconds after startup, then every 24 hours
|
||||||
threading.Timer(2.0, _auto_refresh_tle).start()
|
threading.Timer(2.0, _auto_refresh_tle).start()
|
||||||
logger.info("TLE auto-refresh scheduled")
|
logger.info("TLE auto-refresh scheduled (24h interval)")
|
||||||
|
|
||||||
|
# Start live position tracker thread
|
||||||
|
tracker_thread = threading.Thread(
|
||||||
|
target=_start_satellite_tracker,
|
||||||
|
daemon=True,
|
||||||
|
name='satellite-tracker',
|
||||||
|
)
|
||||||
|
tracker_thread.start()
|
||||||
|
logger.info("Satellite tracker thread launched")
|
||||||
|
|
||||||
|
|
||||||
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
|
def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float | None = None) -> dict | None:
|
||||||
@@ -123,6 +401,7 @@ def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float |
|
|||||||
|
|
||||||
result = {
|
result = {
|
||||||
'satellite': 'ISS',
|
'satellite': 'ISS',
|
||||||
|
'norad_id': 25544,
|
||||||
'lat': iss_lat,
|
'lat': iss_lat,
|
||||||
'lon': iss_lon,
|
'lon': iss_lon,
|
||||||
'altitude': iss_alt,
|
'altitude': iss_alt,
|
||||||
@@ -174,18 +453,21 @@ def _fetch_iss_realtime(observer_lat: float | None = None, observer_lon: float |
|
|||||||
def satellite_dashboard():
|
def satellite_dashboard():
|
||||||
"""Popout satellite tracking dashboard."""
|
"""Popout satellite tracking dashboard."""
|
||||||
embedded = request.args.get('embedded', 'false') == 'true'
|
embedded = request.args.get('embedded', 'false') == 'true'
|
||||||
return render_template(
|
response = make_response(render_template(
|
||||||
'satellite_dashboard.html',
|
'satellite_dashboard.html',
|
||||||
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
shared_observer_location=SHARED_OBSERVER_LOCATION_ENABLED,
|
||||||
embedded=embedded,
|
embedded=embedded,
|
||||||
)
|
))
|
||||||
|
response.headers['Cache-Control'] = 'no-store, no-cache, must-revalidate, max-age=0'
|
||||||
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
response.headers['Expires'] = '0'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/predict', methods=['POST'])
|
@satellite_bp.route('/predict', methods=['POST'])
|
||||||
def predict_passes():
|
def predict_passes():
|
||||||
"""Calculate satellite passes using skyfield."""
|
"""Calculate satellite passes using skyfield."""
|
||||||
try:
|
try:
|
||||||
from skyfield.almanac import find_discrete
|
|
||||||
from skyfield.api import EarthSatellite, wgs84
|
from skyfield.api import EarthSatellite, wgs84
|
||||||
except ImportError:
|
except ImportError:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -193,10 +475,12 @@ def predict_passes():
|
|||||||
'message': 'skyfield library not installed. Run: pip install skyfield'
|
'message': 'skyfield library not installed. Run: pip install skyfield'
|
||||||
}), 503
|
}), 503
|
||||||
|
|
||||||
|
from utils.satellite_predict import predict_passes as _predict_passes
|
||||||
|
|
||||||
data = request.json or {}
|
data = request.json or {}
|
||||||
|
|
||||||
# Validate inputs
|
|
||||||
try:
|
try:
|
||||||
|
# Validate inputs
|
||||||
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
lat = validate_latitude(data.get('latitude', data.get('lat', 51.5074)))
|
||||||
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
lon = validate_longitude(data.get('longitude', data.get('lon', -0.1278)))
|
||||||
hours = validate_hours(data.get('hours', 24))
|
hours = validate_hours(data.get('hours', 24))
|
||||||
@@ -204,135 +488,108 @@ def predict_passes():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
return api_error(str(e), 400)
|
||||||
|
|
||||||
norad_to_name = {
|
try:
|
||||||
25544: 'ISS',
|
sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4'])
|
||||||
40069: 'METEOR-M2',
|
passes = []
|
||||||
57166: 'METEOR-M2-3'
|
colors = {
|
||||||
}
|
'ISS': '#00ffff',
|
||||||
|
'METEOR-M2': '#9370DB',
|
||||||
|
'METEOR-M2-3': '#ff00ff',
|
||||||
|
'METEOR-M2-4': '#00ff88',
|
||||||
|
}
|
||||||
|
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
|
||||||
|
|
||||||
sat_input = data.get('satellites', ['ISS', 'METEOR-M2', 'METEOR-M2-3'])
|
resolved_satellites: list[tuple[str, int, tuple[str, str, str]]] = []
|
||||||
satellites = []
|
for sat in sat_input:
|
||||||
for sat in sat_input:
|
sat_name, norad_id, tle_data = _resolve_satellite_request(
|
||||||
if isinstance(sat, int) and sat in norad_to_name:
|
sat,
|
||||||
satellites.append(norad_to_name[sat])
|
tracked_by_norad,
|
||||||
else:
|
tracked_by_name,
|
||||||
satellites.append(sat)
|
)
|
||||||
|
if not tle_data:
|
||||||
|
continue
|
||||||
|
resolved_satellites.append((sat_name, norad_id or 0, tle_data))
|
||||||
|
|
||||||
passes = []
|
if not resolved_satellites:
|
||||||
colors = {
|
return jsonify({
|
||||||
'ISS': '#00ffff',
|
'status': 'success',
|
||||||
'METEOR-M2': '#9370DB',
|
'passes': [],
|
||||||
'METEOR-M2-3': '#ff00ff'
|
'cached': False,
|
||||||
}
|
})
|
||||||
name_to_norad = {v: k for k, v in norad_to_name.items()}
|
|
||||||
|
|
||||||
ts = _get_timescale()
|
cache_key = _make_pass_cache_key(lat, lon, hours, min_el, resolved_satellites)
|
||||||
observer = wgs84.latlon(lat, lon)
|
cached = _pass_cache.get(cache_key)
|
||||||
|
now_ts = time.time()
|
||||||
|
if cached and (now_ts - cached[1]) < _PASS_CACHE_TTL:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
|
'passes': cached[0],
|
||||||
|
'cached': True,
|
||||||
|
})
|
||||||
|
|
||||||
t0 = ts.now()
|
ts = _get_timescale()
|
||||||
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
t0 = ts.now()
|
||||||
for sat_name in satellites:
|
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
|
||||||
if sat_name not in _tle_cache:
|
|
||||||
continue
|
|
||||||
|
|
||||||
tle_data = _tle_cache[sat_name]
|
|
||||||
try:
|
|
||||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
def above_horizon(t):
|
|
||||||
diff = satellite - observer
|
|
||||||
topocentric = diff.at(t)
|
|
||||||
alt, _, _ = topocentric.altaz()
|
|
||||||
return alt.degrees > 0
|
|
||||||
|
|
||||||
above_horizon.step_days = 1/720
|
|
||||||
|
|
||||||
try:
|
|
||||||
times, events = find_discrete(t0, t1, above_horizon)
|
|
||||||
except Exception:
|
|
||||||
continue
|
|
||||||
|
|
||||||
i = 0
|
|
||||||
while i < len(times):
|
|
||||||
if i < len(events) and events[i]:
|
|
||||||
rise_time = times[i]
|
|
||||||
set_time = None
|
|
||||||
for j in range(i + 1, len(times)):
|
|
||||||
if not events[j]:
|
|
||||||
set_time = times[j]
|
|
||||||
i = j
|
|
||||||
break
|
|
||||||
|
|
||||||
if set_time is None:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
trajectory = []
|
|
||||||
max_elevation = 0
|
|
||||||
num_points = 30
|
|
||||||
|
|
||||||
duration_seconds = (set_time.utc_datetime() - rise_time.utc_datetime()).total_seconds()
|
|
||||||
|
|
||||||
for k in range(num_points):
|
|
||||||
frac = k / (num_points - 1)
|
|
||||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
|
||||||
|
|
||||||
|
for sat_name, norad_id, tle_data in resolved_satellites:
|
||||||
|
current_pos = None
|
||||||
|
try:
|
||||||
|
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||||
|
geo = satellite.at(t0)
|
||||||
|
sp = wgs84.subpoint(geo)
|
||||||
|
current_pos = {
|
||||||
|
'lat': float(sp.latitude.degrees),
|
||||||
|
'lon': float(sp.longitude.degrees),
|
||||||
|
'altitude': float(sp.elevation.km),
|
||||||
|
}
|
||||||
|
# Add observer-relative data using the request's observer location
|
||||||
|
try:
|
||||||
diff = satellite - observer
|
diff = satellite - observer
|
||||||
topocentric = diff.at(t_point)
|
topo = diff.at(t0)
|
||||||
alt, az, _ = topocentric.altaz()
|
alt_deg, az_deg, dist_km = topo.altaz()
|
||||||
|
current_pos['elevation'] = round(float(alt_deg.degrees), 1)
|
||||||
|
current_pos['azimuth'] = round(float(az_deg.degrees), 1)
|
||||||
|
current_pos['distance'] = round(float(dist_km.km), 1)
|
||||||
|
current_pos['visible'] = bool(alt_deg.degrees > 0)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
el = alt.degrees
|
sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el)
|
||||||
azimuth = az.degrees
|
for p in sat_passes:
|
||||||
|
p['satellite'] = sat_name
|
||||||
|
p['norad'] = norad_id
|
||||||
|
p['color'] = colors.get(sat_name, '#00ff00')
|
||||||
|
if current_pos:
|
||||||
|
p['currentPos'] = current_pos
|
||||||
|
passes.extend(sat_passes)
|
||||||
|
|
||||||
if el > max_elevation:
|
passes.sort(key=lambda p: p['startTimeISO'])
|
||||||
max_elevation = el
|
# Only cache non-empty results to avoid serving a stale empty response
|
||||||
|
# on the next request (which could happen if TLEs were too old to produce
|
||||||
|
# any events — the auto-refresh will update them shortly after startup).
|
||||||
|
if passes:
|
||||||
|
_pass_cache[cache_key] = (passes, now_ts)
|
||||||
|
|
||||||
trajectory.append({'el': float(max(0, el)), 'az': float(azimuth)})
|
return jsonify({
|
||||||
|
'status': 'success',
|
||||||
if max_elevation >= min_el:
|
'passes': passes,
|
||||||
duration_minutes = int(duration_seconds / 60)
|
'cached': False,
|
||||||
|
})
|
||||||
ground_track = []
|
except Exception as exc:
|
||||||
for k in range(60):
|
logger.exception('Satellite pass calculation failed')
|
||||||
frac = k / 59
|
if 'cache_key' in locals():
|
||||||
t_point = ts.utc(rise_time.utc_datetime() + timedelta(seconds=duration_seconds * frac))
|
stale_cached = _pass_cache.get(cache_key)
|
||||||
geocentric = satellite.at(t_point)
|
if stale_cached and stale_cached[0]:
|
||||||
subpoint = wgs84.subpoint(geocentric)
|
return jsonify({
|
||||||
ground_track.append({
|
'status': 'success',
|
||||||
'lat': float(subpoint.latitude.degrees),
|
'passes': stale_cached[0],
|
||||||
'lon': float(subpoint.longitude.degrees)
|
'cached': True,
|
||||||
})
|
'stale': True,
|
||||||
|
})
|
||||||
current_geo = satellite.at(ts.now())
|
return api_error(f'Failed to calculate passes: {exc}', 500)
|
||||||
current_subpoint = wgs84.subpoint(current_geo)
|
|
||||||
|
|
||||||
passes.append({
|
|
||||||
'satellite': sat_name,
|
|
||||||
'norad': name_to_norad.get(sat_name, 0),
|
|
||||||
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
|
|
||||||
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
|
||||||
'maxEl': float(round(max_elevation, 1)),
|
|
||||||
'duration': int(duration_minutes),
|
|
||||||
'trajectory': trajectory,
|
|
||||||
'groundTrack': ground_track,
|
|
||||||
'currentPos': {
|
|
||||||
'lat': float(current_subpoint.latitude.degrees),
|
|
||||||
'lon': float(current_subpoint.longitude.degrees)
|
|
||||||
},
|
|
||||||
'color': colors.get(sat_name, '#00ff00')
|
|
||||||
})
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
passes.sort(key=lambda p: p['startTime'])
|
|
||||||
|
|
||||||
return jsonify({
|
|
||||||
'status': 'success',
|
|
||||||
'passes': passes
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
@satellite_bp.route('/position', methods=['POST'])
|
@satellite_bp.route('/position', methods=['POST'])
|
||||||
@@ -354,35 +611,30 @@ def get_satellite_position():
|
|||||||
|
|
||||||
sat_input = data.get('satellites', [])
|
sat_input = data.get('satellites', [])
|
||||||
include_track = bool(data.get('includeTrack', True))
|
include_track = bool(data.get('includeTrack', True))
|
||||||
|
prefer_realtime_api = bool(data.get('preferRealtimeApi', False))
|
||||||
|
|
||||||
norad_to_name = {
|
|
||||||
25544: 'ISS',
|
|
||||||
40069: 'METEOR-M2',
|
|
||||||
57166: 'METEOR-M2-3'
|
|
||||||
}
|
|
||||||
|
|
||||||
satellites = []
|
|
||||||
for sat in sat_input:
|
|
||||||
if isinstance(sat, int) and sat in norad_to_name:
|
|
||||||
satellites.append(norad_to_name[sat])
|
|
||||||
else:
|
|
||||||
satellites.append(sat)
|
|
||||||
|
|
||||||
ts = _get_timescale()
|
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
now = ts.now()
|
ts = None
|
||||||
now_dt = now.utc_datetime()
|
now = None
|
||||||
|
now_dt = None
|
||||||
|
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
|
||||||
|
|
||||||
positions = []
|
positions = []
|
||||||
|
|
||||||
for sat_name in satellites:
|
for sat in sat_input:
|
||||||
# Special handling for ISS - use real-time API for accurate position
|
sat_name, norad_id, tle_data = _resolve_satellite_request(sat, tracked_by_norad, tracked_by_name)
|
||||||
if sat_name == 'ISS':
|
# Optional special handling for ISS. The dashboard does not enable this
|
||||||
|
# because external API latency can make live updates stall.
|
||||||
|
if prefer_realtime_api and (norad_id == 25544 or sat_name == 'ISS'):
|
||||||
iss_data = _fetch_iss_realtime(lat, lon)
|
iss_data = _fetch_iss_realtime(lat, lon)
|
||||||
if iss_data:
|
if iss_data:
|
||||||
# Add orbit track if requested (using TLE for track prediction)
|
# Add orbit track if requested (using TLE for track prediction)
|
||||||
if include_track and 'ISS' in _tle_cache:
|
if include_track and 'ISS' in _tle_cache:
|
||||||
try:
|
try:
|
||||||
|
if ts is None:
|
||||||
|
ts = _get_timescale()
|
||||||
|
now = ts.now()
|
||||||
|
now_dt = now.utc_datetime()
|
||||||
tle_data = _tle_cache['ISS']
|
tle_data = _tle_cache['ISS']
|
||||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||||
orbit_track = []
|
orbit_track = []
|
||||||
@@ -402,14 +654,17 @@ def get_satellite_position():
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
positions.append(iss_data)
|
positions.append(iss_data)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Other satellites - use TLE data
|
# Other satellites - use TLE data
|
||||||
if sat_name not in _tle_cache:
|
if not tle_data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tle_data = _tle_cache[sat_name]
|
|
||||||
try:
|
try:
|
||||||
|
if ts is None:
|
||||||
|
ts = _get_timescale()
|
||||||
|
now = ts.now()
|
||||||
|
now_dt = now.utc_datetime()
|
||||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||||
|
|
||||||
geocentric = satellite.at(now)
|
geocentric = satellite.at(now)
|
||||||
@@ -421,9 +676,10 @@ def get_satellite_position():
|
|||||||
|
|
||||||
pos_data = {
|
pos_data = {
|
||||||
'satellite': sat_name,
|
'satellite': sat_name,
|
||||||
|
'norad_id': norad_id,
|
||||||
'lat': float(subpoint.latitude.degrees),
|
'lat': float(subpoint.latitude.degrees),
|
||||||
'lon': float(subpoint.longitude.degrees),
|
'lon': float(subpoint.longitude.degrees),
|
||||||
'altitude': float(geocentric.distance().km - 6371),
|
'altitude': float(subpoint.elevation.km),
|
||||||
'elevation': float(alt.degrees),
|
'elevation': float(alt.degrees),
|
||||||
'azimuth': float(az.degrees),
|
'azimuth': float(az.degrees),
|
||||||
'distance': float(distance.km),
|
'distance': float(distance.km),
|
||||||
@@ -446,6 +702,7 @@ def get_satellite_position():
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
pos_data['track'] = orbit_track
|
pos_data['track'] = orbit_track
|
||||||
|
pos_data['groundTrack'] = orbit_track
|
||||||
|
|
||||||
positions.append(pos_data)
|
positions.append(pos_data)
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -458,6 +715,49 @@ def get_satellite_position():
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/transmitters/<int:norad_id>')
|
||||||
|
def get_transmitters_endpoint(norad_id: int):
|
||||||
|
"""Return SatNOGS transmitter data for a satellite by NORAD ID."""
|
||||||
|
from utils.satnogs import get_transmitters
|
||||||
|
transmitters = get_transmitters(norad_id)
|
||||||
|
return jsonify({'status': 'success', 'norad_id': norad_id, 'transmitters': transmitters})
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/parse-packet', methods=['POST'])
|
||||||
|
def parse_packet():
|
||||||
|
"""Parse a raw satellite telemetry packet (base64-encoded)."""
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from utils.satellite_telemetry import auto_parse
|
||||||
|
data = request.json or {}
|
||||||
|
try:
|
||||||
|
raw_bytes = base64.b64decode(data.get('data', ''))
|
||||||
|
except Exception:
|
||||||
|
return api_error('Invalid base64 data', 400)
|
||||||
|
result = auto_parse(raw_bytes)
|
||||||
|
return jsonify({'status': 'success', 'parsed': result})
|
||||||
|
|
||||||
|
|
||||||
|
@satellite_bp.route('/stream_satellite')
|
||||||
|
def stream_satellite() -> Response:
|
||||||
|
"""SSE endpoint streaming live satellite positions from the background tracker."""
|
||||||
|
import app as app_module
|
||||||
|
|
||||||
|
response = Response(
|
||||||
|
sse_stream_fanout(
|
||||||
|
source_queue=app_module.satellite_queue,
|
||||||
|
channel_key='satellite',
|
||||||
|
timeout=1.0,
|
||||||
|
keepalive_interval=30.0,
|
||||||
|
),
|
||||||
|
mimetype='text/event-stream',
|
||||||
|
)
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
def refresh_tle_data() -> list:
|
def refresh_tle_data() -> list:
|
||||||
"""
|
"""
|
||||||
Refresh TLE data from CelesTrak.
|
Refresh TLE data from CelesTrak.
|
||||||
|
|||||||
@@ -2,9 +2,13 @@
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request
|
from flask import Blueprint, Response, jsonify, request
|
||||||
|
|
||||||
@@ -17,10 +21,81 @@ from utils.database import (
|
|||||||
)
|
)
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.responses import api_error, api_success
|
from utils.responses import api_error, api_success
|
||||||
|
from utils.validation import validate_latitude, validate_longitude
|
||||||
|
|
||||||
logger = get_logger('intercept.settings')
|
logger = get_logger('intercept.settings')
|
||||||
|
|
||||||
settings_bp = Blueprint('settings', __name__, url_prefix='/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'])
|
@settings_bp.route('', methods=['GET'])
|
||||||
@@ -109,6 +184,34 @@ def delete_single_setting(key: str) -> Response:
|
|||||||
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)
|
||||||
|
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Device Correlation Endpoints
|
# Device Correlation Endpoints
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|||||||
+1
-1
@@ -478,7 +478,7 @@ def _get_timescale():
|
|||||||
with _timescale_lock:
|
with _timescale_lock:
|
||||||
if _timescale is None:
|
if _timescale is None:
|
||||||
from skyfield.api import load
|
from skyfield.api import load
|
||||||
_timescale = load.timescale()
|
_timescale = load.timescale(builtin=True)
|
||||||
return _timescale
|
return _timescale
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+10
-6
@@ -36,7 +36,8 @@ logger = logging.getLogger('intercept.tscm')
|
|||||||
@tscm_bp.route('/status')
|
@tscm_bp.route('/status')
|
||||||
def tscm_status():
|
def tscm_status():
|
||||||
"""Check if any TSCM operation is currently running."""
|
"""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'])
|
@tscm_bp.route('/sweep/start', methods=['POST'])
|
||||||
@@ -95,14 +96,15 @@ def stop_sweep():
|
|||||||
@tscm_bp.route('/sweep/status')
|
@tscm_bp.route('/sweep/status')
|
||||||
def sweep_status():
|
def sweep_status():
|
||||||
"""Get current sweep status."""
|
"""Get current sweep status."""
|
||||||
|
import routes.tscm as _tscm_pkg
|
||||||
|
|
||||||
status = {
|
status = {
|
||||||
'running': _sweep_running,
|
'running': _tscm_pkg._sweep_running,
|
||||||
'sweep_id': _current_sweep_id,
|
'sweep_id': _tscm_pkg._current_sweep_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
if _current_sweep_id:
|
if _tscm_pkg._current_sweep_id:
|
||||||
sweep = get_tscm_sweep(_current_sweep_id)
|
sweep = get_tscm_sweep(_tscm_pkg._current_sweep_id)
|
||||||
if sweep:
|
if sweep:
|
||||||
status['sweep'] = sweep
|
status['sweep'] = sweep
|
||||||
|
|
||||||
@@ -113,12 +115,14 @@ def sweep_status():
|
|||||||
def sweep_stream():
|
def sweep_stream():
|
||||||
"""SSE stream for real-time sweep updates."""
|
"""SSE stream for real-time sweep updates."""
|
||||||
|
|
||||||
|
import routes.tscm as _tscm_pkg
|
||||||
|
|
||||||
def _on_msg(msg: dict[str, Any]) -> None:
|
def _on_msg(msg: dict[str, Any]) -> None:
|
||||||
process_event('tscm', msg, msg.get('type'))
|
process_event('tscm', msg, msg.get('type'))
|
||||||
|
|
||||||
return Response(
|
return Response(
|
||||||
sse_stream_fanout(
|
sse_stream_fanout(
|
||||||
source_queue=tscm_queue,
|
source_queue=_tscm_pkg.tscm_queue,
|
||||||
channel_key='tscm',
|
channel_key='tscm',
|
||||||
timeout=1.0,
|
timeout=1.0,
|
||||||
keepalive_interval=30.0,
|
keepalive_interval=30.0,
|
||||||
|
|||||||
+122
-12
@@ -1,12 +1,15 @@
|
|||||||
"""Weather Satellite decoder routes.
|
"""Weather Satellite decoder routes.
|
||||||
|
|
||||||
Provides endpoints for capturing and decoding weather satellite images
|
Provides endpoints for capturing and decoding Meteor LRPT weather
|
||||||
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
imagery, including shared results produced by the ground-station
|
||||||
|
observation pipeline.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
import queue
|
import queue
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Blueprint, Response, jsonify, request, send_file
|
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
|
# Queue for SSE progress streaming
|
||||||
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
_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:
|
def _progress_callback(progress: CaptureProgress) -> None:
|
||||||
"""Callback to queue progress updates for SSE stream."""
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
@@ -120,7 +132,7 @@ def start_capture():
|
|||||||
|
|
||||||
JSON body:
|
JSON body:
|
||||||
{
|
{
|
||||||
"satellite": "NOAA-18", // Required: satellite key
|
"satellite": "METEOR-M2-3", // Required: satellite key
|
||||||
"device": 0, // RTL-SDR device index (default: 0)
|
"device": 0, // RTL-SDR device index (default: 0)
|
||||||
"gain": 40.0, // SDR gain in dB (default: 40)
|
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||||
"bias_t": false // Enable bias-T for LNA (default: false)
|
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||||
@@ -248,7 +260,7 @@ def test_decode():
|
|||||||
|
|
||||||
JSON body:
|
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
|
"input_file": "/path/to/file", // Required: server-side file path
|
||||||
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
"sample_rate": 1000000 // Sample rate in Hz (default: 1000000)
|
||||||
}
|
}
|
||||||
@@ -292,14 +304,13 @@ def test_decode():
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
input_path = Path(input_file)
|
input_path = Path(input_file)
|
||||||
|
|
||||||
# Security: restrict to data directory (anchored to app root, not CWD)
|
# Restrict test-decode to application-owned sample and recording paths.
|
||||||
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
|
||||||
try:
|
try:
|
||||||
resolved = input_path.resolve()
|
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({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'input_file must be under the data/ directory'
|
'message': 'input_file must be under INTERCEPT data or ground-station recordings'
|
||||||
}), 403
|
}), 403
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -389,21 +400,34 @@ def list_images():
|
|||||||
JSON with list of decoded images.
|
JSON with list of decoded images.
|
||||||
"""
|
"""
|
||||||
decoder = get_weather_sat_decoder()
|
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
|
# Filter by satellite if specified
|
||||||
satellite_filter = request.args.get('satellite')
|
satellite_filter = request.args.get('satellite')
|
||||||
if satellite_filter:
|
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
|
# Apply limit
|
||||||
limit = request.args.get('limit', type=int)
|
limit = request.args.get('limit', type=int)
|
||||||
if limit and limit > 0:
|
if limit and limit > 0:
|
||||||
images = images[-limit:]
|
images = images[:limit]
|
||||||
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
'images': [img.to_dict() for img in images],
|
'images': images,
|
||||||
'count': len(images),
|
'count': len(images),
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -436,6 +460,36 @@ def get_image(filename: str):
|
|||||||
return send_file(image_path, mimetype=mimetype)
|
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'])
|
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||||
def delete_image(filename: str):
|
def delete_image(filename: str):
|
||||||
"""Delete a decoded image.
|
"""Delete a decoded image.
|
||||||
@@ -469,6 +523,62 @@ def delete_all_images():
|
|||||||
return jsonify({'status': 'ok', 'deleted': count})
|
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')
|
@weather_sat_bp.route('/stream')
|
||||||
def stream_progress():
|
def stream_progress():
|
||||||
"""SSE stream of capture/decode progress.
|
"""SSE stream of capture/decode progress.
|
||||||
|
|||||||
+12
-7
@@ -673,13 +673,6 @@ def start_wifi_scan():
|
|||||||
os.remove(f)
|
os.remove(f)
|
||||||
|
|
||||||
airodump_path = get_tool_path('airodump-ng')
|
airodump_path = get_tool_path('airodump-ng')
|
||||||
cmd = [
|
|
||||||
airodump_path,
|
|
||||||
'-w', csv_path,
|
|
||||||
'--output-format', 'csv,pcap',
|
|
||||||
'--band', band,
|
|
||||||
interface
|
|
||||||
]
|
|
||||||
|
|
||||||
channel_list = None
|
channel_list = None
|
||||||
if channels:
|
if channels:
|
||||||
@@ -688,10 +681,22 @@ def start_wifi_scan():
|
|||||||
except ValueError as e:
|
except ValueError as e:
|
||||||
return api_error(str(e), 400)
|
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:
|
if channel_list:
|
||||||
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
cmd.extend(['-c', ','.join(str(c) for c in channel_list)])
|
||||||
elif channel:
|
elif channel:
|
||||||
cmd.extend(['-c', str(channel)])
|
cmd.extend(['-c', str(channel)])
|
||||||
|
else:
|
||||||
|
cmd.extend(['--band', band])
|
||||||
|
|
||||||
|
cmd.append(interface)
|
||||||
|
|
||||||
logger.info(f"Running: {' '.join(cmd)}")
|
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_optional "dumpvdl2" "VDL2 decoder" dumpvdl2
|
||||||
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
check_required "AIS-catcher" "AIS vessel decoder" AIS-catcher aiscatcher
|
||||||
check_optional "satdump" "Weather satellite decoder (NOAA/Meteor)" satdump
|
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
|
echo
|
||||||
info "GPS:"
|
info "GPS:"
|
||||||
check_required "gpsd" "GPS daemon" gpsd
|
check_required "gpsd" "GPS daemon" gpsd
|
||||||
@@ -487,6 +491,16 @@ import sys
|
|||||||
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
raise SystemExit(0 if sys.version_info >= (3,9) else 1)
|
||||||
PY
|
PY
|
||||||
ok "Python version OK (>= 3.9)"
|
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() {
|
install_python_deps() {
|
||||||
@@ -520,8 +534,11 @@ install_python_deps() {
|
|||||||
source venv/bin/activate
|
source venv/bin/activate
|
||||||
local PIP="venv/bin/python -m pip"
|
local PIP="venv/bin/python -m pip"
|
||||||
local PY="venv/bin/python"
|
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"
|
warn "pip/setuptools/wheel upgrade failed - continuing with existing versions"
|
||||||
else
|
else
|
||||||
ok "Upgraded pip tooling"
|
ok "Upgraded pip tooling"
|
||||||
@@ -530,24 +547,39 @@ install_python_deps() {
|
|||||||
progress "Installing Python dependencies"
|
progress "Installing Python dependencies"
|
||||||
|
|
||||||
info "Installing core packages..."
|
info "Installing core packages..."
|
||||||
$PIP install --quiet "flask>=3.0.0" "flask-limiter>=2.5.4" "requests>=2.28.0" \
|
$PIP install $PIP_OPTS "flask>=3.0.0" "flask-wtf>=1.2.0" "flask-compress>=1.15" \
|
||||||
"Werkzeug>=3.1.5" "pyserial>=3.5" 2>/dev/null || true
|
"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 || {
|
# Verify core packages are installed by checking pip's reported list (avoids hanging imports)
|
||||||
fail "Critical Python packages (flask, requests, flask-limiter) not installed"
|
for core_pkg in flask requests flask-limiter flask-compress flask-wtf; do
|
||||||
echo "Try: venv/bin/pip install flask requests flask-limiter"
|
if ! $PIP show "$core_pkg" >/dev/null 2>&1; then
|
||||||
exit 1
|
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"
|
ok "Core Python packages installed"
|
||||||
|
|
||||||
info "Installing optional packages..."
|
info "Installing optional packages..."
|
||||||
for pkg in "flask-sock" "websocket-client>=1.6.0" "numpy>=1.24.0" "scipy>=1.10.0" \
|
# Pure-Python packages: install without --only-binary so they always succeed regardless of platform
|
||||||
"Pillow>=9.0.0" "skyfield>=1.45" "bleak>=0.21.0" "psycopg2-binary>=2.9.9" \
|
for pkg in "flask-sock" "simple-websocket>=0.5.1" "websocket-client>=1.6.0" \
|
||||||
"meshtastic>=2.0.0" "scapy>=2.4.5" "qrcode[pil]>=7.4" "cryptography>=41.0.0" \
|
"skyfield>=1.45" "bleak>=0.21.0" "meshtastic>=2.0.0" \
|
||||||
"gunicorn>=21.2.0" "gevent>=23.9.0" "psutil>=5.9.0"; do
|
"qrcode[pil]>=7.4" "gunicorn>=21.2.0" "psutil>=5.9.0"; do
|
||||||
pkg_name="${pkg%%>=*}"
|
pkg_name="${pkg%%[><=]*}"
|
||||||
info " Installing ${pkg_name}..."
|
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)"
|
warn "${pkg_name} failed to install (optional - related features may be unavailable)"
|
||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
@@ -603,7 +635,25 @@ apt_install() {
|
|||||||
fi
|
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() {
|
apt_try_install_any() {
|
||||||
|
wait_for_apt_lock
|
||||||
local p
|
local p
|
||||||
for p in "$@"; do
|
for p in "$@"; do
|
||||||
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
|
if $SUDO apt-get install -y --no-install-recommends "$p" >/dev/null 2>&1; then
|
||||||
@@ -751,9 +801,26 @@ install_acarsdec_from_source_macos() {
|
|||||||
|
|
||||||
cd "$tmp_dir/acarsdec"
|
cd "$tmp_dir/acarsdec"
|
||||||
|
|
||||||
|
# Replace deprecated -Ofast (all macOS, not just arm64)
|
||||||
|
if grep -q '\-Ofast' CMakeLists.txt 2>/dev/null; then
|
||||||
|
sed -i '' 's/-Ofast/-O3 -ffast-math/g' CMakeLists.txt
|
||||||
|
info "Patched deprecated -Ofast flag"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# macOS doesn't have -march=native on arm64
|
||||||
if [[ "$(uname -m)" == "arm64" ]]; then
|
if [[ "$(uname -m)" == "arm64" ]]; then
|
||||||
sed -i '' 's/-Ofast -march=native/-O3 -ffast-math/g' CMakeLists.txt
|
sed -i '' 's/ -march=native//g' CMakeLists.txt
|
||||||
info "Patched compiler flags for Apple Silicon (arm64)"
|
info "Removed -march=native for Apple Silicon"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# HOST_NAME_MAX is Linux-specific; macOS uses _POSIX_HOST_NAME_MAX
|
||||||
|
if grep -q 'HOST_NAME_MAX' acarsdec.c 2>/dev/null; then
|
||||||
|
sed -i '' '1i\
|
||||||
|
#ifndef HOST_NAME_MAX\
|
||||||
|
#define HOST_NAME_MAX 255\
|
||||||
|
#endif
|
||||||
|
' acarsdec.c
|
||||||
|
info "Patched HOST_NAME_MAX for macOS compatibility"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
if grep -q 'pthread_tryjoin_np' rtl.c 2>/dev/null; then
|
||||||
@@ -1703,6 +1770,7 @@ install_profiles() {
|
|||||||
export NEEDRESTART_MODE=a
|
export NEEDRESTART_MODE=a
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
wait_for_apt_lock
|
||||||
info "Updating APT package lists..."
|
info "Updating APT package lists..."
|
||||||
if ! $SUDO apt-get update -y >/dev/null 2>&1; then
|
if ! $SUDO apt-get update -y >/dev/null 2>&1; then
|
||||||
warn "apt-get update reported errors. Continuing anyway."
|
warn "apt-get update reported errors. Continuing anyway."
|
||||||
@@ -1924,7 +1992,18 @@ do_health_check() {
|
|||||||
info "SDR device detection..."
|
info "SDR device detection..."
|
||||||
if cmd_exists rtl_test; then
|
if cmd_exists rtl_test; then
|
||||||
local rtl_output
|
local rtl_output
|
||||||
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
|
if cmd_exists timeout; then
|
||||||
|
rtl_output=$(timeout 3 rtl_test -d 0 2>&1 || true)
|
||||||
|
elif cmd_exists gtimeout; then
|
||||||
|
rtl_output=$(gtimeout 3 rtl_test -d 0 2>&1 || true)
|
||||||
|
else
|
||||||
|
# No timeout command (common on macOS) — run with background kill
|
||||||
|
rtl_test -d 0 > /tmp/.rtl_test_out 2>&1 & local rtl_pid=$!
|
||||||
|
sleep 2
|
||||||
|
kill "$rtl_pid" 2>/dev/null; wait "$rtl_pid" 2>/dev/null
|
||||||
|
rtl_output=$(cat /tmp/.rtl_test_out 2>/dev/null || true)
|
||||||
|
rm -f /tmp/.rtl_test_out
|
||||||
|
fi
|
||||||
if echo "$rtl_output" | grep -q "Found\|Using device"; then
|
if echo "$rtl_output" | grep -q "Found\|Using device"; then
|
||||||
ok "RTL-SDR device detected"
|
ok "RTL-SDR device detected"
|
||||||
((pass++)) || true
|
((pass++)) || true
|
||||||
@@ -1984,8 +2063,8 @@ do_health_check() {
|
|||||||
ok "Python venv exists"
|
ok "Python venv exists"
|
||||||
((pass++)) || true
|
((pass++)) || true
|
||||||
|
|
||||||
if venv/bin/python -c "import flask; import requests" 2>/dev/null; then
|
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) — OK"
|
ok "Critical Python packages (flask, requests, flask-compress, flask-wtf) — OK"
|
||||||
((pass++)) || true
|
((pass++)) || true
|
||||||
else
|
else
|
||||||
fail "Critical Python packages missing in venv"
|
fail "Critical Python packages missing in venv"
|
||||||
|
|||||||
@@ -88,8 +88,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Branded "i" — inline SVG that matches the logo icon.
|
/* Branded "i" — inline SVG that matches the logo icon.
|
||||||
Sized to 0.9em so it sits naturally alongside text at any font-size. */
|
Sized to 0.9em so it sits naturally alongside text at any font-size.
|
||||||
.brand-i {
|
Uses .logo .brand-i (0,2,0) to beat .logo span (0,1,1) in dashboard CSS
|
||||||
|
which otherwise forces display:inline and breaks width/height. */
|
||||||
|
.brand-i,
|
||||||
|
.logo .brand-i {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 0.55em;
|
width: 0.55em;
|
||||||
height: 0.9em;
|
height: 0.9em;
|
||||||
|
|||||||
+1033
-58
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,7 @@ let agentRunningModes = []; // Track agent's running modes for conflict detecti
|
|||||||
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
|
let agentRunningModesDetail = {}; // Track device info per mode (for multi-SDR agents)
|
||||||
let healthCheckInterval = null; // Health monitoring interval
|
let healthCheckInterval = null; // Health monitoring interval
|
||||||
let agentHealthStatus = {}; // Cache of health status per agent ID
|
let agentHealthStatus = {}; // Cache of health status per agent ID
|
||||||
|
let healthCheckKickoffTimer = null;
|
||||||
|
|
||||||
// ============== AGENT HEALTH MONITORING ==============
|
// ============== AGENT HEALTH MONITORING ==============
|
||||||
|
|
||||||
@@ -25,8 +26,15 @@ function startHealthMonitoring() {
|
|||||||
// Don't start if already running
|
// Don't start if already running
|
||||||
if (healthCheckInterval) return;
|
if (healthCheckInterval) return;
|
||||||
|
|
||||||
// Initial check
|
// Defer the first probe so heavy dashboards can finish initial render
|
||||||
checkAllAgentsHealth();
|
// before we start contacting remote agents.
|
||||||
|
if (healthCheckKickoffTimer) {
|
||||||
|
clearTimeout(healthCheckKickoffTimer);
|
||||||
|
}
|
||||||
|
healthCheckKickoffTimer = setTimeout(() => {
|
||||||
|
healthCheckKickoffTimer = null;
|
||||||
|
checkAllAgentsHealth();
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
// Start periodic checks every 30 seconds
|
// Start periodic checks every 30 seconds
|
||||||
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
|
healthCheckInterval = setInterval(checkAllAgentsHealth, 30000);
|
||||||
@@ -37,6 +45,10 @@ function startHealthMonitoring() {
|
|||||||
* Stop health monitoring.
|
* Stop health monitoring.
|
||||||
*/
|
*/
|
||||||
function stopHealthMonitoring() {
|
function stopHealthMonitoring() {
|
||||||
|
if (healthCheckKickoffTimer) {
|
||||||
|
clearTimeout(healthCheckKickoffTimer);
|
||||||
|
healthCheckKickoffTimer = null;
|
||||||
|
}
|
||||||
if (healthCheckInterval) {
|
if (healthCheckInterval) {
|
||||||
clearInterval(healthCheckInterval);
|
clearInterval(healthCheckInterval);
|
||||||
healthCheckInterval = null;
|
healthCheckInterval = null;
|
||||||
|
|||||||
+103
-33
@@ -8,16 +8,41 @@ const AlertCenter = (function() {
|
|||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let reconnectTimer = null;
|
let reconnectTimer = null;
|
||||||
let lastConnectionWarningAt = 0;
|
let lastConnectionWarningAt = 0;
|
||||||
|
let rulesLoaded = false;
|
||||||
|
let rulesPromise = null;
|
||||||
|
let bootTimer = null;
|
||||||
|
let feedLoaded = false;
|
||||||
|
|
||||||
function init() {
|
function init(options = {}) {
|
||||||
loadRules();
|
const connectFeed = options.connectFeed !== false;
|
||||||
loadFeed();
|
const refreshRules = options.refreshRules === true;
|
||||||
connect();
|
|
||||||
|
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() {
|
function connect() {
|
||||||
if (eventSource) {
|
if (eventSource) {
|
||||||
eventSource.close();
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
eventSource = new EventSource('/alerts/stream');
|
eventSource = new EventSource('/alerts/stream');
|
||||||
@@ -40,6 +65,10 @@ const AlertCenter = (function() {
|
|||||||
lastConnectionWarningAt = now;
|
lastConnectionWarningAt = now;
|
||||||
console.warn('[Alerts] SSE connection error; retrying');
|
console.warn('[Alerts] SSE connection error; retrying');
|
||||||
}
|
}
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
if (reconnectTimer) clearTimeout(reconnectTimer);
|
if (reconnectTimer) clearTimeout(reconnectTimer);
|
||||||
reconnectTimer = setTimeout(connect, 2500);
|
reconnectTimer = setTimeout(connect, 2500);
|
||||||
};
|
};
|
||||||
@@ -133,6 +162,7 @@ const AlertCenter = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function loadFeed() {
|
function loadFeed() {
|
||||||
|
feedLoaded = true;
|
||||||
fetch('/alerts/events?limit=30')
|
fetch('/alerts/events?limit=30')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -144,21 +174,37 @@ const AlertCenter = (function() {
|
|||||||
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
.catch((err) => console.error('[Alerts] Load feed failed', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadRules() {
|
function loadRules(force = false) {
|
||||||
return fetch('/alerts/rules?all=1')
|
if (!force && rulesLoaded) {
|
||||||
|
renderRulesUI();
|
||||||
|
return Promise.resolve(rules);
|
||||||
|
}
|
||||||
|
if (!force && rulesPromise) {
|
||||||
|
return rulesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
rulesPromise = fetch('/alerts/rules?all=1')
|
||||||
.then((r) => r.json())
|
.then((r) => r.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.status === 'success') {
|
if (data.status === 'success') {
|
||||||
rules = data.rules || [];
|
rules = data.rules || [];
|
||||||
|
rulesLoaded = true;
|
||||||
renderRulesUI();
|
renderRulesUI();
|
||||||
}
|
}
|
||||||
|
return rules;
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
console.error('[Alerts] Load rules failed', err);
|
console.error('[Alerts] Load rules failed', err);
|
||||||
if (typeof reportActionableError === 'function') {
|
if (typeof reportActionableError === 'function') {
|
||||||
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
reportActionableError('Alert Rules', err, { onRetry: loadRules });
|
||||||
}
|
}
|
||||||
|
throw err;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
rulesPromise = null;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
return rulesPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveRule() {
|
function saveRule() {
|
||||||
@@ -260,7 +306,7 @@ const AlertCenter = (function() {
|
|||||||
if (data.status !== 'success') {
|
if (data.status !== 'success') {
|
||||||
throw new Error(data.message || 'Failed to update rule');
|
throw new Error(data.message || 'Failed to update rule');
|
||||||
}
|
}
|
||||||
return loadRules();
|
return loadRules(true);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (typeof reportActionableError === 'function') {
|
if (typeof reportActionableError === 'function') {
|
||||||
@@ -287,7 +333,7 @@ const AlertCenter = (function() {
|
|||||||
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
if (Number(getEditingRuleId()) === Number(ruleId)) {
|
||||||
clearRuleForm();
|
clearRuleForm();
|
||||||
}
|
}
|
||||||
return loadRules();
|
return loadRules(true);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
if (typeof reportActionableError === 'function') {
|
if (typeof reportActionableError === 'function') {
|
||||||
@@ -325,7 +371,7 @@ const AlertCenter = (function() {
|
|||||||
method: 'PATCH',
|
method: 'PATCH',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ enabled }),
|
body: JSON.stringify({ enabled }),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules(true));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
@@ -341,7 +387,7 @@ const AlertCenter = (function() {
|
|||||||
enabled: true,
|
enabled: true,
|
||||||
notify: { webhook: true },
|
notify: { webhook: true },
|
||||||
}),
|
}),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules(true));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
@@ -349,41 +395,63 @@ const AlertCenter = (function() {
|
|||||||
|
|
||||||
function addBluetoothWatchlist(address, name) {
|
function addBluetoothWatchlist(address, name) {
|
||||||
if (!address) return;
|
if (!address) return;
|
||||||
const upper = String(address).toUpperCase();
|
loadRules().then(() => {
|
||||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
const upper = String(address).toUpperCase();
|
||||||
if (existing) return;
|
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
||||||
|
if (existing) return;
|
||||||
|
|
||||||
fetch('/alerts/rules', {
|
return fetch('/alerts/rules', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
name: name ? `Watchlist ${name}` : `Watchlist ${upper}`,
|
||||||
mode: 'bluetooth',
|
mode: 'bluetooth',
|
||||||
event_type: 'device_update',
|
event_type: 'device_update',
|
||||||
match: { address: upper },
|
match: { address: upper },
|
||||||
severity: 'medium',
|
severity: 'medium',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
notify: { webhook: true },
|
notify: { webhook: true },
|
||||||
}),
|
}),
|
||||||
}).then(() => loadRules());
|
}).then(() => loadRules(true));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function removeBluetoothWatchlist(address) {
|
function removeBluetoothWatchlist(address) {
|
||||||
if (!address) return;
|
if (!address) return;
|
||||||
const upper = String(address).toUpperCase();
|
loadRules().then(() => {
|
||||||
const existing = rules.find((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper);
|
const upper = String(address).toUpperCase();
|
||||||
if (!existing) return;
|
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' })
|
return fetch(`/alerts/rules/${existing.id}`, { method: 'DELETE' })
|
||||||
.then(() => loadRules());
|
.then(() => loadRules(true));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isWatchlisted(address) {
|
function isWatchlisted(address) {
|
||||||
if (!address) return false;
|
if (!address) return false;
|
||||||
|
if (!rulesLoaded && !rulesPromise) {
|
||||||
|
loadRules();
|
||||||
|
}
|
||||||
const upper = String(address).toUpperCase();
|
const upper = String(address).toUpperCase();
|
||||||
return rules.some((r) => r.mode === 'bluetooth' && r.match && String(r.match.address || '').toUpperCase() === upper && r.enabled);
|
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) {
|
function escapeHtml(str) {
|
||||||
if (!str) return '';
|
if (!str) return '';
|
||||||
return String(str)
|
return String(str)
|
||||||
@@ -396,6 +464,7 @@ const AlertCenter = (function() {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
|
scheduleInit,
|
||||||
loadFeed,
|
loadFeed,
|
||||||
loadRules,
|
loadRules,
|
||||||
saveRule,
|
saveRule,
|
||||||
@@ -408,11 +477,12 @@ const AlertCenter = (function() {
|
|||||||
addBluetoothWatchlist,
|
addBluetoothWatchlist,
|
||||||
removeBluetoothWatchlist,
|
removeBluetoothWatchlist,
|
||||||
isWatchlisted,
|
isWatchlisted,
|
||||||
|
destroy,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
AlertCenter.init();
|
AlertCenter.scheduleInit();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ 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'] },
|
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'] },
|
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'] },
|
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'] },
|
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'] },
|
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'] },
|
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'] },
|
||||||
|
|||||||
@@ -330,6 +330,11 @@ const CommandPalette = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goToMode(mode) {
|
function goToMode(mode) {
|
||||||
|
if (mode === 'satellite') {
|
||||||
|
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const welcome = document.getElementById('welcomePage');
|
const welcome = document.getElementById('welcomePage');
|
||||||
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
if (welcome && getComputedStyle(welcome).display !== 'none') {
|
||||||
welcome.style.display = 'none';
|
welcome.style.display = 'none';
|
||||||
|
|||||||
@@ -1,9 +1,6 @@
|
|||||||
// Shared observer location helper for map-based modules.
|
// Shared observer location helper for map-based modules.
|
||||||
// Default: shared location enabled unless explicitly disabled via config.
|
// Default: shared location enabled unless explicitly disabled via config.
|
||||||
window.ObserverLocation = (function() {
|
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 SHARED_KEY = 'observerLocation';
|
||||||
const AIS_KEY = 'ais_observerLocation';
|
const AIS_KEY = 'ais_observerLocation';
|
||||||
const LEGACY_LAT_KEY = 'observerLat';
|
const LEGACY_LAT_KEY = 'observerLat';
|
||||||
@@ -21,6 +18,9 @@ window.ObserverLocation = (function() {
|
|||||||
return { lat: latNum, lon: lonNum };
|
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) {
|
function parseLocation(raw) {
|
||||||
if (!raw) return null;
|
if (!raw) return null;
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +39,7 @@ window.ObserverLocation = (function() {
|
|||||||
function readLegacyLatLon() {
|
function readLegacyLatLon() {
|
||||||
const lat = localStorage.getItem(LEGACY_LAT_KEY);
|
const lat = localStorage.getItem(LEGACY_LAT_KEY);
|
||||||
const lon = localStorage.getItem(LEGACY_LON_KEY);
|
const lon = localStorage.getItem(LEGACY_LON_KEY);
|
||||||
if (!lat || !lon) return null;
|
if (lat === null || lon === null) return null;
|
||||||
return normalize(lat, lon);
|
return normalize(lat, lon);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,11 +60,12 @@ window.ObserverLocation = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setShared(location, options = {}) {
|
function setShared(location, options = {}) {
|
||||||
if (!location) return;
|
const normalized = location ? normalize(location.lat, location.lon) : null;
|
||||||
localStorage.setItem(SHARED_KEY, JSON.stringify(location));
|
if (!normalized) return;
|
||||||
|
localStorage.setItem(SHARED_KEY, JSON.stringify(normalized));
|
||||||
if (options.updateLegacy !== false) {
|
if (options.updateLegacy !== false) {
|
||||||
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
|
||||||
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,16 +85,17 @@ window.ObserverLocation = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setForModule(moduleKey, location, options = {}) {
|
function setForModule(moduleKey, location, options = {}) {
|
||||||
if (!location) return;
|
const normalized = location ? normalize(location.lat, location.lon) : null;
|
||||||
|
if (!normalized) return;
|
||||||
if (isSharedEnabled()) {
|
if (isSharedEnabled()) {
|
||||||
setShared(location, options);
|
setShared(normalized, options);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (moduleKey) {
|
if (moduleKey) {
|
||||||
localStorage.setItem(moduleKey, JSON.stringify(location));
|
localStorage.setItem(moduleKey, JSON.stringify(normalized));
|
||||||
} else if (options.fallbackToLatLon) {
|
} else if (options.fallbackToLatLon) {
|
||||||
localStorage.setItem(LEGACY_LAT_KEY, location.lat.toString());
|
localStorage.setItem(LEGACY_LAT_KEY, normalized.lat.toString());
|
||||||
localStorage.setItem(LEGACY_LON_KEY, location.lon.toString());
|
localStorage.setItem(LEGACY_LON_KEY, normalized.lon.toString());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -137,9 +137,3 @@ const RecordingUI = (function() {
|
|||||||
openReplay,
|
openReplay,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
|
||||||
if (typeof RecordingUI !== 'undefined') {
|
|
||||||
RecordingUI.init();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -896,23 +896,26 @@ function loadObserverLocation() {
|
|||||||
lon = shared.lon.toString();
|
lon = shared.lon.toString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const hasLat = lat !== undefined && lat !== null && lat !== '';
|
||||||
|
const hasLon = lon !== undefined && lon !== null && lon !== '';
|
||||||
|
|
||||||
const latInput = document.getElementById('observerLatInput');
|
const latInput = document.getElementById('observerLatInput');
|
||||||
const lonInput = document.getElementById('observerLonInput');
|
const lonInput = document.getElementById('observerLonInput');
|
||||||
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
const currentLatDisplay = document.getElementById('currentLatDisplay');
|
||||||
const currentLonDisplay = document.getElementById('currentLonDisplay');
|
const currentLonDisplay = document.getElementById('currentLonDisplay');
|
||||||
|
|
||||||
if (latInput && lat) latInput.value = lat;
|
if (latInput && hasLat) latInput.value = lat;
|
||||||
if (lonInput && lon) lonInput.value = lon;
|
if (lonInput && hasLon) lonInput.value = lon;
|
||||||
|
|
||||||
if (currentLatDisplay) {
|
if (currentLatDisplay) {
|
||||||
currentLatDisplay.textContent = lat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
|
currentLatDisplay.textContent = hasLat ? parseFloat(lat).toFixed(4) + '°' : 'Not set';
|
||||||
}
|
}
|
||||||
if (currentLonDisplay) {
|
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
|
// 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) });
|
const locationObj = JSON.stringify({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||||
if (!localStorage.getItem('observerLocation')) {
|
if (!localStorage.getItem('observerLocation')) {
|
||||||
localStorage.setItem('observerLocation', locationObj);
|
localStorage.setItem('observerLocation', locationObj);
|
||||||
@@ -1011,9 +1014,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 latInput = document.getElementById('observerLatInput');
|
||||||
const lonInput = document.getElementById('observerLonInput');
|
const lonInput = document.getElementById('observerLonInput');
|
||||||
|
|
||||||
@@ -1056,19 +1059,48 @@ function saveObserverLocation() {
|
|||||||
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
|
if (currentLatDisplay) currentLatDisplay.textContent = lat.toFixed(4) + '°';
|
||||||
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
|
if (currentLonDisplay) currentLonDisplay.textContent = lon.toFixed(4) + '°';
|
||||||
|
|
||||||
if (typeof showNotification === 'function') {
|
|
||||||
showNotification('Location', 'Observer location saved');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.observerLocation) {
|
if (window.observerLocation) {
|
||||||
window.observerLocation.lat = lat;
|
window.observerLocation.lat = lat;
|
||||||
window.observerLocation.lon = lon;
|
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
|
// Refresh SSTV ISS schedule if available
|
||||||
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
|
if (typeof SSTV !== 'undefined' && typeof SSTV.loadIssSchedule === 'function') {
|
||||||
SSTV.loadIssSchedule();
|
SSTV.loadIssSchedule();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 +1292,11 @@ function switchSettingsTab(tabName) {
|
|||||||
} else if (tabName === 'alerts') {
|
} else if (tabName === 'alerts') {
|
||||||
loadVoiceAlertConfig();
|
loadVoiceAlertConfig();
|
||||||
if (typeof AlertCenter !== 'undefined') {
|
if (typeof AlertCenter !== 'undefined') {
|
||||||
AlertCenter.loadFeed();
|
AlertCenter.init();
|
||||||
}
|
}
|
||||||
} else if (tabName === 'recording') {
|
} else if (tabName === 'recording') {
|
||||||
if (typeof RecordingUI !== 'undefined') {
|
if (typeof RecordingUI !== 'undefined') {
|
||||||
RecordingUI.refresh();
|
RecordingUI.init();
|
||||||
}
|
}
|
||||||
} else if (tabName === 'apikeys') {
|
} else if (tabName === 'apikeys') {
|
||||||
loadApiKeyStatus();
|
loadApiKeyStatus();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
const Updater = {
|
const Updater = {
|
||||||
// State
|
// State
|
||||||
_checkInterval: null,
|
_checkInterval: null,
|
||||||
|
_startupCheckTimer: null,
|
||||||
_toastElement: null,
|
_toastElement: null,
|
||||||
_modalElement: null,
|
_modalElement: null,
|
||||||
_updateData: null,
|
_updateData: null,
|
||||||
@@ -19,13 +20,26 @@ const Updater = {
|
|||||||
// Create toast container if it doesn't exist
|
// Create toast container if it doesn't exist
|
||||||
this._ensureToastContainer();
|
this._ensureToastContainer();
|
||||||
|
|
||||||
// Check for updates on page load
|
const enabled = localStorage.getItem('intercept_update_check_enabled') !== 'false';
|
||||||
this.checkForUpdates();
|
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
|
// Set up periodic checks
|
||||||
this._checkInterval = setInterval(() => {
|
if (!this._checkInterval) {
|
||||||
this.checkForUpdates();
|
this._checkInterval = setInterval(() => {
|
||||||
}, this.CHECK_INTERVAL_MS);
|
this.checkForUpdates();
|
||||||
|
}, this.CHECK_INTERVAL_MS);
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -506,6 +520,10 @@ const Updater = {
|
|||||||
* Clean up on page unload
|
* Clean up on page unload
|
||||||
*/
|
*/
|
||||||
destroy() {
|
destroy() {
|
||||||
|
if (this._startupCheckTimer) {
|
||||||
|
clearTimeout(this._startupCheckTimer);
|
||||||
|
this._startupCheckTimer = null;
|
||||||
|
}
|
||||||
if (this._checkInterval) {
|
if (this._checkInterval) {
|
||||||
clearInterval(this._checkInterval);
|
clearInterval(this._checkInterval);
|
||||||
this._checkInterval = null;
|
this._checkInterval = null;
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const VoiceAlerts = (function () {
|
|||||||
let _queue = [];
|
let _queue = [];
|
||||||
let _speaking = false;
|
let _speaking = false;
|
||||||
let _sources = {};
|
let _sources = {};
|
||||||
|
let _streamStartTimer = null;
|
||||||
const STORAGE_KEY = 'intercept-voice-muted';
|
const STORAGE_KEY = 'intercept-voice-muted';
|
||||||
const CONFIG_KEY = 'intercept-voice-config';
|
const CONFIG_KEY = 'intercept-voice-config';
|
||||||
const RATE_MIN = 0.5;
|
const RATE_MIN = 0.5;
|
||||||
@@ -132,7 +133,12 @@ const VoiceAlerts = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _startStreams() {
|
function _startStreams() {
|
||||||
|
if (_streamStartTimer) {
|
||||||
|
clearTimeout(_streamStartTimer);
|
||||||
|
_streamStartTimer = null;
|
||||||
|
}
|
||||||
if (!_enabled) return;
|
if (!_enabled) return;
|
||||||
|
if (Object.keys(_sources).length > 0) return;
|
||||||
|
|
||||||
// Pager stream
|
// Pager stream
|
||||||
if (_config.streams.pager) {
|
if (_config.streams.pager) {
|
||||||
@@ -173,17 +179,32 @@ const VoiceAlerts = (function () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function _stopStreams() {
|
function _stopStreams() {
|
||||||
|
if (_streamStartTimer) {
|
||||||
|
clearTimeout(_streamStartTimer);
|
||||||
|
_streamStartTimer = null;
|
||||||
|
}
|
||||||
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
Object.values(_sources).forEach(es => { try { es.close(); } catch (_) {} });
|
||||||
_sources = {};
|
_sources = {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function init() {
|
function init(options) {
|
||||||
|
const opts = options || {};
|
||||||
_loadConfig();
|
_loadConfig();
|
||||||
if (_isSpeechSupported()) {
|
if (_isSpeechSupported()) {
|
||||||
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
// Prime voices list early so user-triggered test calls are less likely to be silent.
|
||||||
speechSynthesis.getVoices();
|
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) {
|
function setEnabled(val) {
|
||||||
@@ -255,7 +276,7 @@ const VoiceAlerts = (function () {
|
|||||||
}, 1200);
|
}, 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;
|
window.VoiceAlerts = VoiceAlerts;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
@@ -18,6 +18,7 @@ const Meshtastic = (function() {
|
|||||||
let meshMap = null;
|
let meshMap = null;
|
||||||
let meshMarkers = {}; // nodeId -> marker
|
let meshMarkers = {}; // nodeId -> marker
|
||||||
let localNodeId = null;
|
let localNodeId = null;
|
||||||
|
let clickDelegationAttached = false;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Meshtastic mode
|
* Initialize the Meshtastic mode
|
||||||
@@ -33,6 +34,9 @@ const Meshtastic = (function() {
|
|||||||
* Setup event delegation for dynamically created elements
|
* Setup event delegation for dynamically created elements
|
||||||
*/
|
*/
|
||||||
function setupEventDelegation() {
|
function setupEventDelegation() {
|
||||||
|
if (clickDelegationAttached) return;
|
||||||
|
clickDelegationAttached = true;
|
||||||
|
|
||||||
// Handle button clicks in Leaflet popups and elsewhere
|
// Handle button clicks in Leaflet popups and elsewhere
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
|
const tracerouteBtn = e.target.closest('.mesh-traceroute-btn');
|
||||||
|
|||||||
@@ -18,6 +18,11 @@ const SpaceWeather = (function () {
|
|||||||
// Current image selections
|
// Current image selections
|
||||||
let _solarImageKey = 'sdo_193';
|
let _solarImageKey = 'sdo_193';
|
||||||
let _drapFreq = 'drap_global';
|
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). */
|
/** Stable cache-bust key that rotates every 5 minutes (matches backend max-age). */
|
||||||
function _cacheBust() {
|
function _cacheBust() {
|
||||||
@@ -54,11 +59,12 @@ const SpaceWeather = (function () {
|
|||||||
const frame = document.getElementById('swSolarImageFrame');
|
const frame = document.getElementById('swSolarImageFrame');
|
||||||
if (frame) {
|
if (frame) {
|
||||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||||
const img = new Image();
|
_loadImageWithFallback(
|
||||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
frame,
|
||||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
['/space-weather/image/' + key + '?' + _cacheBust(), _directImageUrlForKey(key)],
|
||||||
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
|
key,
|
||||||
img.alt = key;
|
'<div class="sw-empty">NASA SDO image is temporarily unavailable</div>'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,11 +74,12 @@ const SpaceWeather = (function () {
|
|||||||
const frame = document.getElementById('swDrapImageFrame');
|
const frame = document.getElementById('swDrapImageFrame');
|
||||||
if (frame) {
|
if (frame) {
|
||||||
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
frame.innerHTML = '<div class="sw-loading">Loading</div>';
|
||||||
const img = new Image();
|
_loadImageWithFallback(
|
||||||
img.onload = function () { frame.innerHTML = ''; frame.appendChild(img); };
|
frame,
|
||||||
img.onerror = function () { frame.innerHTML = '<div class="sw-empty">Failed to load image</div>'; };
|
['/space-weather/image/' + key + '?' + _cacheBust()],
|
||||||
img.src = '/space-weather/image/' + key + '?' + _cacheBust();
|
key,
|
||||||
img.alt = key;
|
'<div class="sw-empty">Failed to load image</div>'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,6 +105,38 @@ const SpaceWeather = (function () {
|
|||||||
if (_pollTimer) { clearInterval(_pollTimer); _pollTimer = null; }
|
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() {
|
function _fetchData() {
|
||||||
fetch('/space-weather/data')
|
fetch('/space-weather/data')
|
||||||
.then(function (r) { return r.json(); })
|
.then(function (r) { return r.json(); })
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ const SSTV = (function() {
|
|||||||
let countdownInterval = null;
|
let countdownInterval = null;
|
||||||
let nextPassData = null;
|
let nextPassData = null;
|
||||||
let pendingMapInvalidate = false;
|
let pendingMapInvalidate = false;
|
||||||
|
let locationListenersAttached = false;
|
||||||
|
|
||||||
// ISS frequency
|
// ISS frequency
|
||||||
const ISS_FREQ = 145.800;
|
const ISS_FREQ = 145.800;
|
||||||
@@ -92,9 +93,11 @@ const SSTV = (function() {
|
|||||||
if (latInput && storedLat) latInput.value = storedLat;
|
if (latInput && storedLat) latInput.value = storedLat;
|
||||||
if (lonInput && storedLon) lonInput.value = storedLon;
|
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||||
|
|
||||||
// Add change handlers to save and refresh
|
if (!locationListenersAttached) {
|
||||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||||
|
locationListenersAttached = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
/**
|
/**
|
||||||
* Weather Satellite Mode
|
* 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.
|
* polar plot, styled real-world map, countdown, and timeline.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const WeatherSat = (function() {
|
const WeatherSat = (function() {
|
||||||
|
const METEOR_NORAD_IDS = {
|
||||||
|
'METEOR-M2-3': 57166,
|
||||||
|
'METEOR-M2-4': 59051,
|
||||||
|
};
|
||||||
|
|
||||||
// State
|
// State
|
||||||
let isRunning = false;
|
let isRunning = false;
|
||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
@@ -27,11 +32,28 @@ const WeatherSat = (function() {
|
|||||||
let consoleAutoHideTimer = null;
|
let consoleAutoHideTimer = null;
|
||||||
let currentModalFilename = null;
|
let currentModalFilename = null;
|
||||||
let locationListenersAttached = false;
|
let locationListenersAttached = false;
|
||||||
|
let initialized = false;
|
||||||
|
let imageRefreshInterval = null;
|
||||||
|
let lastDecodeJobSignature = null;
|
||||||
|
let lastDecodeSatellite = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Weather Satellite mode
|
* Initialize the Weather Satellite mode
|
||||||
*/
|
*/
|
||||||
function init() {
|
function init() {
|
||||||
|
if (initialized) {
|
||||||
|
checkStatus();
|
||||||
|
loadImages();
|
||||||
|
loadLocationInputs();
|
||||||
|
loadPasses();
|
||||||
|
startCountdownTimer();
|
||||||
|
checkSchedulerStatus();
|
||||||
|
initGroundMap();
|
||||||
|
loadLatestDecodeJob();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
initialized = true;
|
||||||
|
|
||||||
checkStatus();
|
checkStatus();
|
||||||
loadImages();
|
loadImages();
|
||||||
loadLocationInputs();
|
loadLocationInputs();
|
||||||
@@ -39,14 +61,8 @@ const WeatherSat = (function() {
|
|||||||
startCountdownTimer();
|
startCountdownTimer();
|
||||||
checkSchedulerStatus();
|
checkSchedulerStatus();
|
||||||
initGroundMap();
|
initGroundMap();
|
||||||
|
ensureImageRefresh();
|
||||||
// Re-filter passes when satellite selection changes
|
loadLatestDecodeJob();
|
||||||
const satSelect = document.getElementById('weatherSatSelect');
|
|
||||||
if (satSelect) {
|
|
||||||
satSelect.addEventListener('change', () => {
|
|
||||||
applyPassFilter();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -132,7 +148,14 @@ const WeatherSat = (function() {
|
|||||||
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||||
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||||
const satSelect = document.getElementById('weatherSatSelect');
|
const satSelect = document.getElementById('weatherSatSelect');
|
||||||
if (satSelect) satSelect.addEventListener('change', applyPassFilter);
|
if (satSelect) {
|
||||||
|
satSelect.addEventListener('change', () => {
|
||||||
|
resetDecodeJobDisplay();
|
||||||
|
applyPassFilter();
|
||||||
|
loadImages();
|
||||||
|
loadLatestDecodeJob();
|
||||||
|
});
|
||||||
|
}
|
||||||
locationListenersAttached = true;
|
locationListenersAttached = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,6 +325,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
|
* Start capture for a specific pass
|
||||||
*/
|
*/
|
||||||
@@ -309,6 +345,7 @@ const WeatherSat = (function() {
|
|||||||
const satSelect = document.getElementById('weatherSatSelect');
|
const satSelect = document.getElementById('weatherSatSelect');
|
||||||
if (satSelect) {
|
if (satSelect) {
|
||||||
satSelect.value = satellite;
|
satSelect.value = satellite;
|
||||||
|
satSelect.dispatchEvent(new Event('change'));
|
||||||
}
|
}
|
||||||
start();
|
start();
|
||||||
}
|
}
|
||||||
@@ -521,6 +558,7 @@ const WeatherSat = (function() {
|
|||||||
updatePhaseIndicator('error');
|
updatePhaseIndicator('error');
|
||||||
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
||||||
|
loadImages();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1534,7 +1572,12 @@ const WeatherSat = (function() {
|
|||||||
*/
|
*/
|
||||||
async function loadImages() {
|
async function loadImages() {
|
||||||
try {
|
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();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
@@ -1599,6 +1642,14 @@ const WeatherSat = (function() {
|
|||||||
html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`;
|
html += `<div class="wxsat-date-header">${escapeHtml(date)}</div>`;
|
||||||
html += imgs.map(img => {
|
html += imgs.map(img => {
|
||||||
const fn = escapeHtml(img.filename || img.url.split('/').pop());
|
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 `
|
return `
|
||||||
<div class="wxsat-image-card">
|
<div class="wxsat-image-card">
|
||||||
<div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')">
|
<div class="wxsat-image-clickable" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}', '${fn}')">
|
||||||
@@ -1609,13 +1660,7 @@ const WeatherSat = (function() {
|
|||||||
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="wxsat-image-actions">
|
${deleteButton}
|
||||||
<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>
|
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
}
|
}
|
||||||
@@ -1707,9 +1752,14 @@ const WeatherSat = (function() {
|
|||||||
*/
|
*/
|
||||||
async function deleteAllImages() {
|
async function deleteAllImages() {
|
||||||
if (images.length === 0) return;
|
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({
|
const confirmed = await AppFeedback.confirmAction({
|
||||||
title: 'Delete All Images',
|
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',
|
confirmLabel: 'Delete All',
|
||||||
confirmClass: 'btn-danger'
|
confirmClass: 'btn-danger'
|
||||||
});
|
});
|
||||||
@@ -1720,8 +1770,8 @@ const WeatherSat = (function() {
|
|||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
images = [];
|
images = images.filter(img => img.deletable === false);
|
||||||
updateImageCount(0);
|
updateImageCount(images.length);
|
||||||
renderGallery();
|
renderGallery();
|
||||||
showNotification('Weather Sat', `Deleted ${data.deleted} images`);
|
showNotification('Weather Sat', `Deleted ${data.deleted} images`);
|
||||||
} else {
|
} else {
|
||||||
@@ -1745,6 +1795,145 @@ const WeatherSat = (function() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(' | ');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escape HTML
|
* Escape HTML
|
||||||
*/
|
*/
|
||||||
@@ -1910,6 +2099,7 @@ const WeatherSat = (function() {
|
|||||||
destroy,
|
destroy,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
|
preSelect,
|
||||||
startPass,
|
startPass,
|
||||||
selectPass,
|
selectPass,
|
||||||
testDecode,
|
testDecode,
|
||||||
|
|||||||
+8
-117
@@ -1,122 +1,13 @@
|
|||||||
/* INTERCEPT Service Worker — cache-first static, network-only for API/SSE/WS */
|
/* INTERCEPT Service Worker disabled to avoid stale cached static assets. */
|
||||||
const CACHE_NAME = 'intercept-v3';
|
self.addEventListener('install', () => {
|
||||||
|
|
||||||
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) => {
|
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
|
|
||||||
self.addEventListener('activate', (e) => {
|
self.addEventListener('activate', (event) => {
|
||||||
e.waitUntil(
|
event.waitUntil(
|
||||||
caches.keys().then(keys =>
|
caches.keys()
|
||||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
.then((keys) => Promise.all(keys.filter((key) => key.startsWith('intercept-')).map((key) => caches.delete(key))))
|
||||||
).then(() => self.clients.claim())
|
.then(() => self.registration.unregister())
|
||||||
);
|
.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 }))
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+344
-173
@@ -4,27 +4,10 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
|
<title>AIRCRAFT RADAR // INTERCEPT - See the Invisible</title>
|
||||||
<!-- Preconnect hints -->
|
<!-- Dedicated dashboards always use bundled assets so navigation is not
|
||||||
{% if offline_settings.assets_source != 'local' %}
|
blocked by external CDN reachability. -->
|
||||||
<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' %}
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<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') }}">
|
<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 -->
|
<!-- Core CSS -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/responsive.css') }}">
|
||||||
@@ -36,15 +19,13 @@
|
|||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||||
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
|
window.INTERCEPT_ADSB_AUTO_START = {{ adsb_auto_start | tojson }};
|
||||||
|
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||||||
|
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||||||
</script>
|
</script>
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
|
||||||
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
<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>
|
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-mode="adsb">
|
||||||
<div class="radar-bg"></div>
|
<div class="radar-bg"></div>
|
||||||
<div class="scanline"></div>
|
<div class="scanline"></div>
|
||||||
|
|
||||||
@@ -328,9 +309,9 @@
|
|||||||
</select>
|
</select>
|
||||||
<button class="watchlist-btn" onclick="showWatchlistModal()" title="Manage Watchlist">★</button>
|
<button class="watchlist-btn" onclick="showWatchlistModal()" title="Manage Watchlist">★</button>
|
||||||
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
|
<select id="rangeSelect" onchange="updateRange()" title="Range rings distance">
|
||||||
<option value="50">50nm</option>
|
<option value="50" selected>50nm</option>
|
||||||
<option value="100">100nm</option>
|
<option value="100">100nm</option>
|
||||||
<option value="200" selected>200nm</option>
|
<option value="200">200nm</option>
|
||||||
<option value="300">300nm</option>
|
<option value="300">300nm</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -340,8 +321,8 @@
|
|||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<span class="control-group-label">LOCATION</span>
|
<span class="control-group-label">LOCATION</span>
|
||||||
<div class="control-group-items">
|
<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="obsLat" value="{{ default_latitude }}" 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="obsLon" value="{{ default_longitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||||
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
<span id="gpsIndicator" class="gps-indicator" style="display: none;" title="GPS connected via gpsd"><span class="gps-dot"></span> GPS</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -441,6 +422,7 @@
|
|||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let agentPollTimer = null; // Polling fallback for agent mode
|
let agentPollTimer = null; // Polling fallback for agent mode
|
||||||
let isTracking = false;
|
let isTracking = false;
|
||||||
|
let isTrackingStarting = false;
|
||||||
let currentFilter = 'all';
|
let currentFilter = 'all';
|
||||||
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
|
// ICAO -> { emergency: bool, watchlist: bool, military: bool }
|
||||||
let alertedAircraft = {};
|
let alertedAircraft = {};
|
||||||
@@ -458,6 +440,13 @@
|
|||||||
let panelSelectionFallbackTimer = null;
|
let panelSelectionFallbackTimer = null;
|
||||||
let panelSelectionStageTimer = null;
|
let panelSelectionStageTimer = null;
|
||||||
let mapCrosshairRequestId = 0;
|
let mapCrosshairRequestId = 0;
|
||||||
|
let detectedDevicesPromise = null;
|
||||||
|
let deviceDetectionRetryTimer = null;
|
||||||
|
let clockInterval = null;
|
||||||
|
let cleanupInterval = null;
|
||||||
|
let delayedGpsInitTimer = null;
|
||||||
|
let delayedDriverCheckTimer = null;
|
||||||
|
let delayedAircraftDbTimer = null;
|
||||||
// Watchlist - persisted to localStorage
|
// Watchlist - persisted to localStorage
|
||||||
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
let watchlist = JSON.parse(localStorage.getItem('adsb_watchlist') || '[]');
|
||||||
|
|
||||||
@@ -467,7 +456,7 @@
|
|||||||
let showTrails = true;
|
let showTrails = true;
|
||||||
const MAX_TRAIL_POINTS = 100;
|
const MAX_TRAIL_POINTS = 100;
|
||||||
|
|
||||||
let maxRange = 200; // nautical miles
|
let maxRange = 50; // nautical miles
|
||||||
|
|
||||||
// Statistics
|
// Statistics
|
||||||
let stats = {
|
let stats = {
|
||||||
@@ -643,7 +632,9 @@
|
|||||||
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
if (parsed.lat !== undefined && parsed.lat !== null && parsed.lon !== undefined && parsed.lon !== null) return parsed;
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
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 rangeRingsLayer = null;
|
||||||
let observerMarker = null;
|
let observerMarker = null;
|
||||||
@@ -1602,7 +1593,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
// ============================================
|
// ============================================
|
||||||
async function autoConnectGps() {
|
async function autoConnectGps() {
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
const response = await fetchJsonWithTimeout('/gps/auto-connect', { method: 'POST' }, 2000);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'connected') {
|
if (data.status === 'connected') {
|
||||||
@@ -1733,98 +1724,227 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
window.addEventListener('pagehide', function() {
|
window.addEventListener('pagehide', function() {
|
||||||
if (eventSource) { eventSource.close(); eventSource = null; }
|
if (eventSource) { eventSource.close(); eventSource = null; }
|
||||||
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
|
if (gpsEventSource) { gpsEventSource.close(); gpsEventSource = null; }
|
||||||
|
if (acarsEventSource) { acarsEventSource.close(); acarsEventSource = null; }
|
||||||
|
if (vdl2EventSource) { vdl2EventSource.close(); vdl2EventSource = null; }
|
||||||
|
if (allAgentsEventSource) { allAgentsEventSource.close(); allAgentsEventSource = null; }
|
||||||
|
if (agentPollTimer) { clearInterval(agentPollTimer); agentPollTimer = null; }
|
||||||
|
if (acarsPollTimer) { clearInterval(acarsPollTimer); acarsPollTimer = null; }
|
||||||
|
if (vdl2PollTimer) { clearInterval(vdl2PollTimer); vdl2PollTimer = null; }
|
||||||
|
if (clockInterval) { clearInterval(clockInterval); clockInterval = null; }
|
||||||
|
if (cleanupInterval) { clearInterval(cleanupInterval); cleanupInterval = null; }
|
||||||
|
if (delayedGpsInitTimer) { clearTimeout(delayedGpsInitTimer); delayedGpsInitTimer = null; }
|
||||||
|
if (delayedDriverCheckTimer) { clearTimeout(delayedDriverCheckTimer); delayedDriverCheckTimer = null; }
|
||||||
|
if (delayedAircraftDbTimer) { clearTimeout(delayedAircraftDbTimer); delayedAircraftDbTimer = null; }
|
||||||
|
if (deviceDetectionRetryTimer) { clearTimeout(deviceDetectionRetryTimer); deviceDetectionRetryTimer = null; }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function ensureAdsbMapBootstrapped() {
|
||||||
|
if (radarMap) return;
|
||||||
|
try {
|
||||||
|
initMap();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ADS-B map bootstrap failed:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Initialize observer location input fields from saved location
|
// Bring the map up first so a later startup error cannot leave the
|
||||||
const obsLatInput = document.getElementById('obsLat');
|
// dashboard in a half-rendered "shell only" state.
|
||||||
const obsLonInput = document.getElementById('obsLon');
|
ensureAdsbMapBootstrapped();
|
||||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
|
||||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
|
||||||
|
|
||||||
// Initialize detection sound toggle from localStorage
|
try {
|
||||||
const detectionToggle = document.getElementById('detectionSoundToggle');
|
// Initialize observer location input fields from saved location
|
||||||
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
|
const obsLatInput = document.getElementById('obsLat');
|
||||||
|
const obsLonInput = document.getElementById('obsLon');
|
||||||
|
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||||
|
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||||
|
|
||||||
// Load Bias-T setting from localStorage
|
// Initialize detection sound toggle from localStorage
|
||||||
loadAdsbBiasTSetting();
|
const detectionToggle = document.getElementById('detectionSoundToggle');
|
||||||
|
if (detectionToggle) detectionToggle.checked = detectionSoundEnabled;
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ADS-B UI bootstrap warning:', e);
|
||||||
|
}
|
||||||
|
|
||||||
initMap();
|
try {
|
||||||
initDeviceSelectors();
|
loadAdsbBiasTSetting();
|
||||||
updateClock();
|
} catch (e) {
|
||||||
setInterval(updateClock, 1000);
|
console.error('ADS-B Bias-T bootstrap warning:', e);
|
||||||
setInterval(cleanupOldAircraft, 10000);
|
}
|
||||||
checkAdsbTools();
|
|
||||||
checkAircraftDatabase();
|
|
||||||
checkDvbDriverConflict();
|
|
||||||
|
|
||||||
// Auto-connect to gpsd if available
|
showDeviceDetectionPendingState();
|
||||||
autoConnectGps();
|
initDeviceSelectors()
|
||||||
|
.then((devices) => checkAdsbTools(devices))
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('ADS-B device selector bootstrap warning:', e);
|
||||||
|
checkAdsbTools([]);
|
||||||
|
});
|
||||||
|
|
||||||
// Sync tracking state if ADS-B already running
|
deviceDetectionRetryTimer = setTimeout(() => {
|
||||||
syncTrackingStatus();
|
deviceDetectionRetryTimer = null;
|
||||||
|
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||||
|
const emptyText = adsbSelect?.options?.[0]?.textContent || '';
|
||||||
|
const stillWaitingForDevices = adsbSelect && adsbSelect.options.length === 1
|
||||||
|
&& /No SDR|Detecting SDR/i.test(emptyText);
|
||||||
|
|
||||||
|
if (!stillWaitingForDevices) return;
|
||||||
|
|
||||||
|
initDeviceSelectors(true, 20000)
|
||||||
|
.then((devices) => checkAdsbTools(devices))
|
||||||
|
.catch((e) => {
|
||||||
|
console.error('ADS-B device selector retry warning:', e);
|
||||||
|
});
|
||||||
|
}, 6000);
|
||||||
|
|
||||||
|
try {
|
||||||
|
updateClock();
|
||||||
|
clockInterval = setInterval(updateClock, 1000);
|
||||||
|
cleanupInterval = setInterval(cleanupOldAircraft, 10000);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('ADS-B timer bootstrap warning:', e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Defer nonessential startup probes so the page can paint and
|
||||||
|
// return navigation remains snappy if the user leaves quickly.
|
||||||
|
delayedAircraftDbTimer = setTimeout(() => {
|
||||||
|
delayedAircraftDbTimer = null;
|
||||||
|
checkAircraftDatabase();
|
||||||
|
}, 1200);
|
||||||
|
|
||||||
|
delayedDriverCheckTimer = setTimeout(() => {
|
||||||
|
delayedDriverCheckTimer = null;
|
||||||
|
checkDvbDriverConflict();
|
||||||
|
}, 1800);
|
||||||
|
|
||||||
|
delayedGpsInitTimer = setTimeout(() => {
|
||||||
|
delayedGpsInitTimer = null;
|
||||||
|
autoConnectGps();
|
||||||
|
}, 2500);
|
||||||
|
|
||||||
|
syncTrackingStatus().catch((e) => {
|
||||||
|
console.error('ADS-B tracking status bootstrap warning:', e);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
if (!radarMap) {
|
||||||
|
console.warn('ADS-B map was not initialized during DOMContentLoaded, retrying on window load');
|
||||||
|
ensureAdsbMapBootstrapped();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Track which device is being used for ADS-B tracking
|
// Track which device is being used for ADS-B tracking
|
||||||
let adsbActiveDevice = null;
|
let adsbActiveDevice = null;
|
||||||
|
|
||||||
function initDeviceSelectors() {
|
function fetchJsonWithTimeout(url, options = {}, timeoutMs = 4000) {
|
||||||
// Populate both ADS-B and airband device selectors
|
const controller = typeof AbortController !== 'undefined' ? new AbortController() : null;
|
||||||
fetch('/devices')
|
const timeoutId = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
||||||
.then(r => r.json())
|
return fetch(url, {
|
||||||
.then(devices => {
|
...options,
|
||||||
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
...(controller ? { signal: controller.signal } : {})
|
||||||
const airbandSelect = document.getElementById('airbandDeviceSelect');
|
}).finally(() => {
|
||||||
|
if (timeoutId) clearTimeout(timeoutId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Clear loading state
|
function populateCompositeDeviceSelect(select, devices, emptyLabel = 'No SDR detected') {
|
||||||
adsbSelect.innerHTML = '';
|
if (!select) return;
|
||||||
airbandSelect.innerHTML = '';
|
select.innerHTML = '';
|
||||||
|
|
||||||
|
if (!devices || devices.length === 0) {
|
||||||
|
select.innerHTML = `<option value="rtlsdr:0">${emptyLabel}</option>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
devices.forEach((dev, i) => {
|
||||||
|
const idx = dev.index !== undefined ? dev.index : i;
|
||||||
|
const sdrType = dev.sdr_type || 'rtlsdr';
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = `${sdrType}:${idx}`;
|
||||||
|
option.dataset.sdrType = sdrType;
|
||||||
|
option.dataset.index = idx;
|
||||||
|
option.textContent = `SDR ${idx}: ${dev.name || dev.type || 'SDR'}`;
|
||||||
|
select.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function showDeviceDetectionPendingState() {
|
||||||
|
populateCompositeDeviceSelect(document.getElementById('adsbDeviceSelect'), [], 'Detecting SDRs...');
|
||||||
|
populateCompositeDeviceSelect(document.getElementById('airbandDeviceSelect'), [], 'Detecting SDRs...');
|
||||||
|
populateCompositeDeviceSelect(document.getElementById('acarsDeviceSelect'), [], 'Detecting SDRs...');
|
||||||
|
populateCompositeDeviceSelect(document.getElementById('vdl2DeviceSelect'), [], 'Detecting SDRs...');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDetectedDevices(force = false, timeoutMs = 12000) {
|
||||||
|
if (force) {
|
||||||
|
detectedDevicesPromise = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!force && detectedDevicesPromise) {
|
||||||
|
return detectedDevicesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
detectedDevicesPromise = fetchJsonWithTimeout('/devices', {}, timeoutMs)
|
||||||
|
.then((r) => r.ok ? r.json() : [])
|
||||||
|
.then((devices) => {
|
||||||
|
if (!Array.isArray(devices)) {
|
||||||
|
detectedDevicesPromise = null;
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
if (devices.length === 0) {
|
if (devices.length === 0) {
|
||||||
adsbSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
detectedDevicesPromise = null;
|
||||||
airbandSelect.innerHTML = '<option value="rtlsdr:0">No SDR found</option>';
|
|
||||||
airbandSelect.disabled = true;
|
|
||||||
} else {
|
|
||||||
devices.forEach((dev, i) => {
|
|
||||||
const idx = dev.index !== undefined ? dev.index : i;
|
|
||||||
const sdrType = dev.sdr_type || 'rtlsdr';
|
|
||||||
const compositeVal = `${sdrType}:${idx}`;
|
|
||||||
const displayName = `SDR ${idx}: ${dev.name}`;
|
|
||||||
|
|
||||||
// Add to ADS-B selector
|
|
||||||
const adsbOpt = document.createElement('option');
|
|
||||||
adsbOpt.value = compositeVal;
|
|
||||||
adsbOpt.dataset.sdrType = sdrType;
|
|
||||||
adsbOpt.dataset.index = idx;
|
|
||||||
adsbOpt.textContent = displayName;
|
|
||||||
adsbSelect.appendChild(adsbOpt);
|
|
||||||
|
|
||||||
// Add to Airband selector
|
|
||||||
const airbandOpt = document.createElement('option');
|
|
||||||
airbandOpt.value = compositeVal;
|
|
||||||
airbandOpt.dataset.sdrType = sdrType;
|
|
||||||
airbandOpt.dataset.index = idx;
|
|
||||||
airbandOpt.textContent = displayName;
|
|
||||||
airbandSelect.appendChild(airbandOpt);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Default: ADS-B uses first device, Airband uses second (if available)
|
|
||||||
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
|
|
||||||
if (devices.length > 1) {
|
|
||||||
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show warning if only one device
|
|
||||||
if (devices.length === 1) {
|
|
||||||
document.getElementById('airbandStatus').textContent = '1 SDR only';
|
|
||||||
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch((err) => {
|
||||||
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
console.warn('[ADS-B] Device detection failed:', err?.message || err);
|
||||||
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
detectedDevicesPromise = null;
|
||||||
|
return [];
|
||||||
});
|
});
|
||||||
|
return detectedDevicesPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initDeviceSelectors(force = false, timeoutMs = 12000) {
|
||||||
|
return getDetectedDevices(force, timeoutMs).then((devices) => {
|
||||||
|
const adsbSelect = document.getElementById('adsbDeviceSelect');
|
||||||
|
const airbandSelect = document.getElementById('airbandDeviceSelect');
|
||||||
|
const acarsSelect = document.getElementById('acarsDeviceSelect');
|
||||||
|
const vdl2Select = document.getElementById('vdl2DeviceSelect');
|
||||||
|
|
||||||
|
populateCompositeDeviceSelect(adsbSelect, devices, 'No SDR found');
|
||||||
|
populateCompositeDeviceSelect(airbandSelect, devices, 'No SDR found');
|
||||||
|
populateCompositeDeviceSelect(acarsSelect, devices);
|
||||||
|
populateCompositeDeviceSelect(vdl2Select, devices);
|
||||||
|
|
||||||
|
if (!devices || devices.length === 0) {
|
||||||
|
if (airbandSelect) airbandSelect.disabled = true;
|
||||||
|
return devices;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (airbandSelect) {
|
||||||
|
airbandSelect.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (adsbSelect) {
|
||||||
|
adsbSelect.value = adsbSelect.options[0]?.value || 'rtlsdr:0';
|
||||||
|
}
|
||||||
|
if (airbandSelect && devices.length > 1) {
|
||||||
|
airbandSelect.value = airbandSelect.options[1]?.value || airbandSelect.options[0]?.value || 'rtlsdr:0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (devices.length === 1) {
|
||||||
|
document.getElementById('airbandStatus').textContent = '1 SDR only';
|
||||||
|
document.getElementById('airbandStatus').style.color = 'var(--accent-orange)';
|
||||||
|
}
|
||||||
|
|
||||||
|
return devices;
|
||||||
|
}).catch(() => {
|
||||||
|
document.getElementById('adsbDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||||
|
document.getElementById('airbandDeviceSelect').innerHTML = '<option value="rtlsdr:0">Error</option>';
|
||||||
|
return [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkDvbDriverConflict() {
|
function checkDvbDriverConflict() {
|
||||||
@@ -1928,12 +2048,15 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
if (warning) warning.remove();
|
if (warning) warning.remove();
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkAdsbTools() {
|
function checkAdsbTools(devices = []) {
|
||||||
fetch('/adsb/tools')
|
fetchJsonWithTimeout('/adsb/tools', {}, 3000)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.needs_readsb) {
|
const soapyTypes = (devices || [])
|
||||||
showReadsbWarning(data.soapy_types);
|
.filter((d) => ['hackrf', 'limesdr', 'airspy'].includes((d.sdr_type || '').toLowerCase()))
|
||||||
|
.map((d) => d.sdr_type);
|
||||||
|
if (!data.readsb && soapyTypes.length > 0) {
|
||||||
|
showReadsbWarning(soapyTypes);
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
@@ -1945,7 +2068,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
let aircraftDbStatus = { installed: false };
|
let aircraftDbStatus = { installed: false };
|
||||||
|
|
||||||
function checkAircraftDatabase() {
|
function checkAircraftDatabase() {
|
||||||
fetch('/adsb/aircraft-db/status')
|
fetchJsonWithTimeout('/adsb/aircraft-db/status', {}, 2000)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(status => {
|
.then(status => {
|
||||||
aircraftDbStatus = status;
|
aircraftDbStatus = status;
|
||||||
@@ -1953,7 +2076,7 @@ ACARS: ${r.statistics.acarsMessages} messages`;
|
|||||||
showAircraftDbBanner('not_installed');
|
showAircraftDbBanner('not_installed');
|
||||||
} else {
|
} else {
|
||||||
// Check for updates in background
|
// Check for updates in background
|
||||||
fetch('/adsb/aircraft-db/check-updates')
|
fetchJsonWithTimeout('/adsb/aircraft-db/check-updates', {}, 2000)
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.update_available) {
|
if (data.update_available) {
|
||||||
@@ -2096,6 +2219,77 @@ sudo make install</code>
|
|||||||
now.toISOString().substring(11, 19) + ' UTC';
|
now.toISOString().substring(11, 19) + ' UTC';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createFallbackGridLayer() {
|
||||||
|
const layer = L.gridLayer({
|
||||||
|
tileSize: 256,
|
||||||
|
updateWhenIdle: true,
|
||||||
|
attribution: 'Local fallback grid'
|
||||||
|
});
|
||||||
|
layer.createTile = function(coords) {
|
||||||
|
const tile = document.createElement('canvas');
|
||||||
|
tile.width = 256;
|
||||||
|
tile.height = 256;
|
||||||
|
const ctx = tile.getContext('2d');
|
||||||
|
|
||||||
|
ctx.fillStyle = '#08121c';
|
||||||
|
ctx.fillRect(0, 0, 256, 256);
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.14)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(256, 0);
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(0, 256);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.08)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(128, 0);
|
||||||
|
ctx.lineTo(128, 256);
|
||||||
|
ctx.moveTo(0, 128);
|
||||||
|
ctx.lineTo(256, 128);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||||
|
ctx.font = '11px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
};
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function upgradeRadarTilesFromSettings(fallbackTiles) {
|
||||||
|
if (typeof Settings === 'undefined') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await Settings.init();
|
||||||
|
if (!radarMap) return;
|
||||||
|
|
||||||
|
const configuredLayer = Settings.createTileLayer();
|
||||||
|
let tileLoaded = false;
|
||||||
|
|
||||||
|
configuredLayer.once('load', () => {
|
||||||
|
tileLoaded = true;
|
||||||
|
if (radarMap && fallbackTiles && radarMap.hasLayer(fallbackTiles)) {
|
||||||
|
radarMap.removeLayer(fallbackTiles);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
configuredLayer.on('tileerror', () => {
|
||||||
|
if (!tileLoaded) {
|
||||||
|
console.warn('ADS-B tile layer failed to load, keeping fallback grid');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
configuredLayer.addTo(radarMap);
|
||||||
|
Settings.registerMap(radarMap);
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('ADS-B: Settings/tile upgrade failed, using fallback grid:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function initMap() {
|
async function initMap() {
|
||||||
// Guard against double initialization (e.g. bfcache restore)
|
// Guard against double initialization (e.g. bfcache restore)
|
||||||
const container = document.getElementById('radarMap');
|
const container = document.getElementById('radarMap');
|
||||||
@@ -2111,13 +2305,9 @@ sudo make install</code>
|
|||||||
// Use settings manager for tile layer (allows runtime changes)
|
// Use settings manager for tile layer (allows runtime changes)
|
||||||
window.radarMap = radarMap;
|
window.radarMap = radarMap;
|
||||||
|
|
||||||
// Add fallback tiles immediately so the map is never blank
|
// Use a zero-network fallback so dashboard navigation stays fast even
|
||||||
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
// when internet map providers are slow or unreachable.
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
const fallbackTiles = createFallbackGridLayer().addTo(radarMap);
|
||||||
maxZoom: 19,
|
|
||||||
subdomains: 'abcd',
|
|
||||||
className: 'tile-layer-cyan'
|
|
||||||
}).addTo(radarMap);
|
|
||||||
|
|
||||||
// Draw range rings after map is ready
|
// Draw range rings after map is ready
|
||||||
setTimeout(() => drawRangeRings(), 100);
|
setTimeout(() => drawRangeRings(), 100);
|
||||||
@@ -2132,20 +2322,9 @@ sudo make install</code>
|
|||||||
if (radarMap) radarMap.invalidateSize();
|
if (radarMap) radarMap.invalidateSize();
|
||||||
}, 500);
|
}, 500);
|
||||||
|
|
||||||
// Upgrade tiles via Settings in the background (non-blocking)
|
// Upgrade tiles via Settings in the background without tearing down
|
||||||
if (typeof Settings !== 'undefined') {
|
// the local fallback grid until a real tile layer actually loads.
|
||||||
try {
|
upgradeRadarTilesFromSettings(fallbackTiles);
|
||||||
await Promise.race([
|
|
||||||
Settings.init(),
|
|
||||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Settings timeout')), 5000))
|
|
||||||
]);
|
|
||||||
radarMap.removeLayer(fallbackTiles);
|
|
||||||
Settings.createTileLayer().addTo(radarMap);
|
|
||||||
Settings.registerMap(radarMap);
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('Settings init failed/timed out, using fallback tiles:', e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle window resize for map (especially important on mobile)
|
// Handle window resize for map (especially important on mobile)
|
||||||
@@ -2189,6 +2368,10 @@ sudo make install</code>
|
|||||||
const btn = document.getElementById('startBtn');
|
const btn = document.getElementById('startBtn');
|
||||||
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
|
const useAgent = typeof adsbCurrentAgent !== 'undefined' && adsbCurrentAgent !== 'local';
|
||||||
|
|
||||||
|
if (isTrackingStarting) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!isTracking) {
|
if (!isTracking) {
|
||||||
// Check for remote dump1090 config (only for local mode)
|
// Check for remote dump1090 config (only for local mode)
|
||||||
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
|
const remoteConfig = !useAgent ? getRemoteDump1090Config() : null;
|
||||||
@@ -2206,7 +2389,8 @@ sudo make install</code>
|
|||||||
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
|
const adsbDevice = parseInt(adsbDeviceIdx) || 0;
|
||||||
|
|
||||||
// Pre-flight: check if another mode is using this device and auto-stop it
|
// Pre-flight: check if another mode is using this device and auto-stop it
|
||||||
if (!useAgent) {
|
// Skip when using a remote SBS feed — no local SDR is needed
|
||||||
|
if (!useAgent && !remoteConfig) {
|
||||||
try {
|
try {
|
||||||
const devResp = await fetch('/devices/status');
|
const devResp = await fetch('/devices/status');
|
||||||
if (devResp.ok) {
|
if (devResp.ok) {
|
||||||
@@ -2266,6 +2450,10 @@ sudo make install</code>
|
|||||||
requestBody.remote_sbs_host = remoteConfig.host;
|
requestBody.remote_sbs_host = remoteConfig.host;
|
||||||
requestBody.remote_sbs_port = remoteConfig.port;
|
requestBody.remote_sbs_port = remoteConfig.port;
|
||||||
}
|
}
|
||||||
|
isTrackingStarting = true;
|
||||||
|
btn.disabled = true;
|
||||||
|
btn.textContent = 'STARTING...';
|
||||||
|
updateTrackingStatusDisplay();
|
||||||
try {
|
try {
|
||||||
// Route through agent proxy if using remote agent
|
// Route through agent proxy if using remote agent
|
||||||
const url = useAgent
|
const url = useAgent
|
||||||
@@ -2292,10 +2480,12 @@ sudo make install</code>
|
|||||||
drawRangeRings();
|
drawRangeRings();
|
||||||
startSessionTimer();
|
startSessionTimer();
|
||||||
isTracking = true;
|
isTracking = true;
|
||||||
|
isTrackingStarting = false;
|
||||||
adsbActiveDevice = adsbDevice; // Track which device is being used
|
adsbActiveDevice = adsbDevice; // Track which device is being used
|
||||||
adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking
|
adsbTrackingSource = useAgent ? adsbCurrentAgent : 'local'; // Track which source started tracking
|
||||||
btn.textContent = 'STOP';
|
btn.textContent = 'STOP';
|
||||||
btn.classList.add('active');
|
btn.classList.add('active');
|
||||||
|
btn.disabled = false;
|
||||||
document.getElementById('trackingDot').classList.remove('inactive');
|
document.getElementById('trackingDot').classList.remove('inactive');
|
||||||
updateTrackingStatusDisplay();
|
updateTrackingStatusDisplay();
|
||||||
// Disable ADS-B device selector while tracking
|
// Disable ADS-B device selector while tracking
|
||||||
@@ -2315,6 +2505,14 @@ sudo make install</code>
|
|||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert('Error: ' + err.message);
|
alert('Error: ' + err.message);
|
||||||
|
} finally {
|
||||||
|
if (!isTracking) {
|
||||||
|
isTrackingStarting = false;
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.textContent = 'START';
|
||||||
|
btn.classList.remove('active');
|
||||||
|
updateTrackingStatusDisplay();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
try {
|
try {
|
||||||
@@ -4277,26 +4475,9 @@ sudo make install</code>
|
|||||||
|
|
||||||
// Populate ACARS device selector
|
// Populate ACARS device selector
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
fetch('/devices')
|
getDetectedDevices().then((devices) => {
|
||||||
.then(r => r.json())
|
populateCompositeDeviceSelect(document.getElementById('acarsDeviceSelect'), devices);
|
||||||
.then(devices => {
|
});
|
||||||
const select = document.getElementById('acarsDeviceSelect');
|
|
||||||
select.innerHTML = '';
|
|
||||||
if (devices.length === 0) {
|
|
||||||
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
|
||||||
} else {
|
|
||||||
devices.forEach((d, i) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
const sdrType = d.sdr_type || 'rtlsdr';
|
|
||||||
const idx = d.index !== undefined ? d.index : i;
|
|
||||||
opt.value = `${sdrType}:${idx}`;
|
|
||||||
opt.dataset.sdrType = sdrType;
|
|
||||||
opt.dataset.index = idx;
|
|
||||||
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -4826,26 +5007,9 @@ sudo make install</code>
|
|||||||
|
|
||||||
// Populate VDL2 device selector and check running status
|
// Populate VDL2 device selector and check running status
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
fetch('/devices')
|
getDetectedDevices().then((devices) => {
|
||||||
.then(r => r.json())
|
populateCompositeDeviceSelect(document.getElementById('vdl2DeviceSelect'), devices);
|
||||||
.then(devices => {
|
});
|
||||||
const select = document.getElementById('vdl2DeviceSelect');
|
|
||||||
select.innerHTML = '';
|
|
||||||
if (devices.length === 0) {
|
|
||||||
select.innerHTML = '<option value="rtlsdr:0">No SDR detected</option>';
|
|
||||||
} else {
|
|
||||||
devices.forEach((d, i) => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
const sdrType = d.sdr_type || 'rtlsdr';
|
|
||||||
const idx = d.index !== undefined ? d.index : i;
|
|
||||||
opt.value = `${sdrType}:${idx}`;
|
|
||||||
opt.dataset.sdrType = sdrType;
|
|
||||||
opt.dataset.index = idx;
|
|
||||||
opt.textContent = `SDR ${idx}: ${d.name || d.type || 'SDR'}`;
|
|
||||||
select.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// Check if VDL2 is already running (e.g. after page reload)
|
// Check if VDL2 is already running (e.g. after page reload)
|
||||||
fetch('/vdl2/status')
|
fetch('/vdl2/status')
|
||||||
@@ -5553,10 +5717,14 @@ sudo make install</code>
|
|||||||
{% include 'partials/help-modal.html' %}
|
{% include 'partials/help-modal.html' %}
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></script>
|
<script src="{{ url_for('static', filename='js/core/voice-alerts.js') }}?v={{ version }}&r=adsbvoice1"></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/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
<script>
|
<script>
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
||||||
|
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -5594,7 +5762,10 @@ sudo make install</code>
|
|||||||
const statusEl = document.getElementById('trackingStatus');
|
const statusEl = document.getElementById('trackingStatus');
|
||||||
if (!statusEl) return;
|
if (!statusEl) return;
|
||||||
|
|
||||||
if (!isTracking) {
|
if (isTrackingStarting && !isTracking) {
|
||||||
|
statusEl.textContent = 'INITIALIZING';
|
||||||
|
statusEl.title = 'Starting ADS-B receiver';
|
||||||
|
} else if (!isTracking) {
|
||||||
statusEl.textContent = 'STANDBY';
|
statusEl.textContent = 'STANDBY';
|
||||||
statusEl.title = 'Select source and click START';
|
statusEl.title = 'Select source and click START';
|
||||||
} else {
|
} else {
|
||||||
|
|||||||
@@ -4,27 +4,10 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
<title>VESSEL RADAR // INTERCEPT - See the Invisible</title>
|
||||||
<!-- Preconnect hints -->
|
<!-- Dedicated dashboards always use bundled assets so navigation is not
|
||||||
{% if offline_settings.assets_source != 'local' %}
|
blocked by external CDN reachability. -->
|
||||||
<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' %}
|
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/fonts-local.css') }}">
|
<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') }}">
|
<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 -->
|
<!-- Core CSS -->
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/core/variables.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') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/ais_dashboard.css') }}">
|
||||||
@@ -35,15 +18,13 @@
|
|||||||
<!-- Deferred scripts -->
|
<!-- Deferred scripts -->
|
||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
window.INTERCEPT_SHARED_OBSERVER_LOCATION = {{ shared_observer_location | tojson }};
|
||||||
|
window.INTERCEPT_DEFAULT_LAT = {{ default_latitude | tojson }};
|
||||||
|
window.INTERCEPT_DEFAULT_LON = {{ default_longitude | tojson }};
|
||||||
</script>
|
</script>
|
||||||
{% if offline_settings.assets_source == 'local' %}
|
|
||||||
<script defer src="{{ url_for('static', filename='vendor/leaflet/leaflet.js') }}"></script>
|
<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>
|
<script defer src="{{ url_for('static', filename='js/core/observer-location.js') }}"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-mode="ais">
|
||||||
<!-- Radar background effects -->
|
<!-- Radar background effects -->
|
||||||
<div class="radar-bg"></div>
|
<div class="radar-bg"></div>
|
||||||
<div class="scanline"></div>
|
<div class="scanline"></div>
|
||||||
@@ -185,8 +166,8 @@
|
|||||||
<div class="control-group">
|
<div class="control-group">
|
||||||
<span class="control-group-label">LOCATION</span>
|
<span class="control-group-label">LOCATION</span>
|
||||||
<div class="control-group-items">
|
<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="obsLat" value="{{ default_latitude }}" 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="obsLon" value="{{ default_longitude }}" onchange="updateObserverLoc()" style="width: 70px;" title="Longitude" placeholder="Lon">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -248,7 +229,9 @@
|
|||||||
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
if (window.ObserverLocation && ObserverLocation.getForModule) {
|
||||||
return ObserverLocation.getForModule('ais_observerLocation');
|
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 rangeRingsLayer = null;
|
||||||
let observerMarker = null;
|
let observerMarker = null;
|
||||||
@@ -405,6 +388,47 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Initialize map
|
// Initialize map
|
||||||
|
function createFallbackGridLayer() {
|
||||||
|
const layer = L.gridLayer({
|
||||||
|
tileSize: 256,
|
||||||
|
updateWhenIdle: true,
|
||||||
|
attribution: 'Local fallback grid'
|
||||||
|
});
|
||||||
|
layer.createTile = function(coords) {
|
||||||
|
const tile = document.createElement('canvas');
|
||||||
|
tile.width = 256;
|
||||||
|
tile.height = 256;
|
||||||
|
const ctx = tile.getContext('2d');
|
||||||
|
|
||||||
|
ctx.fillStyle = '#07131c';
|
||||||
|
ctx.fillRect(0, 0, 256, 256);
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(256, 0);
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(0, 256);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(34, 197, 94, 0.10)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(128, 0);
|
||||||
|
ctx.lineTo(128, 256);
|
||||||
|
ctx.moveTo(0, 128);
|
||||||
|
ctx.lineTo(256, 128);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||||
|
ctx.font = '11px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
};
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
async function initMap() {
|
async function initMap() {
|
||||||
// Guard against double initialization (e.g. bfcache restore)
|
// Guard against double initialization (e.g. bfcache restore)
|
||||||
const container = document.getElementById('vesselMap');
|
const container = document.getElementById('vesselMap');
|
||||||
@@ -424,13 +448,9 @@
|
|||||||
// Use settings manager for tile layer (allows runtime changes)
|
// Use settings manager for tile layer (allows runtime changes)
|
||||||
window.vesselMap = vesselMap;
|
window.vesselMap = vesselMap;
|
||||||
|
|
||||||
// Add fallback tile layer immediately so the map is never blank
|
// Use a zero-network fallback so dashboard navigation stays fast even
|
||||||
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
// when internet map providers are slow or unreachable.
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
const fallbackTiles = createFallbackGridLayer().addTo(vesselMap);
|
||||||
maxZoom: 19,
|
|
||||||
subdomains: 'abcd',
|
|
||||||
className: 'tile-layer-cyan'
|
|
||||||
}).addTo(vesselMap);
|
|
||||||
|
|
||||||
// Then try to upgrade tiles via Settings (non-blocking)
|
// Then try to upgrade tiles via Settings (non-blocking)
|
||||||
if (typeof Settings !== 'undefined') {
|
if (typeof Settings !== 'undefined') {
|
||||||
@@ -1612,7 +1632,20 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% 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/settings-manager.js') }}?v={{ version }}&r=maptheme17"></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 -->
|
<!-- Agent Manager -->
|
||||||
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/core/agents.js') }}"></script>
|
||||||
|
|||||||
+339
-116
@@ -20,7 +20,6 @@
|
|||||||
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<link rel="preconnect" href="https://cartodb-basemaps-a.global.ssl.fastly.net" crossorigin>
|
|
||||||
<!-- Disclaimer gate - must accept before seeing welcome page -->
|
<!-- Disclaimer gate - must accept before seeing welcome page -->
|
||||||
<script>
|
<script>
|
||||||
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
|
// Check BEFORE page renders - if disclaimer not accepted, hide welcome page
|
||||||
@@ -162,28 +161,9 @@
|
|||||||
if (!mode) return;
|
if (!mode) return;
|
||||||
window.ensureModeStyles(mode).catch(() => {});
|
window.ensureModeStyles(mode).catch(() => {});
|
||||||
})();
|
})();
|
||||||
// Warm remaining lazy mode styles in the background to avoid first-switch FOUC.
|
// Do not warm every mode stylesheet on the welcome page. The eager
|
||||||
(function warmModeStylesInBackground() {
|
// background fetch storm was adding substantial cross-mode load and
|
||||||
const modeMap = window.INTERCEPT_MODE_STYLE_MAP || {};
|
// delaying dedicated dashboards like ADS-B.
|
||||||
const queryMode = new URLSearchParams(window.location.search).get('mode');
|
|
||||||
const selectedMode = queryMode === 'listening' ? 'waterfall' : queryMode;
|
|
||||||
const modes = Object.keys(modeMap).filter((mode) => mode !== selectedMode);
|
|
||||||
if (!modes.length) return;
|
|
||||||
|
|
||||||
const warm = function () {
|
|
||||||
modes.forEach(function (mode, index) {
|
|
||||||
setTimeout(function () {
|
|
||||||
window.ensureModeStyles(mode).catch(() => {});
|
|
||||||
}, index * 40);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
if (typeof window.requestIdleCallback === 'function') {
|
|
||||||
window.requestIdleCallback(warm, { timeout: 2000 });
|
|
||||||
} else {
|
|
||||||
setTimeout(warm, 600);
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
</script>
|
</script>
|
||||||
<script>
|
<script>
|
||||||
window.INTERCEPT_MODE_SCRIPT_MAP = {
|
window.INTERCEPT_MODE_SCRIPT_MAP = {
|
||||||
@@ -394,10 +374,10 @@
|
|||||||
<div class="mode-category">
|
<div class="mode-category">
|
||||||
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> Space</h3>
|
<h3 class="mode-category-title"><span class="mode-category-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4.5 16.5c-1.5 1.26-2 5-2 5s3.74-.5 5-2c.71-.84.7-2.13-.09-2.91a2.18 2.18 0 0 0-2.91-.09z"/><path d="m12 15-3-3a22 22 0 0 1 2-3.95A12.88 12.88 0 0 1 22 2c0 2.72-.78 7.5-6 11a22.35 22.35 0 0 1-4 2z"/><path d="M9 12H4s.55-3.03 2-4c1.62-1.08 5 0 5 0"/><path d="M12 15v5s3.03-.55 4-2c1.08-1.62 0-5 0-5"/></svg></span> Space</h3>
|
||||||
<div class="mode-grid mode-grid-compact">
|
<div class="mode-grid mode-grid-compact">
|
||||||
<button class="mode-card mode-card-sm" onclick="selectMode('satellite')">
|
<a href="/satellite/dashboard" target="_blank" rel="noopener noreferrer" class="mode-card mode-card-sm" style="text-decoration: none;">
|
||||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>
|
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg></span>
|
||||||
<span class="mode-name">Satellite</span>
|
<span class="mode-name">Satellite</span>
|
||||||
</button>
|
</a>
|
||||||
<button class="mode-card mode-card-sm" onclick="selectMode('sstv')">
|
<button class="mode-card mode-card-sm" onclick="selectMode('sstv')">
|
||||||
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
|
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/><path d="M3 9h2"/><path d="M19 9h2"/><path d="M3 15h2"/><path d="M19 15h2"/></svg></span>
|
||||||
<span class="mode-name">ISS SSTV</span>
|
<span class="mode-name">ISS SSTV</span>
|
||||||
@@ -1416,8 +1396,9 @@
|
|||||||
|
|
||||||
<!-- Satellite Dashboard (Embedded) -->
|
<!-- Satellite Dashboard (Embedded) -->
|
||||||
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
|
<div id="satelliteVisuals" class="satellite-dashboard-embed" style="display: none;">
|
||||||
<iframe id="satelliteDashboardFrame" src="/satellite/dashboard?embedded=true" frameborder="0"
|
<iframe id="satelliteDashboardFrame" data-src="/satellite/dashboard?embedded=true&v={{ version }}" frameborder="0"
|
||||||
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
|
style="width: 100%; height: 100%; min-height: 700px; border: none; border-radius: 8px;"
|
||||||
|
loading="lazy"
|
||||||
allowfullscreen>
|
allowfullscreen>
|
||||||
</iframe>
|
</iframe>
|
||||||
</div>
|
</div>
|
||||||
@@ -3604,6 +3585,10 @@
|
|||||||
|
|
||||||
// Mode selection from welcome page
|
// Mode selection from welcome page
|
||||||
function selectMode(mode) {
|
function selectMode(mode) {
|
||||||
|
if (mode === 'satellite') {
|
||||||
|
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
selectedStartMode = mode;
|
selectedStartMode = mode;
|
||||||
const welcome = document.getElementById('welcomePage');
|
const welcome = document.getElementById('welcomePage');
|
||||||
welcome.classList.add('fade-out');
|
welcome.classList.add('fade-out');
|
||||||
@@ -3664,6 +3649,10 @@
|
|||||||
function applyModeFromQuery() {
|
function applyModeFromQuery() {
|
||||||
const mode = getModeFromQuery();
|
const mode = getModeFromQuery();
|
||||||
if (!mode) return;
|
if (!mode) return;
|
||||||
|
if (mode === 'satellite') {
|
||||||
|
window.location.replace('/satellite/dashboard');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
const accepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
||||||
if (accepted) {
|
if (accepted) {
|
||||||
const welcome = document.getElementById('welcomePage');
|
const welcome = document.getElementById('welcomePage');
|
||||||
@@ -3890,7 +3879,11 @@
|
|||||||
if (saved) {
|
if (saved) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(saved);
|
const parsed = JSON.parse(saved);
|
||||||
if (parsed.lat && parsed.lon) return parsed;
|
const lat = Number(parsed.lat);
|
||||||
|
const lon = Number(parsed.lon);
|
||||||
|
if (Number.isFinite(lat) && Number.isFinite(lon)) {
|
||||||
|
return { lat, lon };
|
||||||
|
}
|
||||||
} catch (e) { }
|
} catch (e) { }
|
||||||
}
|
}
|
||||||
return { lat: 51.5074, lon: -0.1278 };
|
return { lat: 51.5074, lon: -0.1278 };
|
||||||
@@ -3899,6 +3892,8 @@
|
|||||||
// GPS Dongle state
|
// GPS Dongle state
|
||||||
let gpsConnected = false;
|
let gpsConnected = false;
|
||||||
let gpsEventSource = null;
|
let gpsEventSource = null;
|
||||||
|
let gpsAutoConnectTimer = null;
|
||||||
|
let gpsAutoConnectInFlight = null;
|
||||||
let gpsLastPosition = null;
|
let gpsLastPosition = null;
|
||||||
|
|
||||||
// Satellite state
|
// Satellite state
|
||||||
@@ -4097,8 +4092,8 @@
|
|||||||
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
if (obsLatInput) obsLatInput.value = observerLocation.lat.toFixed(4);
|
||||||
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
if (obsLonInput) obsLonInput.value = observerLocation.lon.toFixed(4);
|
||||||
|
|
||||||
// Auto-connect to gpsd if available
|
// Defer GPS auto-connect so it doesn't compete with initial dashboard navigation.
|
||||||
autoConnectGps();
|
scheduleGpsAutoConnect();
|
||||||
|
|
||||||
// Load pager message filters
|
// Load pager message filters
|
||||||
loadPagerFilters();
|
loadPagerFilters();
|
||||||
@@ -4206,7 +4201,14 @@
|
|||||||
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
|
acars: () => { if (acarsMainEventSource) { acarsMainEventSource.close(); acarsMainEventSource = null; } },
|
||||||
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
vdl2: () => { if (vdl2MainEventSource) { vdl2MainEventSource.close(); vdl2MainEventSource = null; } },
|
||||||
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
radiosonde: () => { if (radiosondeEventSource) { radiosondeEventSource.close(); radiosondeEventSource = null; } },
|
||||||
aprs: () => { if (aprsEventSource) { aprsEventSource.close(); aprsEventSource = null; } },
|
aprs: () => {
|
||||||
|
if (typeof destroyAprsMode === 'function') {
|
||||||
|
destroyAprsMode();
|
||||||
|
} else if (aprsEventSource) {
|
||||||
|
aprsEventSource.close();
|
||||||
|
aprsEventSource = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
|
tscm: () => { if (tscmEventSource) { tscmEventSource.close(); tscmEventSource = null; } },
|
||||||
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
meteor: () => typeof MeteorScatter !== 'undefined' && MeteorScatter.destroy?.(),
|
||||||
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
|
ook: () => typeof OokMode !== 'undefined' && OokMode.destroy?.(),
|
||||||
@@ -4331,8 +4333,10 @@
|
|||||||
activeScans: getActiveScanSummary(),
|
activeScans: getActiveScanSummary(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
// Let dedicated dashboards navigate immediately.
|
||||||
|
// Pre-navigation stop requests from active modes like Pager
|
||||||
|
// can stall same-tab navigation badly on some browsers.
|
||||||
destroyCurrentMode();
|
destroyCurrentMode();
|
||||||
stopActiveLocalScansForNavigation();
|
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
// Ignore malformed hrefs.
|
// Ignore malformed hrefs.
|
||||||
}
|
}
|
||||||
@@ -4382,12 +4386,19 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let modeSwitchRequestId = 0;
|
||||||
|
|
||||||
// Mode switching
|
// Mode switching
|
||||||
async function switchMode(mode, options = {}) {
|
async function switchMode(mode, options = {}) {
|
||||||
|
const requestId = ++modeSwitchRequestId;
|
||||||
const { updateUrl = true } = options;
|
const { updateUrl = true } = options;
|
||||||
const switchStartMs = performance.now();
|
const switchStartMs = performance.now();
|
||||||
const previousMode = currentMode;
|
const previousMode = currentMode;
|
||||||
if (mode === 'listening') mode = 'waterfall';
|
if (mode === 'listening') mode = 'waterfall';
|
||||||
|
if (mode === 'satellite') {
|
||||||
|
window.open('/satellite/dashboard', '_blank', 'noopener');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!validModes.has(mode)) mode = 'pager';
|
if (!validModes.has(mode)) mode = 'pager';
|
||||||
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
|
const styleReadyPromise = (typeof window.ensureModeStyles === 'function')
|
||||||
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
|
? Promise.resolve(window.ensureModeStyles(mode)).catch((err) => {
|
||||||
@@ -4453,6 +4464,7 @@
|
|||||||
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
const stopPhaseMs = Math.round(performance.now() - stopPhaseStartMs);
|
||||||
await styleReadyPromise;
|
await styleReadyPromise;
|
||||||
await scriptReadyPromise;
|
await scriptReadyPromise;
|
||||||
|
if (requestId !== modeSwitchRequestId) return;
|
||||||
|
|
||||||
// Generic module cleanup — destroy previous mode's timers, SSE, etc.
|
// Generic module cleanup — destroy previous mode's timers, SSE, etc.
|
||||||
if (previousMode && previousMode !== mode) {
|
if (previousMode && previousMode !== mode) {
|
||||||
@@ -4461,6 +4473,7 @@
|
|||||||
try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
|
try { destroyFn(); } catch(e) { console.warn(`[switchMode] destroy ${previousMode} failed:`, e); }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (requestId !== modeSwitchRequestId) return;
|
||||||
|
|
||||||
currentMode = mode;
|
currentMode = mode;
|
||||||
document.body.setAttribute('data-mode', mode);
|
document.body.setAttribute('data-mode', mode);
|
||||||
@@ -4476,6 +4489,7 @@
|
|||||||
// Sync with local status
|
// Sync with local status
|
||||||
syncLocalModeStates();
|
syncLocalModeStates();
|
||||||
}
|
}
|
||||||
|
if (requestId !== modeSwitchRequestId) return;
|
||||||
|
|
||||||
// Close dropdowns and update active state
|
// Close dropdowns and update active state
|
||||||
closeAllDropdowns();
|
closeAllDropdowns();
|
||||||
@@ -4564,9 +4578,27 @@
|
|||||||
if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth');
|
if (btLayoutContainer) btLayoutContainer.classList.toggle('active', mode === 'bluetooth');
|
||||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
const satFrame = document.getElementById('satelliteDashboardFrame');
|
const satFrame = document.getElementById('satelliteDashboardFrame');
|
||||||
if (satFrame && satFrame.contentWindow) {
|
if (satFrame && mode === 'satellite') {
|
||||||
|
const baseSrc = satFrame.dataset.src || '/satellite/dashboard?embedded=true&v={{ version }}';
|
||||||
|
const currentSrc = satFrame.getAttribute('src') || '';
|
||||||
|
if (!currentSrc || currentSrc === 'about:blank') {
|
||||||
|
satFrame.src = `${baseSrc}&ts=${Date.now()}`;
|
||||||
|
}
|
||||||
|
} else if (satFrame) {
|
||||||
|
const currentSrc = satFrame.getAttribute('src') || '';
|
||||||
|
if (currentSrc && currentSrc !== 'about:blank') {
|
||||||
|
satFrame.src = 'about:blank';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (satFrame && satFrame.contentWindow && satFrame.getAttribute('src') && satFrame.getAttribute('src') !== 'about:blank') {
|
||||||
satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*');
|
satFrame.contentWindow.postMessage({type: 'satellite-visibility', visible: mode === 'satellite'}, '*');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Weather-sat handoff: when switching away from satellite mode, clear any pending handoff banner
|
||||||
|
if (mode !== 'satellite' && mode !== 'weathersat') {
|
||||||
|
const existing = document.getElementById('weatherSatHandoffBanner');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
}
|
||||||
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
if (aprsVisuals) aprsVisuals.style.display = mode === 'aprs' ? 'flex' : 'none';
|
||||||
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
if (tscmVisuals) tscmVisuals.style.display = mode === 'tscm' ? 'flex' : 'none';
|
||||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||||
@@ -4789,6 +4821,7 @@
|
|||||||
} else if (mode === 'ook') {
|
} else if (mode === 'ook') {
|
||||||
OokMode.init();
|
OokMode.init();
|
||||||
}
|
}
|
||||||
|
if (requestId !== modeSwitchRequestId) return;
|
||||||
|
|
||||||
// Waterfall destroy is now handled by moduleDestroyMap above.
|
// Waterfall destroy is now handled by moduleDestroyMap above.
|
||||||
|
|
||||||
@@ -9847,10 +9880,39 @@
|
|||||||
let aprsStationCount = 0;
|
let aprsStationCount = 0;
|
||||||
let aprsMeterLastUpdate = 0;
|
let aprsMeterLastUpdate = 0;
|
||||||
let aprsMeterCheckInterval = null;
|
let aprsMeterCheckInterval = null;
|
||||||
|
let aprsClockInterval = null;
|
||||||
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
|
const APRS_METER_TIMEOUT = 5000; // 5 seconds for "no signal" state
|
||||||
|
|
||||||
// APRS user location (from GPS)
|
// APRS user location (from GPS or shared observer location)
|
||||||
let aprsUserLocation = { lat: null, lon: null };
|
let aprsUserLocation = { lat: null, lon: null };
|
||||||
|
|
||||||
|
// Seed from configured observer location so the map centres on the
|
||||||
|
// user's position even without a live GPS fix.
|
||||||
|
(function _seedAprsLocation() {
|
||||||
|
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
|
||||||
|
aprsUserLocation.lat = shared.lat;
|
||||||
|
aprsUserLocation.lon = shared.lon;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Fallback: read the Jinja-injected defaults directly
|
||||||
|
const lat = Number(window.INTERCEPT_DEFAULT_LAT);
|
||||||
|
const lon = Number(window.INTERCEPT_DEFAULT_LON);
|
||||||
|
if (aprsHasValidCoordinates(lat, lon)) {
|
||||||
|
aprsUserLocation.lat = lat;
|
||||||
|
aprsUserLocation.lon = lon;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
// Listen for observer location changes from settings or other sources
|
||||||
|
window.addEventListener('observer-location-changed', function(e) {
|
||||||
|
if (e.detail && aprsHasValidCoordinates(e.detail.lat, e.detail.lon)) {
|
||||||
|
updateAprsUserLocation({ latitude: e.detail.lat, longitude: e.detail.lon });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
let aprsUserMarker = null;
|
let aprsUserMarker = null;
|
||||||
|
|
||||||
// Calculate distance in miles using Haversine formula
|
// Calculate distance in miles using Haversine formula
|
||||||
@@ -9962,12 +10024,65 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createAprsFallbackGridLayer() {
|
||||||
|
const layer = L.gridLayer({
|
||||||
|
tileSize: 256,
|
||||||
|
updateWhenIdle: true,
|
||||||
|
attribution: 'Local fallback grid'
|
||||||
|
});
|
||||||
|
layer.createTile = function(coords) {
|
||||||
|
const tile = document.createElement('canvas');
|
||||||
|
tile.width = 256;
|
||||||
|
tile.height = 256;
|
||||||
|
const ctx = tile.getContext('2d');
|
||||||
|
|
||||||
|
ctx.fillStyle = '#08121c';
|
||||||
|
ctx.fillRect(0, 0, 256, 256);
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(0, 212, 255, 0.12)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(256, 0);
|
||||||
|
ctx.moveTo(0, 0);
|
||||||
|
ctx.lineTo(0, 256);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.strokeStyle = 'rgba(255, 255, 255, 0.06)';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.moveTo(128, 0);
|
||||||
|
ctx.lineTo(128, 256);
|
||||||
|
ctx.moveTo(0, 128);
|
||||||
|
ctx.lineTo(256, 128);
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
ctx.fillStyle = 'rgba(160, 220, 255, 0.28)';
|
||||||
|
ctx.font = '11px "JetBrains Mono", monospace';
|
||||||
|
ctx.fillText(`Z${coords.z} X${coords.x} Y${coords.y}`, 12, 22);
|
||||||
|
|
||||||
|
return tile;
|
||||||
|
};
|
||||||
|
return layer;
|
||||||
|
}
|
||||||
|
|
||||||
async function initAprsMap() {
|
async function initAprsMap() {
|
||||||
if (aprsMap) return;
|
if (aprsMap) return;
|
||||||
|
|
||||||
const mapContainer = document.getElementById('aprsMap');
|
const mapContainer = document.getElementById('aprsMap');
|
||||||
if (!mapContainer) return;
|
if (!mapContainer) return;
|
||||||
|
|
||||||
|
// Refresh from ObserverLocation in case it changed since page load
|
||||||
|
if (!aprsHasValidCoordinates(aprsUserLocation.lat, aprsUserLocation.lon) ||
|
||||||
|
(aprsUserLocation.lat === 0 && aprsUserLocation.lon === 0)) {
|
||||||
|
if (typeof ObserverLocation !== 'undefined' && ObserverLocation.getShared) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
if (shared && aprsHasValidCoordinates(shared.lat, shared.lon)) {
|
||||||
|
aprsUserLocation.lat = shared.lat;
|
||||||
|
aprsUserLocation.lon = shared.lon;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Use GPS location if available, otherwise default to center of US
|
// Use GPS location if available, otherwise default to center of US
|
||||||
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
|
const gpsLat = Number(gpsLastPosition && gpsLastPosition.latitude);
|
||||||
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
|
const gpsLon = Number(gpsLastPosition && gpsLastPosition.longitude);
|
||||||
@@ -9981,13 +10096,8 @@
|
|||||||
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
|
aprsMap = L.map('aprsMap').setView([initialLat, initialLon], initialZoom);
|
||||||
window.aprsMap = aprsMap;
|
window.aprsMap = aprsMap;
|
||||||
|
|
||||||
// Add fallback tiles immediately so the map is visible instantly
|
// Zero-network fallback so mode switches never block on external tiles.
|
||||||
const fallbackTiles = L.tileLayer('https://cartodb-basemaps-{s}.global.ssl.fastly.net/dark_all/{z}/{x}/{y}.png', {
|
const fallbackTiles = createAprsFallbackGridLayer().addTo(aprsMap);
|
||||||
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(aprsMap);
|
|
||||||
|
|
||||||
// Upgrade tiles in background via Settings (with timeout fallback)
|
// Upgrade tiles in background via Settings (with timeout fallback)
|
||||||
if (typeof Settings !== 'undefined') {
|
if (typeof Settings !== 'undefined') {
|
||||||
@@ -10011,7 +10121,8 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Update time display (both map header and function bar)
|
// Update time display (both map header and function bar)
|
||||||
setInterval(() => {
|
if (aprsClockInterval) clearInterval(aprsClockInterval);
|
||||||
|
aprsClockInterval = setInterval(() => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
|
const timeStr = now.toLocaleTimeString('en-US', { hour12: false });
|
||||||
const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
|
const utcStr = now.toUTCString().slice(17, 25) + ' UTC';
|
||||||
@@ -10024,6 +10135,31 @@
|
|||||||
}, 1000);
|
}, 1000);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function destroyAprsMode() {
|
||||||
|
stopAprsMeterCheck();
|
||||||
|
if (aprsEventSource) {
|
||||||
|
aprsEventSource.close();
|
||||||
|
aprsEventSource = null;
|
||||||
|
}
|
||||||
|
if (aprsPollTimer) {
|
||||||
|
clearInterval(aprsPollTimer);
|
||||||
|
aprsPollTimer = null;
|
||||||
|
}
|
||||||
|
if (aprsClockInterval) {
|
||||||
|
clearInterval(aprsClockInterval);
|
||||||
|
aprsClockInterval = null;
|
||||||
|
}
|
||||||
|
if (aprsMap) {
|
||||||
|
try {
|
||||||
|
aprsMap.remove();
|
||||||
|
} catch (_) {}
|
||||||
|
aprsMap = null;
|
||||||
|
window.aprsMap = null;
|
||||||
|
}
|
||||||
|
aprsMarkers = {};
|
||||||
|
aprsUserMarker = null;
|
||||||
|
}
|
||||||
|
|
||||||
function updateAprsStatus(state, freq) {
|
function updateAprsStatus(state, freq) {
|
||||||
// Update function bar status
|
// Update function bar status
|
||||||
const stripDot = document.getElementById('aprsStripDot');
|
const stripDot = document.getElementById('aprsStripDot');
|
||||||
@@ -10717,26 +10853,51 @@
|
|||||||
// GPS FUNCTIONS (gpsd auto-connect)
|
// GPS FUNCTIONS (gpsd auto-connect)
|
||||||
// ============================================
|
// ============================================
|
||||||
|
|
||||||
async function autoConnectGps() {
|
function scheduleGpsAutoConnect(delayMs = 20000) {
|
||||||
// Automatically try to connect to gpsd on page load
|
if (gpsConnected || gpsAutoConnectInFlight || gpsAutoConnectTimer) return;
|
||||||
try {
|
gpsAutoConnectTimer = setTimeout(() => {
|
||||||
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
gpsAutoConnectTimer = null;
|
||||||
const data = await response.json();
|
autoConnectGps();
|
||||||
|
}, delayMs);
|
||||||
|
}
|
||||||
|
|
||||||
if (data.status === 'connected') {
|
async function autoConnectGps() {
|
||||||
gpsConnected = true;
|
if (gpsConnected) return true;
|
||||||
startGpsStream();
|
if (gpsAutoConnectTimer) {
|
||||||
showGpsIndicator(true);
|
clearTimeout(gpsAutoConnectTimer);
|
||||||
console.log('GPS: Auto-connected to gpsd');
|
gpsAutoConnectTimer = null;
|
||||||
if (data.position) {
|
|
||||||
updateLocationFromGps(data.position);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.log('GPS: gpsd not available -', data.message);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.log('GPS: Auto-connect failed -', e.message);
|
|
||||||
}
|
}
|
||||||
|
if (gpsAutoConnectInFlight) {
|
||||||
|
return gpsAutoConnectInFlight;
|
||||||
|
}
|
||||||
|
|
||||||
|
gpsAutoConnectInFlight = (async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/gps/auto-connect', { method: 'POST' });
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'connected') {
|
||||||
|
gpsConnected = true;
|
||||||
|
startGpsStream();
|
||||||
|
showGpsIndicator(true);
|
||||||
|
console.log('GPS: Auto-connected to gpsd');
|
||||||
|
if (data.position) {
|
||||||
|
updateLocationFromGps(data.position);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('GPS: gpsd not available -', data.message);
|
||||||
|
return false;
|
||||||
|
} catch (e) {
|
||||||
|
console.log('GPS: Auto-connect failed -', e.message);
|
||||||
|
return false;
|
||||||
|
} finally {
|
||||||
|
gpsAutoConnectInFlight = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
return gpsAutoConnectInFlight;
|
||||||
}
|
}
|
||||||
|
|
||||||
let gpsReconnectTimeout = null;
|
let gpsReconnectTimeout = null;
|
||||||
@@ -10804,25 +10965,26 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
function updateLocationFromGps(position) {
|
function updateLocationFromGps(position) {
|
||||||
if (!position || !position.latitude || !position.longitude) {
|
const lat = Number(position && position.latitude);
|
||||||
|
const lon = Number(position && position.longitude);
|
||||||
|
const fixQuality = Number(position && position.fix_quality);
|
||||||
|
if (!Number.isFinite(lat) || !Number.isFinite(lon)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (Number.isFinite(fixQuality) && fixQuality < 2) return;
|
||||||
|
|
||||||
// Update satellite observer location
|
// Update satellite observer location
|
||||||
const satLatInput = document.getElementById('obsLat');
|
const satLatInput = document.getElementById('obsLat');
|
||||||
const satLonInput = document.getElementById('obsLon');
|
const satLonInput = document.getElementById('obsLon');
|
||||||
if (satLatInput) satLatInput.value = position.latitude.toFixed(4);
|
if (satLatInput) satLatInput.value = lat.toFixed(4);
|
||||||
if (satLonInput) satLonInput.value = position.longitude.toFixed(4);
|
if (satLonInput) satLonInput.value = lon.toFixed(4);
|
||||||
|
|
||||||
// Update observerLocation
|
// Update observerLocation
|
||||||
observerLocation.lat = position.latitude;
|
observerLocation.lat = lat;
|
||||||
observerLocation.lon = position.longitude;
|
observerLocation.lon = lon;
|
||||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
|
||||||
ObserverLocation.setShared({ lat: position.latitude, lon: position.longitude });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update APRS user location
|
// Keep live GPS separate from the configured shared observer location.
|
||||||
updateAprsUserLocation(position);
|
updateAprsUserLocation({ latitude: lat, longitude: lon });
|
||||||
}
|
}
|
||||||
|
|
||||||
function showGpsIndicator(show) {
|
function showGpsIndicator(show) {
|
||||||
@@ -11496,10 +11658,13 @@
|
|||||||
function fetchCelestrakCategory(category) {
|
function fetchCelestrakCategory(category) {
|
||||||
const status = document.getElementById('celestrakStatus');
|
const status = document.getElementById('celestrakStatus');
|
||||||
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
|
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
|
||||||
|
const controller = new AbortController();
|
||||||
|
const timeout = setTimeout(() => controller.abort(), 15000);
|
||||||
|
|
||||||
fetch('/satellite/celestrak/' + category)
|
fetch('/satellite/celestrak/' + category, { signal: controller.signal })
|
||||||
.then(r => r.json())
|
.then(r => r.json())
|
||||||
.then(async data => {
|
.then(async data => {
|
||||||
|
clearTimeout(timeout);
|
||||||
if (data.status === 'success' && data.satellites) {
|
if (data.status === 'success' && data.satellites) {
|
||||||
const toAdd = data.satellites
|
const toAdd = data.satellites
|
||||||
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
|
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
|
||||||
@@ -11544,8 +11709,10 @@
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
|
clearTimeout(timeout);
|
||||||
const msg = err && err.message ? err.message : 'Network error';
|
const msg = err && err.message ? err.message : 'Network error';
|
||||||
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`;
|
const label = err && err.name === 'AbortError' ? 'Request timed out' : msg;
|
||||||
|
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${label}</span>`;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -11555,7 +11722,7 @@
|
|||||||
.then(data => {
|
.then(data => {
|
||||||
if (data.status === 'success' && data.satellites) {
|
if (data.status === 'success' && data.satellites) {
|
||||||
trackedSatellites = data.satellites.map(sat => ({
|
trackedSatellites = data.satellites.map(sat => ({
|
||||||
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(),
|
id: String(sat.norad_id),
|
||||||
name: sat.name,
|
name: sat.name,
|
||||||
norad: sat.norad_id,
|
norad: sat.norad_id,
|
||||||
builtin: sat.builtin,
|
builtin: sat.builtin,
|
||||||
@@ -11569,8 +11736,9 @@
|
|||||||
// Fallback to hardcoded defaults if API fails
|
// Fallback to hardcoded defaults if API fails
|
||||||
if (trackedSatellites.length === 0) {
|
if (trackedSatellites.length === 0) {
|
||||||
trackedSatellites = [
|
trackedSatellites = [
|
||||||
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
|
{ id: '25544', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
|
||||||
{ id: 'METEOR-M2', name: 'Meteor-M 2', norad: '40069', builtin: true, checked: true }
|
{ id: '57166', name: 'Meteor-M2-3', norad: '57166', builtin: true, checked: true },
|
||||||
|
{ id: '59051', name: 'Meteor-M2-4', norad: '59051', builtin: true, checked: true }
|
||||||
];
|
];
|
||||||
renderSatelliteList();
|
renderSatelliteList();
|
||||||
}
|
}
|
||||||
@@ -14572,7 +14740,6 @@
|
|||||||
document.getElementById('tscmProgress').style.display = 'none';
|
document.getElementById('tscmProgress').style.display = 'none';
|
||||||
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
|
document.getElementById('tscmProgressLabel').textContent = 'Sweep Complete';
|
||||||
document.getElementById('tscmProgressPercent').textContent = '100%';
|
document.getElementById('tscmProgressPercent').textContent = '100%';
|
||||||
document.getElementById('tscmProgressBar').style.width = '100%';
|
|
||||||
|
|
||||||
// Final update of counts
|
// Final update of counts
|
||||||
updateTscmThreatCounts();
|
updateTscmThreatCounts();
|
||||||
@@ -16100,40 +16267,6 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// Check dependencies on page load
|
|
||||||
document.addEventListener('DOMContentLoaded', function () {
|
|
||||||
// Check if user dismissed the startup check
|
|
||||||
const dismissed = localStorage.getItem('depsCheckDismissed');
|
|
||||||
|
|
||||||
// Quick check for missing dependencies
|
|
||||||
fetch('/dependencies')
|
|
||||||
.then(r => r.json())
|
|
||||||
.then(data => {
|
|
||||||
if (data.status === 'success') {
|
|
||||||
let missingModes = 0;
|
|
||||||
let missingTools = [];
|
|
||||||
|
|
||||||
for (const [modeKey, mode] of Object.entries(data.modes)) {
|
|
||||||
if (!mode.ready) {
|
|
||||||
missingModes++;
|
|
||||||
mode.missing_required.forEach(tool => {
|
|
||||||
if (!missingTools.includes(tool)) {
|
|
||||||
missingTools.push(tool);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Show startup prompt if tools are missing and not dismissed
|
|
||||||
// Only show if disclaimer has been accepted
|
|
||||||
const disclaimerAccepted = localStorage.getItem('disclaimerAccepted') === 'true';
|
|
||||||
if (missingModes > 0 && !dismissed && disclaimerAccepted) {
|
|
||||||
showStartupDepsPrompt(missingModes, missingTools.length);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
function showStartupDepsPrompt(modeCount, toolCount) {
|
function showStartupDepsPrompt(modeCount, toolCount) {
|
||||||
const notice = document.createElement('div');
|
const notice = document.createElement('div');
|
||||||
notice.id = 'startupDepsModal';
|
notice.id = 'startupDepsModal';
|
||||||
@@ -16257,18 +16390,108 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- PWA Service Worker Registration -->
|
|
||||||
<script>
|
<script>
|
||||||
if ('serviceWorker' in navigator) {
|
|
||||||
window.addEventListener('load', () => {
|
|
||||||
navigator.serviceWorker.register('/static/sw.js').catch(() => {});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Initialize global core modules after page load
|
// Initialize global core modules after page load
|
||||||
window.addEventListener('DOMContentLoaded', () => {
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
if (typeof VoiceAlerts !== 'undefined') VoiceAlerts.init();
|
if (typeof VoiceAlerts !== 'undefined') {
|
||||||
|
VoiceAlerts.init({ startStreams: false });
|
||||||
|
VoiceAlerts.scheduleStreamStart(20000);
|
||||||
|
}
|
||||||
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
if (typeof KeyboardShortcuts !== 'undefined') KeyboardShortcuts.init();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── Weather-satellite handoff from the satellite dashboard iframe/tab ─────
|
||||||
|
function processWeatherSatHandoff(payload) {
|
||||||
|
if (!payload || payload.type !== 'weather-sat-handoff') return;
|
||||||
|
|
||||||
|
const { satellite, aosTime, tcaEl, duration } = payload;
|
||||||
|
if (!satellite) return;
|
||||||
|
|
||||||
|
// Determine how far away the pass is
|
||||||
|
const aosMs = aosTime ? (new Date(aosTime) - Date.now()) : Infinity;
|
||||||
|
const minsAway = aosMs / 60000;
|
||||||
|
|
||||||
|
// Switch to weather-satellite mode and pre-select the satellite
|
||||||
|
switchMode('weathersat', { updateUrl: true }).then(() => {
|
||||||
|
if (typeof WeatherSat !== 'undefined') {
|
||||||
|
if (minsAway <= 2) {
|
||||||
|
// Pass is imminent — start immediately
|
||||||
|
WeatherSat.startPass(satellite);
|
||||||
|
showNotification('Weather Sat', `Auto-starting capture: ${satellite}`);
|
||||||
|
} else {
|
||||||
|
// Pre-select so the user can review settings and hit Start
|
||||||
|
WeatherSat.preSelect(satellite);
|
||||||
|
showHandoffBanner(satellite, minsAway, tcaEl, duration);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function consumePendingWeatherSatHandoff() {
|
||||||
|
let raw = null;
|
||||||
|
try {
|
||||||
|
raw = window.sessionStorage?.getItem('intercept.pendingWeatherSatHandoff')
|
||||||
|
|| window.localStorage?.getItem('intercept.pendingWeatherSatHandoff');
|
||||||
|
} catch (_) {
|
||||||
|
raw = null;
|
||||||
|
}
|
||||||
|
if (!raw) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
window.sessionStorage?.removeItem('intercept.pendingWeatherSatHandoff');
|
||||||
|
window.localStorage?.removeItem('intercept.pendingWeatherSatHandoff');
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
try {
|
||||||
|
processWeatherSatHandoff(JSON.parse(raw));
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('Failed to consume weather-satellite handoff payload:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('message', (event) => {
|
||||||
|
processWeatherSatHandoff(event.data);
|
||||||
|
});
|
||||||
|
|
||||||
|
function showHandoffBanner(satellite, minsAway, tcaEl, duration) {
|
||||||
|
// Remove any existing banner
|
||||||
|
const existing = document.getElementById('weatherSatHandoffBanner');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
const mins = Math.round(minsAway);
|
||||||
|
const elStr = tcaEl != null ? `${Number(tcaEl).toFixed(0)}°` : '?°';
|
||||||
|
const durStr = duration != null ? `${Math.round(duration)} min` : '';
|
||||||
|
|
||||||
|
const banner = document.createElement('div');
|
||||||
|
banner.id = 'weatherSatHandoffBanner';
|
||||||
|
banner.style.cssText = [
|
||||||
|
'position:fixed', 'top:60px', 'left:50%', 'transform:translateX(-50%)',
|
||||||
|
'background:rgba(0,20,30,0.95)', 'border:1px solid rgba(0,255,136,0.5)',
|
||||||
|
'color:#00ff88', 'font-family:var(--font-mono,monospace)', 'font-size:12px',
|
||||||
|
'padding:10px 18px', 'border-radius:6px', 'z-index:9999',
|
||||||
|
'display:flex', 'align-items:center', 'gap:12px',
|
||||||
|
'box-shadow:0 0 20px rgba(0,255,136,0.2)'
|
||||||
|
].join(';');
|
||||||
|
|
||||||
|
banner.innerHTML = `
|
||||||
|
<span>📡 <strong>${satellite}</strong> pass in <strong>${mins} min</strong> · max ${elStr}${durStr ? ' · ' + durStr : ''} — satellite pre-selected</span>
|
||||||
|
<button onclick="if(typeof WeatherSat!=='undefined')WeatherSat.start();this.closest('#weatherSatHandoffBanner').remove();"
|
||||||
|
style="background:rgba(0,255,136,0.2);border:1px solid rgba(0,255,136,0.5);color:#00ff88;padding:3px 10px;border-radius:4px;cursor:pointer;font-family:inherit;font-size:11px;">
|
||||||
|
Start Now
|
||||||
|
</button>
|
||||||
|
<button onclick="this.closest('#weatherSatHandoffBanner').remove();"
|
||||||
|
style="background:none;border:none;color:#666;cursor:pointer;font-size:14px;padding:0 4px;">✕</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
document.body.appendChild(banner);
|
||||||
|
|
||||||
|
// Auto-dismiss after 2 minutes
|
||||||
|
setTimeout(() => { if (banner.parentNode) banner.remove(); }, 120000);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('DOMContentLoaded', () => {
|
||||||
|
setTimeout(consumePendingWeatherSatHandoff, 250);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
||||||
|
|||||||
@@ -518,7 +518,7 @@
|
|||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body data-mode="controller_monitor">
|
||||||
<header class="header">
|
<header class="header">
|
||||||
<div class="logo">
|
<div class="logo">
|
||||||
NETWORK MONITOR
|
NETWORK MONITOR
|
||||||
@@ -1117,7 +1117,20 @@
|
|||||||
<!-- Help Modal -->
|
<!-- Help Modal -->
|
||||||
{% include 'partials/help-modal.html' %}
|
{% 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/settings-manager.js') }}?v={{ version }}&r=maptheme17"></script>
|
||||||
<script src="{{ url_for('static', filename='js/core/global-nav.js') }}"></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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -6,8 +6,8 @@
|
|||||||
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
|
ALPHA: Weather Satellite capture is experimental and may fail depending on SatDump version, SDR driver support, and pass conditions.
|
||||||
</div>
|
</div>
|
||||||
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
<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.
|
Receive and decode Meteor LRPT weather imagery.
|
||||||
Uses SatDump for live SDR capture and image processing.
|
Uses SatDump for live SDR capture and image processing, and also shows Meteor imagery produced by the ground-station scheduler.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -18,9 +18,6 @@
|
|||||||
<select id="weatherSatSelect" class="mode-select">
|
<select id="weatherSatSelect" class="mode-select">
|
||||||
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
|
<option value="METEOR-M2-3" selected>Meteor-M2-3 (137.900 MHz LRPT)</option>
|
||||||
<option value="METEOR-M2-4">Meteor-M2-4 (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>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@@ -72,7 +69,7 @@
|
|||||||
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
|
<li><strong style="color: var(--text-primary);">Connection:</strong> Solder elements to coax center + shield, connect to SDR via SMA</li>
|
||||||
</ul>
|
</ul>
|
||||||
<p style="margin-top: 6px; color: var(--text-dim); font-style: italic;">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -136,7 +133,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);">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);">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.
|
<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>
|
<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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
@@ -165,10 +162,6 @@
|
|||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Polarization</td>
|
<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>
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">RHCP</td>
|
||||||
</tr>
|
</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>
|
<tr>
|
||||||
<td style="padding: 3px 4px; color: var(--text-dim);">Meteor (LRPT) bandwidth</td>
|
<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>
|
<td style="padding: 3px 4px; color: var(--text-primary); text-align: right;">~140 kHz</td>
|
||||||
@@ -185,31 +178,26 @@
|
|||||||
</h3>
|
</h3>
|
||||||
<div class="wxsat-test-decode-body collapsed" style="overflow: hidden;">
|
<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;">
|
<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.
|
Decode a pre-recorded Meteor IQ file without SDR hardware.
|
||||||
Run <code style="font-size: 10px;">./download-weather-sat-samples.sh</code> to fetch sample files.
|
Shared ground-station recordings are also accepted by the backend.
|
||||||
</p>
|
</p>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Satellite</label>
|
<label>Satellite</label>
|
||||||
<select id="wxsatTestSatSelect" class="mode-select">
|
<select id="wxsatTestSatSelect" class="mode-select">
|
||||||
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
|
<option value="METEOR-M2-3" selected>Meteor-M2-3 (LRPT)</option>
|
||||||
<option value="METEOR-M2-4">Meteor-M2-4 (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>
|
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>File Path (server-side)</label>
|
<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;">
|
<input type="text" id="wxsatTestFilePath" value="data/weather_sat/samples/meteor_lrpt.sigmf-data" style="font-family: 'Roboto Condensed', 'Arial Narrow', sans-serif; font-size: 11px;">
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label>Sample Rate</label>
|
<label>Sample Rate</label>
|
||||||
<select id="wxsatTestSampleRate" class="mode-select">
|
<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="500000">500 kHz (IQ LRPT)</option>
|
||||||
<option value="1000000" selected>1 MHz (IQ default)</option>
|
<option value="1000000">1 MHz (IQ narrow)</option>
|
||||||
<option value="2000000">2 MHz (IQ wideband)</option>
|
<option value="2400000" selected>2.4 MHz (INTERCEPT default)</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
<button class="mode-btn" onclick="WeatherSat.testDecode()" style="width: 100%; margin-top: 4px;">
|
||||||
@@ -241,8 +229,8 @@
|
|||||||
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
SatDump Documentation
|
SatDump Documentation
|
||||||
</a>
|
</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;">
|
<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;">
|
||||||
NOAA Reception Guide
|
Meteor Reception Guide
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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) -%}
|
{% macro mode_item(mode, label, icon_svg, href=None) -%}
|
||||||
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
{%- 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">
|
<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-icon icon">{{ icon_svg | safe }}</span>
|
||||||
<span class="nav-label">{{ label }}</span>
|
<span class="nav-label">{{ label }}</span>
|
||||||
@@ -36,7 +41,11 @@
|
|||||||
|
|
||||||
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
|
{% macro mobile_item(mode, label, icon_svg, href=None) -%}
|
||||||
{%- set is_active = 'active' if active_mode == mode else '' -%}
|
{%- 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">
|
<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 }}
|
<span class="icon icon--sm">{{ icon_svg | safe }}</span> {{ label }}
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
+2978
-354
File diff suppressed because it is too large
Load Diff
@@ -189,6 +189,57 @@ class TestSettingsEndpoints:
|
|||||||
assert data['status'] == 'success'
|
assert data['status'] == 'success'
|
||||||
assert data['deleted'] is True
|
assert data['deleted'] is True
|
||||||
|
|
||||||
|
def test_save_observer_location_updates_env_and_runtime_defaults(self, client, monkeypatch, tmp_path):
|
||||||
|
"""Saving observer location should persist to .env and update in-memory defaults."""
|
||||||
|
import app as app_module
|
||||||
|
import config
|
||||||
|
from routes import adsb as adsb_routes
|
||||||
|
from routes import ais as ais_routes
|
||||||
|
from routes import settings as settings_routes
|
||||||
|
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
|
||||||
|
env_path = tmp_path / '.env'
|
||||||
|
monkeypatch.setattr(settings_routes, '_get_env_file_path', lambda: env_path)
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/settings/observer-location',
|
||||||
|
data=json.dumps({'lat': 48.0, 'lon': 16.16}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
data = json.loads(response.data)
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
assert data['lat'] == 48.0
|
||||||
|
assert data['lon'] == 16.16
|
||||||
|
|
||||||
|
env_text = env_path.read_text()
|
||||||
|
assert 'INTERCEPT_DEFAULT_LAT=48.0' in env_text
|
||||||
|
assert 'INTERCEPT_DEFAULT_LON=16.16' in env_text
|
||||||
|
|
||||||
|
assert config.DEFAULT_LATITUDE == 48.0
|
||||||
|
assert config.DEFAULT_LONGITUDE == 16.16
|
||||||
|
assert app_module.DEFAULT_LATITUDE == 48.0
|
||||||
|
assert app_module.DEFAULT_LONGITUDE == 16.16
|
||||||
|
assert adsb_routes.DEFAULT_LATITUDE == 48.0
|
||||||
|
assert adsb_routes.DEFAULT_LONGITUDE == 16.16
|
||||||
|
assert ais_routes.DEFAULT_LATITUDE == 48.0
|
||||||
|
assert ais_routes.DEFAULT_LONGITUDE == 16.16
|
||||||
|
|
||||||
|
def test_save_observer_location_rejects_invalid_values(self, client):
|
||||||
|
"""Observer location save should validate coordinates."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
|
||||||
|
response = client.post(
|
||||||
|
'/settings/observer-location',
|
||||||
|
data=json.dumps({'lat': 200, 'lon': 16.16}),
|
||||||
|
content_type='application/json'
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
class TestCorrelationEndpoints:
|
class TestCorrelationEndpoints:
|
||||||
"""Tests for correlation API endpoints."""
|
"""Tests for correlation API endpoints."""
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import queue
|
||||||
|
import threading
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@@ -70,6 +72,106 @@ def test_get_satellite_position_skyfield_error(mock_load, client):
|
|||||||
assert response.status_code == 200
|
assert response.status_code == 200
|
||||||
assert response.json['positions'] == []
|
assert response.json['positions'] == []
|
||||||
|
|
||||||
|
def test_tracker_position_has_no_observer_fields():
|
||||||
|
"""SSE tracker positions must NOT include observer-relative fields.
|
||||||
|
|
||||||
|
The tracker runs server-side with a fixed (potentially wrong) observer
|
||||||
|
location. Only the per-request /satellite/position endpoint, which
|
||||||
|
receives the client's actual location, should emit elevation/azimuth/
|
||||||
|
distance/visible.
|
||||||
|
"""
|
||||||
|
from routes.satellite import _start_satellite_tracker
|
||||||
|
|
||||||
|
ISS_TLE = (
|
||||||
|
'ISS (ZARYA)',
|
||||||
|
'1 25544U 98067A 24001.00000000 .00016717 00000-0 30171-3 0 9993',
|
||||||
|
'2 25544 51.6416 20.4567 0004561 45.3212 67.8912 15.49876543123457',
|
||||||
|
)
|
||||||
|
|
||||||
|
sat_q = queue.Queue(maxsize=5)
|
||||||
|
mock_app = MagicMock()
|
||||||
|
mock_app.satellite_queue = sat_q
|
||||||
|
|
||||||
|
from skyfield.api import load as _real_load
|
||||||
|
real_ts = _real_load.timescale(builtin=True)
|
||||||
|
|
||||||
|
# Pre-populate track cache so the tracker loop doesn't block computing 90 points
|
||||||
|
tle_key = (ISS_TLE[0], ISS_TLE[1][:20])
|
||||||
|
stub_track = [{'lat': 0.0, 'lon': float(i), 'past': i < 45} for i in range(91)]
|
||||||
|
with patch('routes.satellite._tle_cache', {'ISS': ISS_TLE}), \
|
||||||
|
patch('routes.satellite.get_tracked_satellites') as mock_tracked, \
|
||||||
|
patch('routes.satellite._track_cache', {tle_key: (stub_track, 1e18)}), \
|
||||||
|
patch('routes.satellite._get_timescale', return_value=real_ts), \
|
||||||
|
patch.dict('sys.modules', {'app': mock_app}):
|
||||||
|
mock_tracked.return_value = [{
|
||||||
|
'name': 'ISS (ZARYA)', 'norad_id': 25544,
|
||||||
|
'tle_line1': ISS_TLE[1], 'tle_line2': ISS_TLE[2],
|
||||||
|
}]
|
||||||
|
|
||||||
|
t = threading.Thread(target=_start_satellite_tracker, daemon=True)
|
||||||
|
t.start()
|
||||||
|
msg = sat_q.get(timeout=10)
|
||||||
|
|
||||||
|
assert msg['type'] == 'positions'
|
||||||
|
pos = msg['positions'][0]
|
||||||
|
for forbidden in ('elevation', 'azimuth', 'distance', 'visible'):
|
||||||
|
assert forbidden not in pos, f"SSE tracker must not emit '{forbidden}'"
|
||||||
|
for required in ('lat', 'lon', 'altitude', 'satellite', 'norad_id'):
|
||||||
|
assert required in pos, f"SSE tracker must emit '{required}'"
|
||||||
|
|
||||||
|
|
||||||
|
def test_predict_passes_currentpos_has_full_fields(client):
|
||||||
|
"""currentPos in pass results must include altitude, elevation, azimuth, distance."""
|
||||||
|
payload = {
|
||||||
|
'latitude': 51.5074,
|
||||||
|
'longitude': -0.1278,
|
||||||
|
'hours': 48,
|
||||||
|
'minEl': 5,
|
||||||
|
'satellites': ['ISS'],
|
||||||
|
}
|
||||||
|
response = client.post('/satellite/predict', json=payload)
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json
|
||||||
|
assert data['status'] == 'success'
|
||||||
|
if data['passes']:
|
||||||
|
cp = data['passes'][0].get('currentPos', {})
|
||||||
|
for field in ('lat', 'lon', 'altitude', 'elevation', 'azimuth', 'distance'):
|
||||||
|
assert field in cp, f"currentPos missing field: {field}"
|
||||||
|
|
||||||
|
|
||||||
|
@patch('routes.satellite.refresh_tle_data', return_value=['ISS'])
|
||||||
|
@patch('routes.satellite._load_db_satellites_into_cache')
|
||||||
|
def test_tle_auto_refresh_schedules_daily_repeat(mock_load_db, mock_refresh):
|
||||||
|
"""After the first TLE refresh, a 24-hour follow-up timer must be scheduled."""
|
||||||
|
import threading as real_threading
|
||||||
|
|
||||||
|
scheduled_delays = []
|
||||||
|
|
||||||
|
class CapturingTimer:
|
||||||
|
def __init__(self, delay, fn, *a, **kw):
|
||||||
|
scheduled_delays.append(delay)
|
||||||
|
self._fn = fn
|
||||||
|
self._delay = delay
|
||||||
|
|
||||||
|
def start(self):
|
||||||
|
# Execute the startup timer inline so we can capture the follow-up
|
||||||
|
if self._delay <= 5:
|
||||||
|
self._fn()
|
||||||
|
|
||||||
|
with patch('routes.satellite.threading') as mock_threading:
|
||||||
|
mock_threading.Timer = CapturingTimer
|
||||||
|
mock_threading.Thread = real_threading.Thread
|
||||||
|
|
||||||
|
from routes.satellite import init_tle_auto_refresh
|
||||||
|
init_tle_auto_refresh()
|
||||||
|
|
||||||
|
# First timer: startup delay (≤5s); second timer: 24h repeat (≥86400s)
|
||||||
|
assert any(d <= 5 for d in scheduled_delays), \
|
||||||
|
f"Expected startup delay timer; got delays: {scheduled_delays}"
|
||||||
|
assert any(d >= 86400 for d in scheduled_delays), \
|
||||||
|
f"Expected ~24h repeat timer; got delays: {scheduled_delays}"
|
||||||
|
|
||||||
|
|
||||||
# Logic Integration Test (Simulating prediction)
|
# Logic Integration Test (Simulating prediction)
|
||||||
def test_predict_passes_empty_cache(client):
|
def test_predict_passes_empty_cache(client):
|
||||||
"""Verify that if the satellite is not in cache, no passes are returned."""
|
"""Verify that if the satellite is not in cache, no passes are returned."""
|
||||||
|
|||||||
@@ -13,6 +13,20 @@ import pytest
|
|||||||
|
|
||||||
from utils.weather_sat_predict import _format_utc_iso, predict_passes
|
from utils.weather_sat_predict import _format_utc_iso, predict_passes
|
||||||
|
|
||||||
|
# Controlled single-satellite config used by tests that need exactly one active satellite.
|
||||||
|
# NOAA-18 was decommissioned Jun 2025 and is inactive in the real WEATHER_SATELLITES,
|
||||||
|
# so tests that assert on satellite-specific fields patch the module-level name.
|
||||||
|
_MOCK_WEATHER_SATS = {
|
||||||
|
'NOAA-18': {
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'pipeline': 'noaa_apt',
|
||||||
|
'tle_key': 'NOAA-18',
|
||||||
|
'active': True,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class TestPredictPasses:
|
class TestPredictPasses:
|
||||||
"""Tests for predict_passes() function."""
|
"""Tests for predict_passes() function."""
|
||||||
@@ -31,6 +45,7 @@ class TestPredictPasses:
|
|||||||
|
|
||||||
assert passes == []
|
assert passes == []
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -96,6 +111,7 @@ class TestPredictPasses:
|
|||||||
assert 'duration' in pass_data
|
assert 'duration' in pass_data
|
||||||
assert 'quality' in pass_data
|
assert 'quality' in pass_data
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -150,6 +166,7 @@ class TestPredictPasses:
|
|||||||
|
|
||||||
assert len(passes) == 0
|
assert len(passes) == 0
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -207,6 +224,7 @@ class TestPredictPasses:
|
|||||||
assert 'trajectory' in passes[0]
|
assert 'trajectory' in passes[0]
|
||||||
assert len(passes[0]['trajectory']) == 30
|
assert len(passes[0]['trajectory']) == 30
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -281,6 +299,7 @@ class TestPredictPasses:
|
|||||||
assert 'groundTrack' in passes[0]
|
assert 'groundTrack' in passes[0]
|
||||||
assert len(passes[0]['groundTrack']) == 60
|
assert len(passes[0]['groundTrack']) == 60
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -336,6 +355,7 @@ class TestPredictPasses:
|
|||||||
assert passes[0]['quality'] == 'excellent'
|
assert passes[0]['quality'] == 'excellent'
|
||||||
assert passes[0]['maxEl'] >= 60
|
assert passes[0]['maxEl'] >= 60
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -391,6 +411,7 @@ class TestPredictPasses:
|
|||||||
assert passes[0]['quality'] == 'good'
|
assert passes[0]['quality'] == 'good'
|
||||||
assert 30 <= passes[0]['maxEl'] < 60
|
assert 30 <= passes[0]['maxEl'] < 60
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -530,6 +551,7 @@ class TestPredictPasses:
|
|||||||
predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
predict_passes(lat=51.5, lon=-0.1, hours=24, min_elevation=15)
|
||||||
# Should not raise
|
# Should not raise
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
@@ -605,6 +627,7 @@ class TestPredictPasses:
|
|||||||
class TestPassDataStructure:
|
class TestPassDataStructure:
|
||||||
"""Tests for pass data structure."""
|
"""Tests for pass data structure."""
|
||||||
|
|
||||||
|
@patch('utils.weather_sat_predict.WEATHER_SATELLITES', _MOCK_WEATHER_SATS)
|
||||||
@patch('utils.weather_sat_predict.load')
|
@patch('utils.weather_sat_predict.load')
|
||||||
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
@patch('utils.weather_sat_predict.TLE_SATELLITES')
|
||||||
@patch('utils.weather_sat_predict.wgs84')
|
@patch('utils.weather_sat_predict.wgs84')
|
||||||
|
|||||||
@@ -11,9 +11,19 @@ from datetime import datetime, timezone
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from utils.weather_sat import WeatherSatImage
|
from utils.weather_sat import WeatherSatImage
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(client):
|
||||||
|
"""Authenticated client for weather-sat route tests."""
|
||||||
|
with client.session_transaction() as sess:
|
||||||
|
sess['logged_in'] = True
|
||||||
|
return client
|
||||||
|
|
||||||
|
|
||||||
class TestWeatherSatRoutes:
|
class TestWeatherSatRoutes:
|
||||||
"""Tests for weather satellite routes."""
|
"""Tests for weather satellite routes."""
|
||||||
|
|
||||||
@@ -68,7 +78,8 @@ class TestWeatherSatRoutes:
|
|||||||
"""POST /weather-sat/start successfully starts capture."""
|
"""POST /weather-sat/start successfully starts capture."""
|
||||||
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
patch('routes.weather_sat.queue.Queue'):
|
patch('routes.weather_sat.queue.Queue'), \
|
||||||
|
patch('app.claim_sdr_device', return_value=None):
|
||||||
|
|
||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
mock_decoder.is_running = False
|
mock_decoder.is_running = False
|
||||||
@@ -96,12 +107,12 @@ class TestWeatherSatRoutes:
|
|||||||
assert data['mode'] == 'APT'
|
assert data['mode'] == 'APT'
|
||||||
assert data['device'] == 0
|
assert data['device'] == 0
|
||||||
|
|
||||||
mock_decoder.start.assert_called_once_with(
|
mock_decoder.start.assert_called_once()
|
||||||
satellite='NOAA-18',
|
call_kwargs = mock_decoder.start.call_args[1]
|
||||||
device_index=0,
|
assert call_kwargs['satellite'] == 'NOAA-18'
|
||||||
gain=40.0,
|
assert call_kwargs['device_index'] == 0
|
||||||
bias_t=False,
|
assert call_kwargs['gain'] == 40.0
|
||||||
)
|
assert call_kwargs['bias_t'] is False
|
||||||
|
|
||||||
def test_start_capture_no_satdump(self, client):
|
def test_start_capture_no_satdump(self, client):
|
||||||
"""POST /weather-sat/start returns error when SatDump unavailable."""
|
"""POST /weather-sat/start returns error when SatDump unavailable."""
|
||||||
@@ -290,7 +301,8 @@ class TestWeatherSatRoutes:
|
|||||||
def test_start_capture_start_failure(self, client):
|
def test_start_capture_start_failure(self, client):
|
||||||
"""POST /weather-sat/start when decoder.start() fails."""
|
"""POST /weather-sat/start when decoder.start() fails."""
|
||||||
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('app.claim_sdr_device', return_value=None):
|
||||||
|
|
||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
mock_decoder.is_running = False
|
mock_decoder.is_running = False
|
||||||
@@ -409,7 +421,13 @@ class TestWeatherSatRoutes:
|
|||||||
def test_test_decode_invalid_sample_rate(self, client):
|
def test_test_decode_invalid_sample_rate(self, client):
|
||||||
"""POST /weather-sat/test-decode with invalid sample rate."""
|
"""POST /weather-sat/test-decode with invalid sample rate."""
|
||||||
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
with patch('routes.weather_sat.is_weather_sat_available', return_value=True), \
|
||||||
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get:
|
patch('routes.weather_sat.get_weather_sat_decoder') as mock_get, \
|
||||||
|
patch('pathlib.Path.is_file', return_value=True), \
|
||||||
|
patch('pathlib.Path.resolve') as mock_resolve:
|
||||||
|
|
||||||
|
mock_path = MagicMock()
|
||||||
|
mock_path.is_relative_to.return_value = True
|
||||||
|
mock_resolve.return_value = mock_path
|
||||||
|
|
||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
mock_decoder.is_running = False
|
mock_decoder.is_running = False
|
||||||
@@ -558,7 +576,7 @@ class TestWeatherSatRoutes:
|
|||||||
mock_decoder = MagicMock()
|
mock_decoder = MagicMock()
|
||||||
mock_get.return_value = mock_decoder
|
mock_get.return_value = mock_decoder
|
||||||
|
|
||||||
response = client.get('/weather-sat/images/../../../etc/passwd')
|
response = client.get('/weather-sat/images/bad!file@name.png')
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert data['status'] == 'error'
|
assert data['status'] == 'error'
|
||||||
@@ -647,7 +665,7 @@ class TestWeatherSatRoutes:
|
|||||||
|
|
||||||
def test_get_passes_success(self, client):
|
def test_get_passes_success(self, client):
|
||||||
"""GET /weather-sat/passes successfully predicts passes."""
|
"""GET /weather-sat/passes successfully predicts passes."""
|
||||||
with patch('routes.weather_sat.predict_passes') as mock_predict:
|
with patch('utils.weather_sat_predict.predict_passes') as mock_predict:
|
||||||
mock_predict.return_value = [
|
mock_predict.return_value = [
|
||||||
{
|
{
|
||||||
'id': 'NOAA-18_202401011200',
|
'id': 'NOAA-18_202401011200',
|
||||||
@@ -676,7 +694,7 @@ class TestWeatherSatRoutes:
|
|||||||
|
|
||||||
def test_get_passes_with_options(self, client):
|
def test_get_passes_with_options(self, client):
|
||||||
"""GET /weather-sat/passes with trajectory and ground track."""
|
"""GET /weather-sat/passes with trajectory and ground track."""
|
||||||
with patch('routes.weather_sat.predict_passes') as mock_predict:
|
with patch('utils.weather_sat_predict.predict_passes') as mock_predict:
|
||||||
mock_predict.return_value = []
|
mock_predict.return_value = []
|
||||||
|
|
||||||
response = client.get(
|
response = client.get(
|
||||||
@@ -696,7 +714,7 @@ class TestWeatherSatRoutes:
|
|||||||
|
|
||||||
def test_get_passes_import_error(self, client):
|
def test_get_passes_import_error(self, client):
|
||||||
"""GET /weather-sat/passes when skyfield not installed."""
|
"""GET /weather-sat/passes when skyfield not installed."""
|
||||||
with patch('routes.weather_sat.predict_passes', side_effect=ImportError):
|
with patch('utils.weather_sat_predict.predict_passes', side_effect=ImportError):
|
||||||
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
|
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
|
||||||
assert response.status_code == 503
|
assert response.status_code == 503
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
@@ -705,7 +723,7 @@ class TestWeatherSatRoutes:
|
|||||||
|
|
||||||
def test_get_passes_prediction_error(self, client):
|
def test_get_passes_prediction_error(self, client):
|
||||||
"""GET /weather-sat/passes when prediction fails."""
|
"""GET /weather-sat/passes when prediction fails."""
|
||||||
with patch('routes.weather_sat.predict_passes', side_effect=Exception('TLE error')):
|
with patch('utils.weather_sat_predict.predict_passes', side_effect=Exception('TLE error')):
|
||||||
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
|
response = client.get('/weather-sat/passes?latitude=51.5&longitude=-0.1')
|
||||||
assert response.status_code == 500
|
assert response.status_code == 500
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
@@ -717,7 +735,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
def test_enable_schedule_success(self, client):
|
def test_enable_schedule_success(self, client):
|
||||||
"""POST /weather-sat/schedule/enable enables scheduler."""
|
"""POST /weather-sat/schedule/enable enables scheduler."""
|
||||||
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
|
||||||
mock_scheduler = MagicMock()
|
mock_scheduler = MagicMock()
|
||||||
mock_scheduler.enable.return_value = {
|
mock_scheduler.enable.return_value = {
|
||||||
'enabled': True,
|
'enabled': True,
|
||||||
@@ -780,7 +798,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
def test_disable_schedule(self, client):
|
def test_disable_schedule(self, client):
|
||||||
"""POST /weather-sat/schedule/disable disables scheduler."""
|
"""POST /weather-sat/schedule/disable disables scheduler."""
|
||||||
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
|
||||||
mock_scheduler = MagicMock()
|
mock_scheduler = MagicMock()
|
||||||
mock_scheduler.disable.return_value = {'status': 'disabled'}
|
mock_scheduler.disable.return_value = {'status': 'disabled'}
|
||||||
mock_get.return_value = mock_scheduler
|
mock_get.return_value = mock_scheduler
|
||||||
@@ -792,7 +810,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
def test_schedule_status(self, client):
|
def test_schedule_status(self, client):
|
||||||
"""GET /weather-sat/schedule/status returns scheduler status."""
|
"""GET /weather-sat/schedule/status returns scheduler status."""
|
||||||
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
|
||||||
mock_scheduler = MagicMock()
|
mock_scheduler = MagicMock()
|
||||||
mock_scheduler.get_status.return_value = {
|
mock_scheduler.get_status.return_value = {
|
||||||
'enabled': False,
|
'enabled': False,
|
||||||
@@ -813,7 +831,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
def test_schedule_passes(self, client):
|
def test_schedule_passes(self, client):
|
||||||
"""GET /weather-sat/schedule/passes lists scheduled passes."""
|
"""GET /weather-sat/schedule/passes lists scheduled passes."""
|
||||||
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
|
||||||
mock_scheduler = MagicMock()
|
mock_scheduler = MagicMock()
|
||||||
mock_scheduler.get_passes.return_value = [
|
mock_scheduler.get_passes.return_value = [
|
||||||
{
|
{
|
||||||
@@ -832,7 +850,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
def test_skip_pass_success(self, client):
|
def test_skip_pass_success(self, client):
|
||||||
"""POST /weather-sat/schedule/skip/<id> skips a pass."""
|
"""POST /weather-sat/schedule/skip/<id> skips a pass."""
|
||||||
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
|
||||||
mock_scheduler = MagicMock()
|
mock_scheduler = MagicMock()
|
||||||
mock_scheduler.skip_pass.return_value = True
|
mock_scheduler.skip_pass.return_value = True
|
||||||
mock_get.return_value = mock_scheduler
|
mock_get.return_value = mock_scheduler
|
||||||
@@ -845,7 +863,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
def test_skip_pass_not_found(self, client):
|
def test_skip_pass_not_found(self, client):
|
||||||
"""POST /weather-sat/schedule/skip/<id> for non-existent pass."""
|
"""POST /weather-sat/schedule/skip/<id> for non-existent pass."""
|
||||||
with patch('routes.weather_sat.get_weather_sat_scheduler') as mock_get:
|
with patch('utils.weather_sat_scheduler.get_weather_sat_scheduler') as mock_get:
|
||||||
mock_scheduler = MagicMock()
|
mock_scheduler = MagicMock()
|
||||||
mock_scheduler.skip_pass.return_value = False
|
mock_scheduler.skip_pass.return_value = False
|
||||||
mock_get.return_value = mock_scheduler
|
mock_get.return_value = mock_scheduler
|
||||||
@@ -855,7 +873,7 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
def test_skip_pass_invalid_id(self, client):
|
def test_skip_pass_invalid_id(self, client):
|
||||||
"""POST /weather-sat/schedule/skip/<id> with invalid ID."""
|
"""POST /weather-sat/schedule/skip/<id> with invalid ID."""
|
||||||
response = client.post('/weather-sat/schedule/skip/../../../etc/passwd')
|
response = client.post('/weather-sat/schedule/skip/invalid!pass@id')
|
||||||
assert response.status_code == 400
|
assert response.status_code == 400
|
||||||
data = response.get_json()
|
data = response.get_json()
|
||||||
assert data['status'] == 'error'
|
assert data['status'] == 'error'
|
||||||
|
|||||||
@@ -191,8 +191,10 @@ class TestWeatherSatScheduler:
|
|||||||
'quality': 'good',
|
'quality': 'good',
|
||||||
}
|
}
|
||||||
sp = ScheduledPass(pass_data)
|
sp = ScheduledPass(pass_data)
|
||||||
sp._timer = MagicMock()
|
mock_pass_timer = MagicMock()
|
||||||
sp._stop_timer = MagicMock()
|
mock_stop_timer = MagicMock()
|
||||||
|
sp._timer = mock_pass_timer
|
||||||
|
sp._stop_timer = mock_stop_timer
|
||||||
scheduler._passes = [sp]
|
scheduler._passes = [sp]
|
||||||
|
|
||||||
result = scheduler.disable()
|
result = scheduler.disable()
|
||||||
@@ -200,8 +202,8 @@ class TestWeatherSatScheduler:
|
|||||||
assert scheduler._enabled is False
|
assert scheduler._enabled is False
|
||||||
assert scheduler._passes == []
|
assert scheduler._passes == []
|
||||||
mock_timer.cancel.assert_called_once()
|
mock_timer.cancel.assert_called_once()
|
||||||
sp._timer.cancel.assert_called_once()
|
mock_pass_timer.cancel.assert_called_once()
|
||||||
sp._stop_timer.cancel.assert_called_once()
|
mock_stop_timer.cancel.assert_called_once()
|
||||||
assert result['status'] == 'disabled'
|
assert result['status'] == 'disabled'
|
||||||
|
|
||||||
def test_skip_pass_success(self):
|
def test_skip_pass_success(self):
|
||||||
@@ -223,7 +225,8 @@ class TestWeatherSatScheduler:
|
|||||||
'quality': 'good',
|
'quality': 'good',
|
||||||
}
|
}
|
||||||
sp = ScheduledPass(pass_data)
|
sp = ScheduledPass(pass_data)
|
||||||
sp._timer = MagicMock()
|
mock_pass_timer = MagicMock()
|
||||||
|
sp._timer = mock_pass_timer
|
||||||
scheduler._passes = [sp]
|
scheduler._passes = [sp]
|
||||||
|
|
||||||
result = scheduler.skip_pass('NOAA-18_202401011200')
|
result = scheduler.skip_pass('NOAA-18_202401011200')
|
||||||
@@ -231,7 +234,7 @@ class TestWeatherSatScheduler:
|
|||||||
assert result is True
|
assert result is True
|
||||||
assert sp.status == 'skipped'
|
assert sp.status == 'skipped'
|
||||||
assert sp.skipped is True
|
assert sp.skipped is True
|
||||||
sp._timer.cancel.assert_called_once()
|
mock_pass_timer.cancel.assert_called_once()
|
||||||
event_cb.assert_called_once()
|
event_cb.assert_called_once()
|
||||||
|
|
||||||
def test_skip_pass_not_found(self):
|
def test_skip_pass_not_found(self):
|
||||||
@@ -531,9 +534,10 @@ class TestWeatherSatScheduler:
|
|||||||
assert event_data['type'] == 'schedule_capture_skipped'
|
assert event_data['type'] == 'schedule_capture_skipped'
|
||||||
assert event_data['reason'] == 'sdr_busy'
|
assert event_data['reason'] == 'sdr_busy'
|
||||||
|
|
||||||
|
@patch('app.claim_sdr_device', return_value=None)
|
||||||
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
@patch('threading.Timer')
|
@patch('threading.Timer')
|
||||||
def test_execute_capture_success(self, mock_timer, mock_get):
|
def test_execute_capture_success(self, mock_timer, mock_get, mock_claim):
|
||||||
"""_execute_capture() should start capture."""
|
"""_execute_capture() should start capture."""
|
||||||
scheduler = WeatherSatScheduler()
|
scheduler = WeatherSatScheduler()
|
||||||
scheduler._enabled = True
|
scheduler._enabled = True
|
||||||
@@ -570,18 +574,18 @@ class TestWeatherSatScheduler:
|
|||||||
|
|
||||||
assert sp.status == 'capturing'
|
assert sp.status == 'capturing'
|
||||||
mock_decoder.set_callback.assert_called_once_with(progress_cb)
|
mock_decoder.set_callback.assert_called_once_with(progress_cb)
|
||||||
mock_decoder.start.assert_called_once_with(
|
call_kwargs = mock_decoder.start.call_args[1]
|
||||||
satellite='NOAA-18',
|
assert call_kwargs['satellite'] == 'NOAA-18'
|
||||||
device_index=0,
|
assert call_kwargs['device_index'] == 0
|
||||||
gain=40.0,
|
assert call_kwargs['gain'] == 40.0
|
||||||
bias_t=False,
|
assert call_kwargs['bias_t'] is False
|
||||||
)
|
|
||||||
event_cb.assert_called_once()
|
event_cb.assert_called_once()
|
||||||
event_data = event_cb.call_args[0][0]
|
event_data = event_cb.call_args[0][0]
|
||||||
assert event_data['type'] == 'schedule_capture_start'
|
assert event_data['type'] == 'schedule_capture_start'
|
||||||
|
|
||||||
|
@patch('app.claim_sdr_device', return_value=None)
|
||||||
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
def test_execute_capture_start_failed(self, mock_get):
|
def test_execute_capture_start_failed(self, mock_get, mock_claim):
|
||||||
"""_execute_capture() should handle start failure."""
|
"""_execute_capture() should handle start failure."""
|
||||||
scheduler = WeatherSatScheduler()
|
scheduler = WeatherSatScheduler()
|
||||||
scheduler._enabled = True
|
scheduler._enabled = True
|
||||||
@@ -773,10 +777,11 @@ class TestUtcIsoParsing:
|
|||||||
class TestSchedulerIntegration:
|
class TestSchedulerIntegration:
|
||||||
"""Integration tests for scheduler."""
|
"""Integration tests for scheduler."""
|
||||||
|
|
||||||
|
@patch('app.claim_sdr_device', return_value=None)
|
||||||
@patch('utils.weather_sat_predict.predict_passes')
|
@patch('utils.weather_sat_predict.predict_passes')
|
||||||
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
@patch('utils.weather_sat_scheduler.get_weather_sat_decoder')
|
||||||
@patch('threading.Timer')
|
@patch('threading.Timer')
|
||||||
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict):
|
def test_full_scheduling_cycle(self, mock_timer, mock_get_decoder, mock_predict, mock_claim):
|
||||||
"""Test complete scheduling cycle from enable to execute."""
|
"""Test complete scheduling cycle from enable to execute."""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
future_pass = {
|
future_pass = {
|
||||||
|
|||||||
+166
-21
@@ -635,6 +635,142 @@ def init_db() -> None:
|
|||||||
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
|
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
|
||||||
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
|
VALUES ('40069', 'METEOR-M2', NULL, NULL, 1, 1)
|
||||||
''')
|
''')
|
||||||
|
conn.execute('''
|
||||||
|
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
|
||||||
|
VALUES ('57166', 'METEOR-M2-3', NULL, NULL, 1, 1)
|
||||||
|
''')
|
||||||
|
conn.execute('''
|
||||||
|
INSERT OR IGNORE INTO tracked_satellites (norad_id, name, tle_line1, tle_line2, enabled, builtin)
|
||||||
|
VALUES ('59051', 'METEOR-M2-4', NULL, NULL, 1, 1)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# =====================================================================
|
||||||
|
# Ground Station Tables (automated observations, IQ recordings)
|
||||||
|
# =====================================================================
|
||||||
|
|
||||||
|
# Observation profiles — per-satellite capture configuration
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS observation_profiles (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
norad_id INTEGER UNIQUE NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
frequency_mhz REAL NOT NULL,
|
||||||
|
decoder_type TEXT NOT NULL DEFAULT 'fm',
|
||||||
|
tasks_json TEXT,
|
||||||
|
gain REAL DEFAULT 40.0,
|
||||||
|
bandwidth_hz INTEGER DEFAULT 200000,
|
||||||
|
min_elevation REAL DEFAULT 10.0,
|
||||||
|
enabled BOOLEAN DEFAULT 1,
|
||||||
|
record_iq BOOLEAN DEFAULT 0,
|
||||||
|
iq_sample_rate INTEGER DEFAULT 2400000,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Observation history — one row per captured pass
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS ground_station_observations (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
profile_id INTEGER,
|
||||||
|
norad_id INTEGER NOT NULL,
|
||||||
|
satellite TEXT NOT NULL,
|
||||||
|
aos_time TEXT,
|
||||||
|
los_time TEXT,
|
||||||
|
status TEXT DEFAULT 'scheduled',
|
||||||
|
output_path TEXT,
|
||||||
|
packets_decoded INTEGER DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (profile_id) REFERENCES observation_profiles(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Per-observation events (packets decoded, Doppler updates, etc.)
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS ground_station_events (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
observation_id INTEGER,
|
||||||
|
event_type TEXT NOT NULL,
|
||||||
|
payload_json TEXT,
|
||||||
|
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# SigMF recordings — one row per IQ recording file pair
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS sigmf_recordings (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
observation_id INTEGER,
|
||||||
|
sigmf_data_path TEXT NOT NULL,
|
||||||
|
sigmf_meta_path TEXT NOT NULL,
|
||||||
|
size_bytes INTEGER DEFAULT 0,
|
||||||
|
sample_rate INTEGER,
|
||||||
|
center_freq_hz INTEGER,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE SET NULL
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS ground_station_outputs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
observation_id INTEGER,
|
||||||
|
norad_id INTEGER,
|
||||||
|
output_type TEXT NOT NULL,
|
||||||
|
backend TEXT,
|
||||||
|
file_path TEXT NOT NULL,
|
||||||
|
preview_path TEXT,
|
||||||
|
metadata_json TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE TABLE IF NOT EXISTS ground_station_decode_jobs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
observation_id INTEGER,
|
||||||
|
norad_id INTEGER,
|
||||||
|
backend TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'queued',
|
||||||
|
input_path TEXT,
|
||||||
|
output_dir TEXT,
|
||||||
|
error_message TEXT,
|
||||||
|
details_json TEXT,
|
||||||
|
started_at TIMESTAMP,
|
||||||
|
completed_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
FOREIGN KEY (observation_id) REFERENCES ground_station_observations(id) ON DELETE CASCADE
|
||||||
|
)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gs_observations_norad
|
||||||
|
ON ground_station_observations(norad_id, created_at)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gs_events_observation
|
||||||
|
ON ground_station_events(observation_id, timestamp)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gs_outputs_observation
|
||||||
|
ON ground_station_outputs(observation_id, created_at)
|
||||||
|
''')
|
||||||
|
|
||||||
|
conn.execute('''
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_gs_decode_jobs_observation
|
||||||
|
ON ground_station_decode_jobs(observation_id, created_at)
|
||||||
|
''')
|
||||||
|
|
||||||
|
# Lightweight schema migrations for existing installs.
|
||||||
|
profile_cols = {
|
||||||
|
row['name'] for row in conn.execute('PRAGMA table_info(observation_profiles)')
|
||||||
|
}
|
||||||
|
if 'tasks_json' not in profile_cols:
|
||||||
|
conn.execute('ALTER TABLE observation_profiles ADD COLUMN tasks_json TEXT')
|
||||||
|
|
||||||
logger.info("Database initialized successfully")
|
logger.info("Database initialized successfully")
|
||||||
|
|
||||||
@@ -2336,28 +2472,37 @@ def add_tracked_satellite(
|
|||||||
|
|
||||||
def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int:
|
def bulk_add_tracked_satellites(satellites_list: list[dict]) -> int:
|
||||||
"""Insert many tracked satellites at once. Returns count of newly inserted."""
|
"""Insert many tracked satellites at once. Returns count of newly inserted."""
|
||||||
added = 0
|
if not satellites_list:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
rows = []
|
||||||
|
for sat in satellites_list:
|
||||||
|
try:
|
||||||
|
rows.append((
|
||||||
|
str(sat['norad_id']),
|
||||||
|
sat['name'],
|
||||||
|
sat.get('tle_line1'),
|
||||||
|
sat.get('tle_line2'),
|
||||||
|
int(sat.get('enabled', True)),
|
||||||
|
int(sat.get('builtin', False)),
|
||||||
|
))
|
||||||
|
except (KeyError, TypeError) as e:
|
||||||
|
logger.warning(f"Skipping malformed satellite entry: {e}")
|
||||||
|
|
||||||
|
norad_ids = [r[0] for r in rows]
|
||||||
|
placeholders = ','.join('?' * len(norad_ids))
|
||||||
|
count_sql = f'SELECT COUNT(*) FROM tracked_satellites WHERE norad_id IN ({placeholders})'
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
for sat in satellites_list:
|
before = conn.execute(count_sql, norad_ids).fetchone()[0]
|
||||||
try:
|
conn.executemany(
|
||||||
cursor = conn.execute(
|
'INSERT OR IGNORE INTO tracked_satellites '
|
||||||
'INSERT OR IGNORE INTO tracked_satellites '
|
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
|
||||||
'(norad_id, name, tle_line1, tle_line2, enabled, builtin) '
|
'VALUES (?, ?, ?, ?, ?, ?)',
|
||||||
'VALUES (?, ?, ?, ?, ?, ?)',
|
rows,
|
||||||
(
|
)
|
||||||
str(sat['norad_id']),
|
after = conn.execute(count_sql, norad_ids).fetchone()[0]
|
||||||
sat['name'],
|
return after - before
|
||||||
sat.get('tle_line1'),
|
|
||||||
sat.get('tle_line2'),
|
|
||||||
int(sat.get('enabled', True)),
|
|
||||||
int(sat.get('builtin', False)),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
if cursor.rowcount > 0:
|
|
||||||
added += 1
|
|
||||||
except (sqlite3.Error, KeyError) as e:
|
|
||||||
logger.warning(f"Error bulk-adding satellite: {e}")
|
|
||||||
return added
|
|
||||||
|
|
||||||
|
|
||||||
def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
|
def update_tracked_satellite(norad_id: str, enabled: bool) -> bool:
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
"""Generalised Doppler shift calculator for satellite observations.
|
||||||
|
|
||||||
|
Extracted from utils/sstv/sstv_decoder.py and generalised to accept any
|
||||||
|
satellite by name (looked up in the live TLE cache) or by raw TLE tuple.
|
||||||
|
|
||||||
|
The sstv_decoder module imports DopplerTracker and DopplerInfo from here.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import threading
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.doppler')
|
||||||
|
|
||||||
|
# Speed of light in m/s
|
||||||
|
SPEED_OF_LIGHT = 299_792_458.0
|
||||||
|
|
||||||
|
# Default Hz threshold before triggering a retune
|
||||||
|
DEFAULT_RETUNE_THRESHOLD_HZ = 500
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DopplerInfo:
|
||||||
|
"""Doppler shift information for a satellite observation."""
|
||||||
|
|
||||||
|
frequency_hz: float
|
||||||
|
shift_hz: float
|
||||||
|
range_rate_km_s: float
|
||||||
|
elevation: float
|
||||||
|
azimuth: float
|
||||||
|
timestamp: datetime
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'frequency_hz': self.frequency_hz,
|
||||||
|
'shift_hz': round(self.shift_hz, 1),
|
||||||
|
'range_rate_km_s': round(self.range_rate_km_s, 3),
|
||||||
|
'elevation': round(self.elevation, 1),
|
||||||
|
'azimuth': round(self.azimuth, 1),
|
||||||
|
'timestamp': self.timestamp.isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DopplerTracker:
|
||||||
|
"""Real-time Doppler shift calculator for satellite tracking.
|
||||||
|
|
||||||
|
Accepts a satellite by name (looked up in the live TLE cache, falling
|
||||||
|
back to static data) **or** a raw TLE tuple ``(name, line1, line2)``
|
||||||
|
passed via the constructor or via :meth:`update_tle`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
satellite_name: str = 'ISS',
|
||||||
|
tle_data: tuple[str, str, str] | None = None,
|
||||||
|
):
|
||||||
|
self._satellite_name = satellite_name
|
||||||
|
self._tle_data = tle_data
|
||||||
|
self._observer_lat: float | None = None
|
||||||
|
self._observer_lon: float | None = None
|
||||||
|
self._satellite = None
|
||||||
|
self._observer = None
|
||||||
|
self._ts = None
|
||||||
|
self._enabled = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def configure(self, latitude: float, longitude: float) -> bool:
|
||||||
|
"""Configure the tracker with an observer location.
|
||||||
|
|
||||||
|
Resolves TLE data, builds the skyfield objects, and marks the
|
||||||
|
tracker enabled. Returns True on success.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
from skyfield.api import EarthSatellite, load, wgs84
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("skyfield not available — Doppler tracking disabled")
|
||||||
|
return False
|
||||||
|
|
||||||
|
tle = self._resolve_tle()
|
||||||
|
if tle is None:
|
||||||
|
logger.error(f"No TLE data for satellite: {self._satellite_name}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
ts = load.timescale(builtin=True)
|
||||||
|
satellite = EarthSatellite(tle[1], tle[2], tle[0], ts)
|
||||||
|
observer = wgs84.latlon(latitude, longitude)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to configure DopplerTracker: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._ts = ts
|
||||||
|
self._satellite = satellite
|
||||||
|
self._observer = observer
|
||||||
|
self._observer_lat = latitude
|
||||||
|
self._observer_lon = longitude
|
||||||
|
self._enabled = True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"DopplerTracker configured for {self._satellite_name} "
|
||||||
|
f"at ({latitude}, {longitude})"
|
||||||
|
)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def update_tle(self, tle_data: tuple[str, str, str]) -> bool:
|
||||||
|
"""Update TLE data and re-configure if already enabled."""
|
||||||
|
self._tle_data = tle_data
|
||||||
|
if (
|
||||||
|
self._enabled
|
||||||
|
and self._observer_lat is not None
|
||||||
|
and self._observer_lon is not None
|
||||||
|
):
|
||||||
|
return self.configure(self._observer_lat, self._observer_lon)
|
||||||
|
return True
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None:
|
||||||
|
"""Calculate the Doppler-corrected receive frequency.
|
||||||
|
|
||||||
|
Returns a :class:`DopplerInfo` or *None* if the tracker is not
|
||||||
|
enabled or the calculation fails.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if not self._enabled or self._satellite is None or self._observer is None:
|
||||||
|
return None
|
||||||
|
ts = self._ts
|
||||||
|
satellite = self._satellite
|
||||||
|
observer = self._observer
|
||||||
|
|
||||||
|
try:
|
||||||
|
t = ts.now()
|
||||||
|
difference = satellite - observer
|
||||||
|
topocentric = difference.at(t)
|
||||||
|
alt, az, distance = topocentric.altaz()
|
||||||
|
|
||||||
|
dt_seconds = 1.0
|
||||||
|
t_future = ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds))
|
||||||
|
topocentric_future = difference.at(t_future)
|
||||||
|
_, _, distance_future = topocentric_future.altaz()
|
||||||
|
|
||||||
|
range_rate_km_s = (distance_future.km - distance.km) / dt_seconds
|
||||||
|
nominal_freq_hz = nominal_freq_mhz * 1_000_000
|
||||||
|
doppler_factor = 1.0 - (range_rate_km_s * 1000.0 / SPEED_OF_LIGHT)
|
||||||
|
corrected_freq_hz = nominal_freq_hz * doppler_factor
|
||||||
|
shift_hz = corrected_freq_hz - nominal_freq_hz
|
||||||
|
|
||||||
|
return DopplerInfo(
|
||||||
|
frequency_hz=corrected_freq_hz,
|
||||||
|
shift_hz=shift_hz,
|
||||||
|
range_rate_km_s=range_rate_km_s,
|
||||||
|
elevation=alt.degrees,
|
||||||
|
azimuth=az.degrees,
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Doppler calculation failed: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _resolve_tle(self) -> tuple[str, str, str] | None:
|
||||||
|
"""Return the best available TLE tuple."""
|
||||||
|
if self._tle_data:
|
||||||
|
return self._tle_data
|
||||||
|
|
||||||
|
# Try the live TLE cache maintained by routes/satellite.py
|
||||||
|
try:
|
||||||
|
from routes.satellite import _tle_cache # type: ignore[import]
|
||||||
|
if _tle_cache:
|
||||||
|
tle = _tle_cache.get(self._satellite_name)
|
||||||
|
if tle:
|
||||||
|
return tle
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Fall back to static bundled data
|
||||||
|
try:
|
||||||
|
from data.satellites import TLE_SATELLITES
|
||||||
|
return TLE_SATELLITES.get(self._satellite_name)
|
||||||
|
except ImportError:
|
||||||
|
return None
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Ground station automation subpackage.
|
||||||
|
|
||||||
|
Provides unattended satellite observation, Doppler correction, IQ recording
|
||||||
|
(SigMF), parallel multi-decoder pipelines, live spectrum, and optional
|
||||||
|
antenna rotator control.
|
||||||
|
|
||||||
|
Public API::
|
||||||
|
|
||||||
|
from utils.ground_station.scheduler import get_ground_station_scheduler
|
||||||
|
from utils.ground_station.observation_profile import ObservationProfile
|
||||||
|
from utils.ground_station.iq_bus import IQBus
|
||||||
|
"""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""IQ bus consumer implementations."""
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"""FMDemodConsumer — demodulates FM from CU8 IQ and pipes PCM to a decoder.
|
||||||
|
|
||||||
|
Performs FM (or AM/USB/LSB) demodulation in-process using numpy — the
|
||||||
|
same algorithm as the listening-post waterfall monitor. The resulting
|
||||||
|
int16 PCM is written to the stdin of a configurable decoder subprocess
|
||||||
|
(e.g. direwolf for AX.25 AFSK or multimon-ng for GMSK/POCSAG).
|
||||||
|
|
||||||
|
Decoded lines from the subprocess stdout are forwarded to an optional
|
||||||
|
``on_decoded`` callback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.process import register_process, safe_terminate, unregister_process
|
||||||
|
from utils.waterfall_fft import cu8_to_complex
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.fm_demod')
|
||||||
|
|
||||||
|
AUDIO_RATE = 48_000 # Hz — standard rate for direwolf / multimon-ng
|
||||||
|
|
||||||
|
|
||||||
|
class FMDemodConsumer:
|
||||||
|
"""CU8 IQ → FM demodulation → int16 PCM → decoder subprocess stdin."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
decoder_cmd: list[str],
|
||||||
|
*,
|
||||||
|
modulation: str = 'fm',
|
||||||
|
on_decoded: Callable[[str], None] | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
decoder_cmd: Decoder command + args, e.g.
|
||||||
|
``['direwolf', '-r', '48000', '-']`` or
|
||||||
|
``['multimon-ng', '-t', 'raw', '-a', 'AFSK1200', '-']``.
|
||||||
|
modulation: ``'fm'``, ``'am'``, ``'usb'``, ``'lsb'``.
|
||||||
|
on_decoded: Callback invoked with each decoded line from stdout.
|
||||||
|
"""
|
||||||
|
self._decoder_cmd = decoder_cmd
|
||||||
|
self._modulation = modulation.lower()
|
||||||
|
self._on_decoded = on_decoded
|
||||||
|
self._proc: subprocess.Popen | None = None
|
||||||
|
self._stdout_thread: threading.Thread | None = None
|
||||||
|
self._center_mhz = 0.0
|
||||||
|
self._sample_rate = 0
|
||||||
|
self._rotator_phase = 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# IQConsumer protocol
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def on_start(
|
||||||
|
self,
|
||||||
|
center_mhz: float,
|
||||||
|
sample_rate: int,
|
||||||
|
*,
|
||||||
|
start_freq_mhz: float,
|
||||||
|
end_freq_mhz: float,
|
||||||
|
) -> None:
|
||||||
|
self._center_mhz = center_mhz
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._rotator_phase = 0.0
|
||||||
|
self._start_proc()
|
||||||
|
|
||||||
|
def on_chunk(self, raw: bytes) -> None:
|
||||||
|
if self._proc is None or self._proc.poll() is not None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
pcm, self._rotator_phase = _demodulate(
|
||||||
|
raw,
|
||||||
|
sample_rate=self._sample_rate,
|
||||||
|
center_mhz=self._center_mhz,
|
||||||
|
monitor_freq_mhz=self._center_mhz, # decode on-center
|
||||||
|
modulation=self._modulation,
|
||||||
|
rotator_phase=self._rotator_phase,
|
||||||
|
)
|
||||||
|
if pcm and self._proc.stdin:
|
||||||
|
self._proc.stdin.write(pcm)
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
pass # decoder exited
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"FMDemodConsumer on_chunk error: {e}")
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
if self._proc:
|
||||||
|
safe_terminate(self._proc)
|
||||||
|
unregister_process(self._proc)
|
||||||
|
self._proc = None
|
||||||
|
if self._stdout_thread and self._stdout_thread.is_alive():
|
||||||
|
self._stdout_thread.join(timeout=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_proc(self) -> None:
|
||||||
|
import shutil
|
||||||
|
if not shutil.which(self._decoder_cmd[0]):
|
||||||
|
logger.warning(
|
||||||
|
f"FMDemodConsumer: decoder '{self._decoder_cmd[0]}' not found — disabled"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
self._decoder_cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
register_process(self._proc)
|
||||||
|
self._stdout_thread = threading.Thread(
|
||||||
|
target=self._read_stdout, daemon=True, name='fm-demod-stdout'
|
||||||
|
)
|
||||||
|
self._stdout_thread.start()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"FMDemodConsumer: failed to start decoder: {e}")
|
||||||
|
self._proc = None
|
||||||
|
|
||||||
|
def _read_stdout(self) -> None:
|
||||||
|
assert self._proc is not None
|
||||||
|
assert self._proc.stdout is not None
|
||||||
|
try:
|
||||||
|
for line in self._proc.stdout:
|
||||||
|
decoded = line.decode('utf-8', errors='replace').rstrip()
|
||||||
|
if decoded and self._on_decoded:
|
||||||
|
try:
|
||||||
|
self._on_decoded(decoded)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"FMDemodConsumer callback error: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# In-process FM demodulation (mirrors waterfall_websocket._demodulate_monitor_audio)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _demodulate(
|
||||||
|
raw: bytes,
|
||||||
|
sample_rate: int,
|
||||||
|
center_mhz: float,
|
||||||
|
monitor_freq_mhz: float,
|
||||||
|
modulation: str,
|
||||||
|
rotator_phase: float,
|
||||||
|
) -> tuple[bytes | None, float]:
|
||||||
|
"""Demodulate CU8 IQ to int16 PCM.
|
||||||
|
|
||||||
|
Returns ``(pcm_bytes, next_rotator_phase)``.
|
||||||
|
"""
|
||||||
|
if len(raw) < 32 or sample_rate <= 0:
|
||||||
|
return None, float(rotator_phase)
|
||||||
|
|
||||||
|
samples = cu8_to_complex(raw)
|
||||||
|
fs = float(sample_rate)
|
||||||
|
freq_offset_hz = (float(monitor_freq_mhz) - float(center_mhz)) * 1e6
|
||||||
|
nyquist = fs * 0.5
|
||||||
|
if abs(freq_offset_hz) > nyquist * 0.98:
|
||||||
|
return None, float(rotator_phase)
|
||||||
|
|
||||||
|
phase_inc = (2.0 * np.pi * freq_offset_hz) / fs
|
||||||
|
n = np.arange(samples.size, dtype=np.float64)
|
||||||
|
rotator = np.exp(-1j * (float(rotator_phase) + phase_inc * n)).astype(np.complex64)
|
||||||
|
next_phase = float((float(rotator_phase) + phase_inc * samples.size) % (2.0 * np.pi))
|
||||||
|
shifted = samples * rotator
|
||||||
|
|
||||||
|
mod = modulation.lower().strip()
|
||||||
|
target_bb = 48_000.0
|
||||||
|
pre_decim = max(1, int(fs // target_bb))
|
||||||
|
if pre_decim > 1:
|
||||||
|
usable = (shifted.size // pre_decim) * pre_decim
|
||||||
|
if usable < pre_decim:
|
||||||
|
return None, next_phase
|
||||||
|
shifted = shifted[:usable].reshape(-1, pre_decim).mean(axis=1)
|
||||||
|
fs1 = fs / pre_decim
|
||||||
|
|
||||||
|
if shifted.size < 16:
|
||||||
|
return None, next_phase
|
||||||
|
|
||||||
|
if mod == 'fm':
|
||||||
|
audio = np.angle(shifted[1:] * np.conj(shifted[:-1])).astype(np.float32)
|
||||||
|
elif mod == 'am':
|
||||||
|
envelope = np.abs(shifted).astype(np.float32)
|
||||||
|
audio = envelope - float(np.mean(envelope))
|
||||||
|
elif mod == 'usb':
|
||||||
|
audio = np.real(shifted).astype(np.float32)
|
||||||
|
elif mod == 'lsb':
|
||||||
|
audio = -np.real(shifted).astype(np.float32)
|
||||||
|
else:
|
||||||
|
audio = np.real(shifted).astype(np.float32)
|
||||||
|
|
||||||
|
if audio.size < 8:
|
||||||
|
return None, next_phase
|
||||||
|
|
||||||
|
audio = audio - float(np.mean(audio))
|
||||||
|
|
||||||
|
# Resample to AUDIO_RATE
|
||||||
|
out_len = int(audio.size * AUDIO_RATE / fs1)
|
||||||
|
if out_len < 32:
|
||||||
|
return None, next_phase
|
||||||
|
x_old = np.linspace(0.0, 1.0, audio.size, endpoint=False, dtype=np.float32)
|
||||||
|
x_new = np.linspace(0.0, 1.0, out_len, endpoint=False, dtype=np.float32)
|
||||||
|
audio = np.interp(x_new, x_old, audio).astype(np.float32)
|
||||||
|
|
||||||
|
peak = float(np.max(np.abs(audio))) if audio.size else 0.0
|
||||||
|
if peak > 0:
|
||||||
|
audio = audio * min(20.0, 0.85 / peak)
|
||||||
|
|
||||||
|
pcm = np.clip(audio, -1.0, 1.0)
|
||||||
|
return (pcm * 32767.0).astype(np.int16).tobytes(), next_phase
|
||||||
@@ -0,0 +1,154 @@
|
|||||||
|
"""GrSatConsumer — pipes CU8 IQ to gr_satellites for packet decoding.
|
||||||
|
|
||||||
|
``gr_satellites`` is a GNU Radio-based multi-satellite decoder
|
||||||
|
(https://github.com/daniestevez/gr-satellites). It accepts complex
|
||||||
|
float32 (cf32) IQ samples on stdin when invoked with ``--iq``.
|
||||||
|
|
||||||
|
This consumer converts CU8 → cf32 via numpy and pipes the result to
|
||||||
|
``gr_satellites``. If the tool is not installed it silently stays
|
||||||
|
disabled.
|
||||||
|
|
||||||
|
Decoded JSON packets are forwarded to an optional ``on_decoded`` callback.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.process import register_process, safe_terminate, unregister_process
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.gr_satellites')
|
||||||
|
|
||||||
|
GR_SATELLITES_BIN = 'gr_satellites'
|
||||||
|
|
||||||
|
|
||||||
|
class GrSatConsumer:
|
||||||
|
"""CU8 IQ → cf32 → gr_satellites stdin → JSON packets."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
satellite_name: str,
|
||||||
|
*,
|
||||||
|
on_decoded: Callable[[dict], None] | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
satellite_name: Satellite name as known to gr_satellites
|
||||||
|
(e.g. ``'NOAA 15'``, ``'ISS'``).
|
||||||
|
on_decoded: Callback invoked with each parsed JSON packet dict.
|
||||||
|
"""
|
||||||
|
self._satellite_name = satellite_name
|
||||||
|
self._on_decoded = on_decoded
|
||||||
|
self._proc: subprocess.Popen | None = None
|
||||||
|
self._stdout_thread: threading.Thread | None = None
|
||||||
|
self._sample_rate = 0
|
||||||
|
self._enabled = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# IQConsumer protocol
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def on_start(
|
||||||
|
self,
|
||||||
|
center_mhz: float,
|
||||||
|
sample_rate: int,
|
||||||
|
*,
|
||||||
|
start_freq_mhz: float,
|
||||||
|
end_freq_mhz: float,
|
||||||
|
) -> None:
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
if not shutil.which(GR_SATELLITES_BIN):
|
||||||
|
logger.info(
|
||||||
|
"gr_satellites not found — GrSatConsumer disabled. "
|
||||||
|
"Install via: pip install gr-satellites or apt install python3-gr-satellites"
|
||||||
|
)
|
||||||
|
self._enabled = False
|
||||||
|
return
|
||||||
|
self._start_proc(sample_rate)
|
||||||
|
|
||||||
|
def on_chunk(self, raw: bytes) -> None:
|
||||||
|
if not self._enabled or self._proc is None or self._proc.poll() is not None:
|
||||||
|
return
|
||||||
|
# Convert CU8 → cf32
|
||||||
|
try:
|
||||||
|
iq = np.frombuffer(raw, dtype=np.uint8).astype(np.float32)
|
||||||
|
cf32 = ((iq - 127.5) / 127.5).view(np.complex64)
|
||||||
|
if self._proc.stdin:
|
||||||
|
self._proc.stdin.write(cf32.tobytes())
|
||||||
|
self._proc.stdin.flush()
|
||||||
|
except (BrokenPipeError, OSError):
|
||||||
|
pass
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"GrSatConsumer on_chunk error: {e}")
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
self._enabled = False
|
||||||
|
if self._proc:
|
||||||
|
safe_terminate(self._proc)
|
||||||
|
unregister_process(self._proc)
|
||||||
|
self._proc = None
|
||||||
|
if self._stdout_thread and self._stdout_thread.is_alive():
|
||||||
|
self._stdout_thread.join(timeout=2)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_proc(self, sample_rate: int) -> None:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
GR_SATELLITES_BIN,
|
||||||
|
self._satellite_name,
|
||||||
|
'--samplerate', str(sample_rate),
|
||||||
|
'--iq',
|
||||||
|
'--json',
|
||||||
|
'-',
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdin=subprocess.PIPE,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.DEVNULL,
|
||||||
|
)
|
||||||
|
register_process(self._proc)
|
||||||
|
self._enabled = True
|
||||||
|
self._stdout_thread = threading.Thread(
|
||||||
|
target=self._read_stdout,
|
||||||
|
args=(_json,),
|
||||||
|
daemon=True,
|
||||||
|
name='gr-sat-stdout',
|
||||||
|
)
|
||||||
|
self._stdout_thread.start()
|
||||||
|
logger.info(f"GrSatConsumer started for '{self._satellite_name}'")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GrSatConsumer: failed to start gr_satellites: {e}")
|
||||||
|
self._proc = None
|
||||||
|
self._enabled = False
|
||||||
|
|
||||||
|
def _read_stdout(self, _json) -> None:
|
||||||
|
assert self._proc is not None
|
||||||
|
assert self._proc.stdout is not None
|
||||||
|
try:
|
||||||
|
for line in self._proc.stdout:
|
||||||
|
text = line.decode('utf-8', errors='replace').rstrip()
|
||||||
|
if not text:
|
||||||
|
continue
|
||||||
|
if self._on_decoded:
|
||||||
|
try:
|
||||||
|
data = _json.loads(text)
|
||||||
|
except _json.JSONDecodeError:
|
||||||
|
data = {'raw': text}
|
||||||
|
try:
|
||||||
|
self._on_decoded(data)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"GrSatConsumer callback error: {e}")
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
"""SigMFConsumer — wraps SigMFWriter as an IQ bus consumer."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sigmf import SigMFMetadata, SigMFWriter
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.sigmf_consumer')
|
||||||
|
|
||||||
|
|
||||||
|
class SigMFConsumer:
|
||||||
|
"""IQ consumer that records CU8 chunks to a SigMF file pair."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
metadata: SigMFMetadata,
|
||||||
|
on_complete: callable | None = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Args:
|
||||||
|
metadata: Pre-populated SigMF metadata (satellite info, freq, etc.)
|
||||||
|
on_complete: Optional callback invoked with ``(meta_path, data_path)``
|
||||||
|
when the recording is closed.
|
||||||
|
"""
|
||||||
|
self._metadata = metadata
|
||||||
|
self._on_complete = on_complete
|
||||||
|
self._writer: SigMFWriter | None = None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# IQConsumer protocol
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def on_start(
|
||||||
|
self,
|
||||||
|
center_mhz: float,
|
||||||
|
sample_rate: int,
|
||||||
|
*,
|
||||||
|
start_freq_mhz: float,
|
||||||
|
end_freq_mhz: float,
|
||||||
|
) -> None:
|
||||||
|
self._metadata.center_frequency_hz = center_mhz * 1e6
|
||||||
|
self._metadata.sample_rate = sample_rate
|
||||||
|
self._writer = SigMFWriter(self._metadata)
|
||||||
|
try:
|
||||||
|
self._writer.open()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"SigMFConsumer: failed to open writer: {e}")
|
||||||
|
self._writer = None
|
||||||
|
|
||||||
|
def on_chunk(self, raw: bytes) -> None:
|
||||||
|
if self._writer is None:
|
||||||
|
return
|
||||||
|
ok = self._writer.write_chunk(raw)
|
||||||
|
if not ok and self._writer.aborted:
|
||||||
|
logger.warning("SigMFConsumer: recording aborted (disk full)")
|
||||||
|
self._writer = None
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
if self._writer is None:
|
||||||
|
return
|
||||||
|
result = self._writer.close()
|
||||||
|
self._writer = None
|
||||||
|
if result and self._on_complete:
|
||||||
|
try:
|
||||||
|
self._on_complete(*result)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"SigMFConsumer on_complete error: {e}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Status
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bytes_written(self) -> int:
|
||||||
|
return self._writer.bytes_written if self._writer else 0
|
||||||
@@ -0,0 +1,121 @@
|
|||||||
|
"""WaterfallConsumer — converts CU8 IQ chunks into binary waterfall frames.
|
||||||
|
|
||||||
|
Frames are placed on an ``output_queue`` that the WebSocket endpoint
|
||||||
|
(``/ws/satellite_waterfall``) drains and sends to the browser.
|
||||||
|
|
||||||
|
Reuses :mod:`utils.waterfall_fft` for FFT processing so the wire format
|
||||||
|
is identical to the main listening-post waterfall.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.waterfall_fft import (
|
||||||
|
build_binary_frame,
|
||||||
|
compute_power_spectrum,
|
||||||
|
cu8_to_complex,
|
||||||
|
quantize_to_uint8,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.waterfall_consumer')
|
||||||
|
|
||||||
|
FFT_SIZE = 1024
|
||||||
|
AVG_COUNT = 4
|
||||||
|
FPS = 20
|
||||||
|
DB_MIN: float | None = None # auto-range
|
||||||
|
DB_MAX: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class WaterfallConsumer:
|
||||||
|
"""IQ consumer that produces waterfall binary frames."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
output_queue: queue.Queue | None = None,
|
||||||
|
fft_size: int = FFT_SIZE,
|
||||||
|
avg_count: int = AVG_COUNT,
|
||||||
|
fps: int = FPS,
|
||||||
|
db_min: float | None = DB_MIN,
|
||||||
|
db_max: float | None = DB_MAX,
|
||||||
|
):
|
||||||
|
self.output_queue: queue.Queue = output_queue or queue.Queue(maxsize=120)
|
||||||
|
self._fft_size = fft_size
|
||||||
|
self._avg_count = avg_count
|
||||||
|
self._fps = fps
|
||||||
|
self._db_min = db_min
|
||||||
|
self._db_max = db_max
|
||||||
|
|
||||||
|
self._center_mhz = 0.0
|
||||||
|
self._start_freq = 0.0
|
||||||
|
self._end_freq = 0.0
|
||||||
|
self._sample_rate = 0
|
||||||
|
self._buffer = b''
|
||||||
|
self._required_bytes = 0
|
||||||
|
self._frame_interval = 1.0 / max(1, fps)
|
||||||
|
self._last_frame_time = 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# IQConsumer protocol
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def on_start(
|
||||||
|
self,
|
||||||
|
center_mhz: float,
|
||||||
|
sample_rate: int,
|
||||||
|
*,
|
||||||
|
start_freq_mhz: float,
|
||||||
|
end_freq_mhz: float,
|
||||||
|
) -> None:
|
||||||
|
self._center_mhz = center_mhz
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._start_freq = start_freq_mhz
|
||||||
|
self._end_freq = end_freq_mhz
|
||||||
|
# How many IQ samples (pairs) we need for one FFT frame
|
||||||
|
required_samples = max(
|
||||||
|
self._fft_size * self._avg_count,
|
||||||
|
sample_rate // max(1, self._fps),
|
||||||
|
)
|
||||||
|
self._required_bytes = required_samples * 2 # 1 byte I + 1 byte Q
|
||||||
|
self._frame_interval = 1.0 / max(1, self._fps)
|
||||||
|
self._buffer = b''
|
||||||
|
self._last_frame_time = 0.0
|
||||||
|
|
||||||
|
def on_chunk(self, raw: bytes) -> None:
|
||||||
|
self._buffer += raw
|
||||||
|
now = time.monotonic()
|
||||||
|
if (now - self._last_frame_time) < self._frame_interval:
|
||||||
|
return
|
||||||
|
if len(self._buffer) < self._required_bytes:
|
||||||
|
return
|
||||||
|
|
||||||
|
chunk = self._buffer[-self._required_bytes:]
|
||||||
|
self._buffer = b''
|
||||||
|
self._last_frame_time = now
|
||||||
|
|
||||||
|
try:
|
||||||
|
samples = cu8_to_complex(chunk)
|
||||||
|
power_db = compute_power_spectrum(
|
||||||
|
samples, fft_size=self._fft_size, avg_count=self._avg_count
|
||||||
|
)
|
||||||
|
quantized = quantize_to_uint8(power_db, db_min=self._db_min, db_max=self._db_max)
|
||||||
|
frame = build_binary_frame(self._start_freq, self._end_freq, quantized)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"WaterfallConsumer FFT error: {e}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Non-blocking enqueue: drop oldest if full
|
||||||
|
if self.output_queue.full():
|
||||||
|
try:
|
||||||
|
self.output_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
self.output_queue.put_nowait(frame)
|
||||||
|
except queue.Full:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
self._buffer = b''
|
||||||
@@ -0,0 +1,307 @@
|
|||||||
|
"""IQ broadcast bus — single SDR producer, multiple consumers.
|
||||||
|
|
||||||
|
The :class:`IQBus` claims an SDR device, spawns a capture subprocess
|
||||||
|
(``rx_sdr`` / ``rtl_sdr``), reads raw CU8 bytes from stdout in a
|
||||||
|
producer thread, and calls :meth:`IQConsumer.on_chunk` on every
|
||||||
|
registered consumer for each chunk.
|
||||||
|
|
||||||
|
Consumers are responsible for their own internal buffering. The bus
|
||||||
|
does *not* block on slow consumers — each consumer's ``on_chunk`` is
|
||||||
|
called in the producer thread, so consumers must be non-blocking.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Protocol, runtime_checkable
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.process import register_process, safe_terminate, unregister_process
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.iq_bus')
|
||||||
|
|
||||||
|
CHUNK_SIZE = 65_536 # bytes per read (~27 ms @ 2.4 Msps CU8)
|
||||||
|
|
||||||
|
|
||||||
|
@runtime_checkable
|
||||||
|
class IQConsumer(Protocol):
|
||||||
|
"""Protocol for objects that receive raw CU8 chunks from the IQ bus."""
|
||||||
|
|
||||||
|
def on_chunk(self, raw: bytes) -> None:
|
||||||
|
"""Called with each raw CU8 chunk from the SDR. Must be fast."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def on_start(
|
||||||
|
self,
|
||||||
|
center_mhz: float,
|
||||||
|
sample_rate: int,
|
||||||
|
*,
|
||||||
|
start_freq_mhz: float,
|
||||||
|
end_freq_mhz: float,
|
||||||
|
) -> None:
|
||||||
|
"""Called once when the bus starts, before the first chunk."""
|
||||||
|
...
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
"""Called once when the bus stops (LOS or manual stop)."""
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
class _NoopConsumer:
|
||||||
|
"""Fallback used internally for isinstance checks."""
|
||||||
|
|
||||||
|
def on_chunk(self, raw: bytes) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_start(self, center_mhz, sample_rate, *, start_freq_mhz, end_freq_mhz):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def on_stop(self) -> None:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class IQBus:
|
||||||
|
"""Single-SDR IQ capture bus with fan-out to multiple consumers."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
center_mhz: float,
|
||||||
|
sample_rate: int = 2_400_000,
|
||||||
|
gain: float | None = None,
|
||||||
|
device_index: int = 0,
|
||||||
|
sdr_type: str = 'rtlsdr',
|
||||||
|
ppm: int | None = None,
|
||||||
|
bias_t: bool = False,
|
||||||
|
):
|
||||||
|
self._center_mhz = center_mhz
|
||||||
|
self._sample_rate = sample_rate
|
||||||
|
self._gain = gain
|
||||||
|
self._device_index = device_index
|
||||||
|
self._sdr_type = sdr_type
|
||||||
|
self._ppm = ppm
|
||||||
|
self._bias_t = bias_t
|
||||||
|
|
||||||
|
self._consumers: list[IQConsumer] = []
|
||||||
|
self._consumers_lock = threading.Lock()
|
||||||
|
self._proc: subprocess.Popen | None = None
|
||||||
|
self._producer_thread: threading.Thread | None = None
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._running = False
|
||||||
|
self._current_freq_mhz = center_mhz
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Consumer management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def add_consumer(self, consumer: IQConsumer) -> None:
|
||||||
|
with self._consumers_lock:
|
||||||
|
if consumer not in self._consumers:
|
||||||
|
self._consumers.append(consumer)
|
||||||
|
|
||||||
|
def remove_consumer(self, consumer: IQConsumer) -> None:
|
||||||
|
with self._consumers_lock:
|
||||||
|
self._consumers = [c for c in self._consumers if c is not consumer]
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Lifecycle
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def start(self) -> tuple[bool, str]:
|
||||||
|
"""Start IQ capture. Returns (success, error_message)."""
|
||||||
|
if self._running:
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
try:
|
||||||
|
cmd = self._build_command(self._center_mhz)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f'Failed to build IQ capture command: {e}'
|
||||||
|
|
||||||
|
if not shutil.which(cmd[0]):
|
||||||
|
return False, f'Required tool "{cmd[0]}" not found. Install SoapySDR (rx_sdr) or rtl-sdr.'
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
register_process(self._proc)
|
||||||
|
except Exception as e:
|
||||||
|
return False, f'Failed to spawn IQ capture: {e}'
|
||||||
|
|
||||||
|
# Brief check that the process actually started
|
||||||
|
time.sleep(0.3)
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
stderr_out = ''
|
||||||
|
if self._proc.stderr:
|
||||||
|
try:
|
||||||
|
stderr_out = self._proc.stderr.read().decode('utf-8', errors='replace').strip()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
unregister_process(self._proc)
|
||||||
|
self._proc = None
|
||||||
|
detail = f': {stderr_out}' if stderr_out else ''
|
||||||
|
return False, f'IQ capture process exited immediately{detail}'
|
||||||
|
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
span_mhz = self._sample_rate / 1e6
|
||||||
|
start_freq_mhz = self._center_mhz - span_mhz / 2
|
||||||
|
end_freq_mhz = self._center_mhz + span_mhz / 2
|
||||||
|
|
||||||
|
with self._consumers_lock:
|
||||||
|
for consumer in list(self._consumers):
|
||||||
|
try:
|
||||||
|
consumer.on_start(
|
||||||
|
self._center_mhz,
|
||||||
|
self._sample_rate,
|
||||||
|
start_freq_mhz=start_freq_mhz,
|
||||||
|
end_freq_mhz=end_freq_mhz,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Consumer on_start error: {e}")
|
||||||
|
|
||||||
|
self._producer_thread = threading.Thread(
|
||||||
|
target=self._producer_loop, daemon=True, name='iq-bus-producer'
|
||||||
|
)
|
||||||
|
self._producer_thread.start()
|
||||||
|
logger.info(
|
||||||
|
f"IQBus started: {self._center_mhz} MHz, sr={self._sample_rate}, "
|
||||||
|
f"device={self._sdr_type}:{self._device_index}"
|
||||||
|
)
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop IQ capture and notify all consumers."""
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._proc:
|
||||||
|
safe_terminate(self._proc)
|
||||||
|
unregister_process(self._proc)
|
||||||
|
self._proc = None
|
||||||
|
if self._producer_thread and self._producer_thread.is_alive():
|
||||||
|
self._producer_thread.join(timeout=3)
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
with self._consumers_lock:
|
||||||
|
for consumer in list(self._consumers):
|
||||||
|
try:
|
||||||
|
consumer.on_stop()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Consumer on_stop error: {e}")
|
||||||
|
|
||||||
|
logger.info("IQBus stopped")
|
||||||
|
|
||||||
|
def retune(self, new_freq_mhz: float) -> tuple[bool, str]:
|
||||||
|
"""Retune by stopping and restarting the capture process."""
|
||||||
|
self._current_freq_mhz = new_freq_mhz
|
||||||
|
if not self._running:
|
||||||
|
return False, 'Not running'
|
||||||
|
|
||||||
|
# Stop the current process
|
||||||
|
self._stop_event.set()
|
||||||
|
if self._proc:
|
||||||
|
safe_terminate(self._proc)
|
||||||
|
unregister_process(self._proc)
|
||||||
|
self._proc = None
|
||||||
|
if self._producer_thread and self._producer_thread.is_alive():
|
||||||
|
self._producer_thread.join(timeout=2)
|
||||||
|
|
||||||
|
# Restart at new frequency
|
||||||
|
self._stop_event.clear()
|
||||||
|
try:
|
||||||
|
cmd = self._build_command(new_freq_mhz)
|
||||||
|
self._proc = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.PIPE,
|
||||||
|
bufsize=0,
|
||||||
|
)
|
||||||
|
register_process(self._proc)
|
||||||
|
except Exception as e:
|
||||||
|
self._running = False
|
||||||
|
return False, f'Retune failed: {e}'
|
||||||
|
|
||||||
|
self._producer_thread = threading.Thread(
|
||||||
|
target=self._producer_loop, daemon=True, name='iq-bus-producer'
|
||||||
|
)
|
||||||
|
self._producer_thread.start()
|
||||||
|
logger.info(f"IQBus retuned to {new_freq_mhz:.6f} MHz")
|
||||||
|
return True, ''
|
||||||
|
|
||||||
|
@property
|
||||||
|
def running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def center_mhz(self) -> float:
|
||||||
|
return self._current_freq_mhz
|
||||||
|
|
||||||
|
@property
|
||||||
|
def sample_rate(self) -> int:
|
||||||
|
return self._sample_rate
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _producer_loop(self) -> None:
|
||||||
|
"""Read CU8 chunks from the subprocess and fan out to consumers."""
|
||||||
|
assert self._proc is not None
|
||||||
|
assert self._proc.stdout is not None
|
||||||
|
|
||||||
|
try:
|
||||||
|
while not self._stop_event.is_set():
|
||||||
|
if self._proc.poll() is not None:
|
||||||
|
logger.warning("IQBus: capture process exited unexpectedly")
|
||||||
|
break
|
||||||
|
raw = self._proc.stdout.read(CHUNK_SIZE)
|
||||||
|
if not raw:
|
||||||
|
break
|
||||||
|
with self._consumers_lock:
|
||||||
|
consumers = list(self._consumers)
|
||||||
|
for consumer in consumers:
|
||||||
|
try:
|
||||||
|
consumer.on_chunk(raw)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Consumer on_chunk error: {e}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"IQBus producer loop error: {e}")
|
||||||
|
|
||||||
|
def _build_command(self, freq_mhz: float) -> list[str]:
|
||||||
|
"""Build the IQ capture command using the SDR factory."""
|
||||||
|
from utils.sdr import SDRFactory, SDRType
|
||||||
|
from utils.sdr.base import SDRDevice
|
||||||
|
|
||||||
|
type_map = {
|
||||||
|
'rtlsdr': SDRType.RTL_SDR,
|
||||||
|
'rtl_sdr': SDRType.RTL_SDR,
|
||||||
|
'hackrf': SDRType.HACKRF,
|
||||||
|
'limesdr': SDRType.LIME_SDR,
|
||||||
|
'airspy': SDRType.AIRSPY,
|
||||||
|
'sdrplay': SDRType.SDRPLAY,
|
||||||
|
}
|
||||||
|
sdr_type = type_map.get(self._sdr_type.lower(), SDRType.RTL_SDR)
|
||||||
|
builder = SDRFactory.get_builder(sdr_type)
|
||||||
|
caps = builder.get_capabilities()
|
||||||
|
device = SDRDevice(
|
||||||
|
sdr_type=sdr_type,
|
||||||
|
index=self._device_index,
|
||||||
|
name=f'{sdr_type.value}-{self._device_index}',
|
||||||
|
serial='N/A',
|
||||||
|
driver=sdr_type.value,
|
||||||
|
capabilities=caps,
|
||||||
|
)
|
||||||
|
return builder.build_iq_capture_command(
|
||||||
|
device=device,
|
||||||
|
frequency_mhz=freq_mhz,
|
||||||
|
sample_rate=self._sample_rate,
|
||||||
|
gain=self._gain,
|
||||||
|
ppm=self._ppm,
|
||||||
|
bias_t=self._bias_t,
|
||||||
|
)
|
||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""Meteor LRPT offline decode backend for ground-station observations."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.weather_sat import WeatherSatDecoder
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.meteor_backend')
|
||||||
|
|
||||||
|
OUTPUT_ROOT = Path('instance/ground_station/weather_outputs')
|
||||||
|
DECODE_TIMEOUT_SECONDS = 30 * 60
|
||||||
|
|
||||||
|
_NORAD_TO_SAT_KEY = {
|
||||||
|
57166: 'METEOR-M2-3',
|
||||||
|
59051: 'METEOR-M2-4',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_meteor_satellite_key(norad_id: int, satellite_name: str) -> str | None:
|
||||||
|
if norad_id in _NORAD_TO_SAT_KEY:
|
||||||
|
return _NORAD_TO_SAT_KEY[norad_id]
|
||||||
|
|
||||||
|
upper = str(satellite_name or '').upper()
|
||||||
|
if 'M2-4' in upper:
|
||||||
|
return 'METEOR-M2-4'
|
||||||
|
if 'M2-3' in upper or 'METEOR' in upper:
|
||||||
|
return 'METEOR-M2-3'
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def launch_meteor_decode(
|
||||||
|
*,
|
||||||
|
obs_db_id: int | None,
|
||||||
|
norad_id: int,
|
||||||
|
satellite_name: str,
|
||||||
|
sample_rate: int,
|
||||||
|
data_path: Path,
|
||||||
|
emit_event,
|
||||||
|
register_output,
|
||||||
|
) -> None:
|
||||||
|
"""Run Meteor LRPT offline decode in a background thread."""
|
||||||
|
decode_job_id = _create_decode_job(
|
||||||
|
observation_id=obs_db_id,
|
||||||
|
norad_id=norad_id,
|
||||||
|
backend='meteor_lrpt',
|
||||||
|
input_path=data_path,
|
||||||
|
)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_queued',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'input_path': str(data_path),
|
||||||
|
})
|
||||||
|
t = threading.Thread(
|
||||||
|
target=_run_decode,
|
||||||
|
kwargs={
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'obs_db_id': obs_db_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite_name': satellite_name,
|
||||||
|
'sample_rate': sample_rate,
|
||||||
|
'data_path': data_path,
|
||||||
|
'emit_event': emit_event,
|
||||||
|
'register_output': register_output,
|
||||||
|
},
|
||||||
|
daemon=True,
|
||||||
|
name=f'gs-meteor-decode-{norad_id}',
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
|
||||||
|
|
||||||
|
def _run_decode(
|
||||||
|
*,
|
||||||
|
decode_job_id: int | None,
|
||||||
|
obs_db_id: int | None,
|
||||||
|
norad_id: int,
|
||||||
|
satellite_name: str,
|
||||||
|
sample_rate: int,
|
||||||
|
data_path: Path,
|
||||||
|
emit_event,
|
||||||
|
register_output,
|
||||||
|
) -> None:
|
||||||
|
latest_status: dict[str, str | int | None] = {
|
||||||
|
'message': None,
|
||||||
|
'status': None,
|
||||||
|
'phase': None,
|
||||||
|
}
|
||||||
|
sat_key = resolve_meteor_satellite_key(norad_id, satellite_name)
|
||||||
|
if not sat_key:
|
||||||
|
_update_decode_job(
|
||||||
|
decode_job_id,
|
||||||
|
status='failed',
|
||||||
|
error_message='No Meteor satellite mapping is available for this observation.',
|
||||||
|
details={'reason': 'unknown_satellite_mapping'},
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_failed',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'message': 'No Meteor satellite mapping is available for this observation.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
output_dir = OUTPUT_ROOT / f'{norad_id}_{int(time.time())}'
|
||||||
|
decoder = WeatherSatDecoder(output_dir=output_dir)
|
||||||
|
if decoder.decoder_available is None:
|
||||||
|
_update_decode_job(
|
||||||
|
decode_job_id,
|
||||||
|
status='failed',
|
||||||
|
error_message='SatDump backend is not available for Meteor LRPT decode.',
|
||||||
|
details={'reason': 'backend_unavailable', 'output_dir': str(output_dir)},
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_failed',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'message': 'SatDump backend is not available for Meteor LRPT decode.',
|
||||||
|
})
|
||||||
|
return
|
||||||
|
|
||||||
|
def _progress_cb(progress):
|
||||||
|
latest_status['message'] = progress.message or latest_status.get('message')
|
||||||
|
latest_status['status'] = progress.status
|
||||||
|
latest_status['phase'] = progress.capture_phase or latest_status.get('phase')
|
||||||
|
progress_event = progress.to_dict()
|
||||||
|
progress_event.pop('type', None)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_progress',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
**progress_event,
|
||||||
|
})
|
||||||
|
|
||||||
|
decoder.set_callback(_progress_cb)
|
||||||
|
_update_decode_job(
|
||||||
|
decode_job_id,
|
||||||
|
status='decoding',
|
||||||
|
output_dir=output_dir,
|
||||||
|
details={
|
||||||
|
'sample_rate': sample_rate,
|
||||||
|
'input_path': str(data_path),
|
||||||
|
'satellite': satellite_name,
|
||||||
|
},
|
||||||
|
started=True,
|
||||||
|
)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_started',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'input_path': str(data_path),
|
||||||
|
})
|
||||||
|
|
||||||
|
ok, error = decoder.start_from_file(
|
||||||
|
satellite=sat_key,
|
||||||
|
input_file=data_path,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
details = _build_failure_details(
|
||||||
|
data_path=data_path,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
decoder=decoder,
|
||||||
|
latest_status=latest_status,
|
||||||
|
)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_failed',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'message': error or details['message'],
|
||||||
|
'failure_reason': details['reason'],
|
||||||
|
'details': details,
|
||||||
|
})
|
||||||
|
_update_decode_job(
|
||||||
|
decode_job_id,
|
||||||
|
status='failed',
|
||||||
|
error_message=error or details['message'],
|
||||||
|
details=details,
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
started = time.time()
|
||||||
|
while decoder.is_running and (time.time() - started) < DECODE_TIMEOUT_SECONDS:
|
||||||
|
time.sleep(1.0)
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
decoder.stop()
|
||||||
|
details = _build_failure_details(
|
||||||
|
data_path=data_path,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
decoder=decoder,
|
||||||
|
latest_status=latest_status,
|
||||||
|
override_reason='timeout',
|
||||||
|
override_message='Meteor decode timed out.',
|
||||||
|
)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_failed',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'message': details['message'],
|
||||||
|
'failure_reason': details['reason'],
|
||||||
|
'details': details,
|
||||||
|
})
|
||||||
|
_update_decode_job(
|
||||||
|
decode_job_id,
|
||||||
|
status='failed',
|
||||||
|
error_message=details['message'],
|
||||||
|
details=details,
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
images = decoder.get_images()
|
||||||
|
if not images:
|
||||||
|
details = _build_failure_details(
|
||||||
|
data_path=data_path,
|
||||||
|
sample_rate=sample_rate,
|
||||||
|
decoder=decoder,
|
||||||
|
latest_status=latest_status,
|
||||||
|
)
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_failed',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'message': details['message'],
|
||||||
|
'failure_reason': details['reason'],
|
||||||
|
'details': details,
|
||||||
|
})
|
||||||
|
_update_decode_job(
|
||||||
|
decode_job_id,
|
||||||
|
status='failed',
|
||||||
|
error_message=details['message'],
|
||||||
|
details=details,
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
outputs = []
|
||||||
|
for image in images:
|
||||||
|
metadata = {
|
||||||
|
'satellite': image.satellite,
|
||||||
|
'mode': image.mode,
|
||||||
|
'frequency': image.frequency,
|
||||||
|
'product': image.product,
|
||||||
|
'timestamp': image.timestamp.isoformat(),
|
||||||
|
'size_bytes': image.size_bytes,
|
||||||
|
}
|
||||||
|
output_id = register_output(
|
||||||
|
observation_id=obs_db_id,
|
||||||
|
norad_id=norad_id,
|
||||||
|
output_type='image',
|
||||||
|
backend='meteor_lrpt',
|
||||||
|
file_path=image.path,
|
||||||
|
preview_path=image.path,
|
||||||
|
metadata=metadata,
|
||||||
|
)
|
||||||
|
outputs.append({
|
||||||
|
'id': output_id,
|
||||||
|
'file_path': str(image.path),
|
||||||
|
'filename': image.filename,
|
||||||
|
'product': image.product,
|
||||||
|
})
|
||||||
|
|
||||||
|
completion_details = {
|
||||||
|
'sample_rate': sample_rate,
|
||||||
|
'input_path': str(data_path),
|
||||||
|
'output_dir': str(output_dir),
|
||||||
|
'output_count': len(outputs),
|
||||||
|
}
|
||||||
|
_update_decode_job(
|
||||||
|
decode_job_id,
|
||||||
|
status='complete',
|
||||||
|
details=completion_details,
|
||||||
|
completed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
emit_event({
|
||||||
|
'type': 'weather_decode_complete',
|
||||||
|
'decode_job_id': decode_job_id,
|
||||||
|
'norad_id': norad_id,
|
||||||
|
'satellite': satellite_name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'outputs': outputs,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def _build_failure_details(
|
||||||
|
*,
|
||||||
|
data_path: Path,
|
||||||
|
sample_rate: int,
|
||||||
|
decoder: WeatherSatDecoder,
|
||||||
|
latest_status: dict[str, str | int | None],
|
||||||
|
override_reason: str | None = None,
|
||||||
|
override_message: str | None = None,
|
||||||
|
) -> dict[str, str | int | None]:
|
||||||
|
file_size = data_path.stat().st_size if data_path.exists() else 0
|
||||||
|
status = decoder.get_status()
|
||||||
|
last_error = str(status.get('last_error') or latest_status.get('message') or '').strip()
|
||||||
|
return_code = status.get('last_returncode')
|
||||||
|
|
||||||
|
if override_reason:
|
||||||
|
reason = override_reason
|
||||||
|
elif sample_rate < 200_000:
|
||||||
|
reason = 'sample_rate_too_low'
|
||||||
|
elif not data_path.exists():
|
||||||
|
reason = 'missing_recording'
|
||||||
|
elif file_size < 5_000_000:
|
||||||
|
reason = 'recording_too_small'
|
||||||
|
elif return_code not in (None, 0):
|
||||||
|
reason = 'satdump_failed'
|
||||||
|
elif 'samplerate' in last_error.lower() or 'sample rate' in last_error.lower():
|
||||||
|
reason = 'invalid_sample_rate'
|
||||||
|
elif 'not found' in last_error.lower():
|
||||||
|
reason = 'input_missing'
|
||||||
|
elif 'permission' in last_error.lower():
|
||||||
|
reason = 'permission_error'
|
||||||
|
else:
|
||||||
|
reason = 'no_imagery_produced'
|
||||||
|
|
||||||
|
if override_message:
|
||||||
|
message = override_message
|
||||||
|
elif reason == 'sample_rate_too_low':
|
||||||
|
message = f'Sample rate {sample_rate} Hz is too low for Meteor LRPT decoding.'
|
||||||
|
elif reason == 'missing_recording':
|
||||||
|
message = 'The recording file was not found when decode started.'
|
||||||
|
elif reason == 'recording_too_small':
|
||||||
|
message = (
|
||||||
|
f'Recording is very small ({_format_bytes(file_size)}); this usually means the pass '
|
||||||
|
'ended early or little usable IQ was captured.'
|
||||||
|
)
|
||||||
|
elif reason == 'satdump_failed':
|
||||||
|
message = last_error or f'SatDump exited with code {return_code}.'
|
||||||
|
elif reason == 'invalid_sample_rate':
|
||||||
|
message = last_error or 'SatDump rejected the recording sample rate.'
|
||||||
|
elif reason == 'input_missing':
|
||||||
|
message = last_error or 'Input recording was not accessible to the decoder.'
|
||||||
|
elif reason == 'permission_error':
|
||||||
|
message = last_error or 'Decoder could not access the recording or output path.'
|
||||||
|
else:
|
||||||
|
message = (
|
||||||
|
last_error or
|
||||||
|
'Decode completed without any image outputs. This usually indicates weak signal, '
|
||||||
|
'incorrect sample rate, or a SatDump pipeline mismatch.'
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'reason': reason,
|
||||||
|
'message': message,
|
||||||
|
'sample_rate': sample_rate,
|
||||||
|
'file_size_bytes': file_size,
|
||||||
|
'file_size_human': _format_bytes(file_size),
|
||||||
|
'last_error': last_error or None,
|
||||||
|
'last_returncode': return_code,
|
||||||
|
'capture_phase': status.get('capture_phase') or latest_status.get('phase'),
|
||||||
|
'input_path': str(data_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _format_bytes(size_bytes: int) -> str:
|
||||||
|
if size_bytes < 1024:
|
||||||
|
return f'{size_bytes} B'
|
||||||
|
if size_bytes < 1024 * 1024:
|
||||||
|
return f'{size_bytes / 1024:.1f} KB'
|
||||||
|
if size_bytes < 1024 * 1024 * 1024:
|
||||||
|
return f'{size_bytes / (1024 * 1024):.1f} MB'
|
||||||
|
return f'{size_bytes / (1024 * 1024 * 1024):.2f} GB'
|
||||||
|
|
||||||
|
|
||||||
|
def _create_decode_job(
|
||||||
|
*,
|
||||||
|
observation_id: int | None,
|
||||||
|
norad_id: int,
|
||||||
|
backend: str,
|
||||||
|
input_path: Path,
|
||||||
|
) -> int | None:
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
'''
|
||||||
|
INSERT INTO ground_station_decode_jobs
|
||||||
|
(observation_id, norad_id, backend, status, input_path, created_at, updated_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''',
|
||||||
|
(
|
||||||
|
observation_id,
|
||||||
|
norad_id,
|
||||||
|
backend,
|
||||||
|
'queued',
|
||||||
|
str(input_path),
|
||||||
|
_utcnow_iso(),
|
||||||
|
_utcnow_iso(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to create decode job: %s", e)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _update_decode_job(
|
||||||
|
decode_job_id: int | None,
|
||||||
|
*,
|
||||||
|
status: str,
|
||||||
|
output_dir: Path | None = None,
|
||||||
|
error_message: str | None = None,
|
||||||
|
details: dict | None = None,
|
||||||
|
started: bool = False,
|
||||||
|
completed: bool = False,
|
||||||
|
) -> None:
|
||||||
|
if decode_job_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
|
fields = ['status = ?', 'updated_at = ?']
|
||||||
|
values: list[object] = [status, _utcnow_iso()]
|
||||||
|
|
||||||
|
if output_dir is not None:
|
||||||
|
fields.append('output_dir = ?')
|
||||||
|
values.append(str(output_dir))
|
||||||
|
if error_message is not None:
|
||||||
|
fields.append('error_message = ?')
|
||||||
|
values.append(error_message)
|
||||||
|
if details is not None:
|
||||||
|
fields.append('details_json = ?')
|
||||||
|
values.append(json.dumps(details))
|
||||||
|
if started:
|
||||||
|
fields.append('started_at = ?')
|
||||||
|
values.append(_utcnow_iso())
|
||||||
|
if completed:
|
||||||
|
fields.append('completed_at = ?')
|
||||||
|
values.append(_utcnow_iso())
|
||||||
|
|
||||||
|
values.append(decode_job_id)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
f'''
|
||||||
|
UPDATE ground_station_decode_jobs
|
||||||
|
SET {", ".join(fields)}
|
||||||
|
WHERE id = ?
|
||||||
|
''',
|
||||||
|
values,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Failed to update decode job %s: %s", decode_job_id, e)
|
||||||
|
|
||||||
|
|
||||||
|
def _utcnow_iso() -> str:
|
||||||
|
return datetime.now(timezone.utc).isoformat()
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
"""Observation profile dataclass and DB CRUD.
|
||||||
|
|
||||||
|
An ObservationProfile describes *how* to capture a particular satellite:
|
||||||
|
frequency, decoder type, gain, bandwidth, minimum elevation, and whether
|
||||||
|
to record raw IQ in SigMF format.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.profile')
|
||||||
|
|
||||||
|
|
||||||
|
VALID_TASK_TYPES = {
|
||||||
|
'telemetry_ax25',
|
||||||
|
'telemetry_gmsk',
|
||||||
|
'telemetry_bpsk',
|
||||||
|
'weather_meteor_lrpt',
|
||||||
|
'record_iq',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def legacy_decoder_to_tasks(decoder_type: str | None, record_iq: bool = False) -> list[str]:
|
||||||
|
decoder = (decoder_type or 'fm').lower()
|
||||||
|
tasks: list[str] = []
|
||||||
|
if decoder in ('fm', 'afsk'):
|
||||||
|
tasks.append('telemetry_ax25')
|
||||||
|
elif decoder == 'gmsk':
|
||||||
|
tasks.append('telemetry_gmsk')
|
||||||
|
elif decoder == 'bpsk':
|
||||||
|
tasks.append('telemetry_bpsk')
|
||||||
|
elif decoder == 'iq_only':
|
||||||
|
tasks.append('record_iq')
|
||||||
|
|
||||||
|
if record_iq and 'record_iq' not in tasks:
|
||||||
|
tasks.append('record_iq')
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
|
||||||
|
def tasks_to_legacy_decoder(tasks: list[str]) -> str:
|
||||||
|
normalized = normalize_tasks(tasks)
|
||||||
|
if 'telemetry_bpsk' in normalized:
|
||||||
|
return 'bpsk'
|
||||||
|
if 'telemetry_gmsk' in normalized:
|
||||||
|
return 'gmsk'
|
||||||
|
if 'telemetry_ax25' in normalized:
|
||||||
|
return 'afsk'
|
||||||
|
return 'iq_only'
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tasks(tasks: list[str] | None) -> list[str]:
|
||||||
|
result: list[str] = []
|
||||||
|
for task in tasks or []:
|
||||||
|
value = str(task or '').strip().lower()
|
||||||
|
if value and value in VALID_TASK_TYPES and value not in result:
|
||||||
|
result.append(value)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ObservationProfile:
|
||||||
|
"""Per-satellite capture configuration."""
|
||||||
|
|
||||||
|
norad_id: int
|
||||||
|
name: str # Human-readable label
|
||||||
|
frequency_mhz: float
|
||||||
|
decoder_type: str # 'fm', 'afsk', 'bpsk', 'gmsk', 'iq_only'
|
||||||
|
gain: float = 40.0
|
||||||
|
bandwidth_hz: int = 200_000
|
||||||
|
min_elevation: float = 10.0
|
||||||
|
enabled: bool = True
|
||||||
|
record_iq: bool = False
|
||||||
|
iq_sample_rate: int = 2_400_000
|
||||||
|
tasks: list[str] = field(default_factory=list)
|
||||||
|
id: int | None = None
|
||||||
|
created_at: str = field(
|
||||||
|
default_factory=lambda: datetime.now(timezone.utc).isoformat()
|
||||||
|
)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
normalized_tasks = self.get_tasks()
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'norad_id': self.norad_id,
|
||||||
|
'name': self.name,
|
||||||
|
'frequency_mhz': self.frequency_mhz,
|
||||||
|
'decoder_type': self.decoder_type,
|
||||||
|
'legacy_decoder_type': self.decoder_type,
|
||||||
|
'gain': self.gain,
|
||||||
|
'bandwidth_hz': self.bandwidth_hz,
|
||||||
|
'min_elevation': self.min_elevation,
|
||||||
|
'enabled': self.enabled,
|
||||||
|
'record_iq': self.record_iq,
|
||||||
|
'iq_sample_rate': self.iq_sample_rate,
|
||||||
|
'tasks': normalized_tasks,
|
||||||
|
'created_at': self.created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_tasks(self) -> list[str]:
|
||||||
|
tasks = normalize_tasks(self.tasks)
|
||||||
|
if not tasks:
|
||||||
|
tasks = legacy_decoder_to_tasks(self.decoder_type, self.record_iq)
|
||||||
|
if self.record_iq and 'record_iq' not in tasks:
|
||||||
|
tasks.append('record_iq')
|
||||||
|
if 'weather_meteor_lrpt' in tasks and 'record_iq' not in tasks:
|
||||||
|
tasks.append('record_iq')
|
||||||
|
return tasks
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_row(cls, row) -> ObservationProfile:
|
||||||
|
tasks = []
|
||||||
|
raw_tasks = row.get('tasks_json', None)
|
||||||
|
if raw_tasks:
|
||||||
|
try:
|
||||||
|
tasks = normalize_tasks(json.loads(raw_tasks))
|
||||||
|
except (TypeError, ValueError, json.JSONDecodeError):
|
||||||
|
tasks = []
|
||||||
|
return cls(
|
||||||
|
id=row['id'],
|
||||||
|
norad_id=row['norad_id'],
|
||||||
|
name=row['name'],
|
||||||
|
frequency_mhz=row['frequency_mhz'],
|
||||||
|
decoder_type=row['decoder_type'],
|
||||||
|
gain=row['gain'],
|
||||||
|
bandwidth_hz=row['bandwidth_hz'],
|
||||||
|
min_elevation=row['min_elevation'],
|
||||||
|
enabled=bool(row['enabled']),
|
||||||
|
record_iq=bool(row['record_iq']),
|
||||||
|
iq_sample_rate=row['iq_sample_rate'],
|
||||||
|
tasks=tasks,
|
||||||
|
created_at=row['created_at'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB CRUD
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def list_profiles() -> list[ObservationProfile]:
|
||||||
|
"""Return all observation profiles from the database."""
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
rows = conn.execute(
|
||||||
|
'SELECT * FROM observation_profiles ORDER BY created_at DESC'
|
||||||
|
).fetchall()
|
||||||
|
return [ObservationProfile.from_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
def get_profile(norad_id: int) -> ObservationProfile | None:
|
||||||
|
"""Return the profile for a NORAD ID, or None if not found."""
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
'SELECT * FROM observation_profiles WHERE norad_id = ?', (norad_id,)
|
||||||
|
).fetchone()
|
||||||
|
return ObservationProfile.from_row(row) if row else None
|
||||||
|
|
||||||
|
|
||||||
|
def save_profile(profile: ObservationProfile) -> ObservationProfile:
|
||||||
|
"""Insert or replace an observation profile. Returns the saved profile."""
|
||||||
|
from utils.database import get_db
|
||||||
|
normalized_tasks = profile.get_tasks()
|
||||||
|
profile.tasks = normalized_tasks
|
||||||
|
profile.record_iq = 'record_iq' in normalized_tasks
|
||||||
|
profile.decoder_type = tasks_to_legacy_decoder(normalized_tasks)
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO observation_profiles
|
||||||
|
(norad_id, name, frequency_mhz, decoder_type, tasks_json, gain,
|
||||||
|
bandwidth_hz, min_elevation, enabled, record_iq,
|
||||||
|
iq_sample_rate, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
ON CONFLICT(norad_id) DO UPDATE SET
|
||||||
|
name=excluded.name,
|
||||||
|
frequency_mhz=excluded.frequency_mhz,
|
||||||
|
decoder_type=excluded.decoder_type,
|
||||||
|
tasks_json=excluded.tasks_json,
|
||||||
|
gain=excluded.gain,
|
||||||
|
bandwidth_hz=excluded.bandwidth_hz,
|
||||||
|
min_elevation=excluded.min_elevation,
|
||||||
|
enabled=excluded.enabled,
|
||||||
|
record_iq=excluded.record_iq,
|
||||||
|
iq_sample_rate=excluded.iq_sample_rate
|
||||||
|
''', (
|
||||||
|
profile.norad_id,
|
||||||
|
profile.name,
|
||||||
|
profile.frequency_mhz,
|
||||||
|
profile.decoder_type,
|
||||||
|
json.dumps(normalized_tasks),
|
||||||
|
profile.gain,
|
||||||
|
profile.bandwidth_hz,
|
||||||
|
profile.min_elevation,
|
||||||
|
int(profile.enabled),
|
||||||
|
int(profile.record_iq),
|
||||||
|
profile.iq_sample_rate,
|
||||||
|
profile.created_at,
|
||||||
|
))
|
||||||
|
return get_profile(profile.norad_id) or profile
|
||||||
|
|
||||||
|
|
||||||
|
def delete_profile(norad_id: int) -> bool:
|
||||||
|
"""Delete a profile by NORAD ID. Returns True if a row was deleted."""
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
'DELETE FROM observation_profiles WHERE norad_id = ?', (norad_id,)
|
||||||
|
)
|
||||||
|
return cur.rowcount > 0
|
||||||
@@ -0,0 +1,932 @@
|
|||||||
|
"""Ground station automated observation scheduler.
|
||||||
|
|
||||||
|
Watches enabled :class:`~utils.ground_station.observation_profile.ObservationProfile`
|
||||||
|
entries, predicts passes for each satellite, fires a capture at AOS, and
|
||||||
|
stops it at LOS.
|
||||||
|
|
||||||
|
During a capture:
|
||||||
|
* An :class:`~utils.ground_station.iq_bus.IQBus` claims the SDR device.
|
||||||
|
* Consumers are attached according to ``profile.decoder_type``:
|
||||||
|
- ``'iq_only'`` → SigMFConsumer only (if ``record_iq`` is True).
|
||||||
|
- ``'fm'`` → FMDemodConsumer (direwolf AX.25) + optional SigMF.
|
||||||
|
- ``'afsk'`` → FMDemodConsumer (direwolf AX.25) + optional SigMF.
|
||||||
|
- ``'gmsk'`` → FMDemodConsumer (multimon-ng) + optional SigMF.
|
||||||
|
- ``'bpsk'`` → GrSatConsumer + optional SigMF.
|
||||||
|
* A WaterfallConsumer is always attached for the live spectrum panel.
|
||||||
|
* A Doppler correction thread retunes the IQ bus every 5 s if shift > threshold.
|
||||||
|
* A rotator control thread points the antenna (if rotctld is available).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import queue
|
||||||
|
import threading
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime, timedelta, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.ground_station.scheduler')
|
||||||
|
|
||||||
|
# Env-configurable Doppler retune threshold (Hz)
|
||||||
|
try:
|
||||||
|
from config import GS_DOPPLER_THRESHOLD_HZ # type: ignore[import]
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
import os
|
||||||
|
GS_DOPPLER_THRESHOLD_HZ = int(os.environ.get('INTERCEPT_GS_DOPPLER_THRESHOLD_HZ', 500))
|
||||||
|
|
||||||
|
DOPPLER_INTERVAL_SECONDS = 5
|
||||||
|
SCHEDULE_REFRESH_MINUTES = 30
|
||||||
|
CAPTURE_BUFFER_SECONDS = 30
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scheduled observation (state machine)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class ScheduledObservation:
|
||||||
|
"""A single scheduled pass for a profile."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
profile_norad_id: int,
|
||||||
|
satellite_name: str,
|
||||||
|
aos_iso: str,
|
||||||
|
los_iso: str,
|
||||||
|
max_el: float,
|
||||||
|
):
|
||||||
|
self.id = str(uuid.uuid4())[:8]
|
||||||
|
self.profile_norad_id = profile_norad_id
|
||||||
|
self.satellite_name = satellite_name
|
||||||
|
self.aos_iso = aos_iso
|
||||||
|
self.los_iso = los_iso
|
||||||
|
self.max_el = max_el
|
||||||
|
self.status: str = 'scheduled'
|
||||||
|
self._start_timer: threading.Timer | None = None
|
||||||
|
self._stop_timer: threading.Timer | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aos_dt(self) -> datetime:
|
||||||
|
return _parse_utc_iso(self.aos_iso)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def los_dt(self) -> datetime:
|
||||||
|
return _parse_utc_iso(self.los_iso)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
'id': self.id,
|
||||||
|
'norad_id': self.profile_norad_id,
|
||||||
|
'satellite': self.satellite_name,
|
||||||
|
'aos': self.aos_iso,
|
||||||
|
'los': self.los_iso,
|
||||||
|
'max_el': self.max_el,
|
||||||
|
'status': self.status,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Scheduler
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class GroundStationScheduler:
|
||||||
|
"""Automated ground station observation scheduler."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._enabled = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._observations: list[ScheduledObservation] = []
|
||||||
|
self._refresh_timer: threading.Timer | None = None
|
||||||
|
self._event_callback: Callable[[dict[str, Any]], None] | None = None
|
||||||
|
|
||||||
|
# Active capture state
|
||||||
|
self._active_obs: ScheduledObservation | None = None
|
||||||
|
self._active_iq_bus = None # IQBus instance
|
||||||
|
self._active_waterfall_consumer = None
|
||||||
|
self._doppler_thread: threading.Thread | None = None
|
||||||
|
self._doppler_stop = threading.Event()
|
||||||
|
self._active_profile = None # ObservationProfile
|
||||||
|
self._active_doppler_tracker = None # DopplerTracker
|
||||||
|
|
||||||
|
# Shared waterfall queue (consumed by /ws/satellite_waterfall)
|
||||||
|
self.waterfall_queue: queue.Queue = queue.Queue(maxsize=120)
|
||||||
|
|
||||||
|
# Observer location
|
||||||
|
self._lat: float = 0.0
|
||||||
|
self._lon: float = 0.0
|
||||||
|
self._device: int = 0
|
||||||
|
self._sdr_type: str = 'rtlsdr'
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public control API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def set_event_callback(
|
||||||
|
self, callback: Callable[[dict[str, Any]], None]
|
||||||
|
) -> None:
|
||||||
|
self._event_callback = callback
|
||||||
|
|
||||||
|
def enable(
|
||||||
|
self,
|
||||||
|
lat: float,
|
||||||
|
lon: float,
|
||||||
|
device: int = 0,
|
||||||
|
sdr_type: str = 'rtlsdr',
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
self._lat = lat
|
||||||
|
self._lon = lon
|
||||||
|
self._device = device
|
||||||
|
self._sdr_type = sdr_type
|
||||||
|
self._enabled = True
|
||||||
|
self._refresh_schedule()
|
||||||
|
return self.get_status()
|
||||||
|
|
||||||
|
def disable(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
self._enabled = False
|
||||||
|
if self._refresh_timer:
|
||||||
|
self._refresh_timer.cancel()
|
||||||
|
self._refresh_timer = None
|
||||||
|
for obs in self._observations:
|
||||||
|
if obs._start_timer:
|
||||||
|
obs._start_timer.cancel()
|
||||||
|
if obs._stop_timer:
|
||||||
|
obs._stop_timer.cancel()
|
||||||
|
self._observations.clear()
|
||||||
|
self._stop_active_capture(reason='scheduler_disabled')
|
||||||
|
return {'status': 'disabled'}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def get_status(self) -> dict[str, Any]:
|
||||||
|
with self._lock:
|
||||||
|
active = self._active_obs.to_dict() if self._active_obs else None
|
||||||
|
return {
|
||||||
|
'enabled': self._enabled,
|
||||||
|
'observer': {'latitude': self._lat, 'longitude': self._lon},
|
||||||
|
'device': self._device,
|
||||||
|
'sdr_type': self._sdr_type,
|
||||||
|
'scheduled_count': sum(
|
||||||
|
1 for o in self._observations if o.status == 'scheduled'
|
||||||
|
),
|
||||||
|
'total_observations': len(self._observations),
|
||||||
|
'active_observation': active,
|
||||||
|
'waterfall_active': self._active_iq_bus is not None
|
||||||
|
and self._active_iq_bus.running,
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_scheduled_observations(self) -> list[dict[str, Any]]:
|
||||||
|
with self._lock:
|
||||||
|
return [o.to_dict() for o in self._observations]
|
||||||
|
|
||||||
|
def trigger_manual(self, norad_id: int) -> tuple[bool, str]:
|
||||||
|
"""Immediately start a manual observation for the given NORAD ID."""
|
||||||
|
from utils.ground_station.observation_profile import get_profile
|
||||||
|
profile = get_profile(norad_id)
|
||||||
|
if not profile:
|
||||||
|
return False, f'No observation profile for NORAD {norad_id}'
|
||||||
|
obs = ScheduledObservation(
|
||||||
|
profile_norad_id=norad_id,
|
||||||
|
satellite_name=profile.name,
|
||||||
|
aos_iso=datetime.now(timezone.utc).isoformat(),
|
||||||
|
los_iso=(datetime.now(timezone.utc) + timedelta(minutes=15)).isoformat(),
|
||||||
|
max_el=90.0,
|
||||||
|
)
|
||||||
|
self._execute_observation(obs)
|
||||||
|
return True, 'Manual observation started'
|
||||||
|
|
||||||
|
def stop_active(self) -> dict[str, Any]:
|
||||||
|
"""Stop the currently running observation."""
|
||||||
|
self._stop_active_capture(reason='manual_stop')
|
||||||
|
return self.get_status()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Schedule management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _refresh_schedule(self) -> None:
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
|
||||||
|
from utils.ground_station.observation_profile import list_profiles
|
||||||
|
|
||||||
|
profiles = [p for p in list_profiles() if p.enabled]
|
||||||
|
if not profiles:
|
||||||
|
logger.info("Ground station scheduler: no enabled profiles")
|
||||||
|
self._arm_refresh_timer()
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
passes_by_profile = self._predict_passes_for_profiles(profiles)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Ground station scheduler: pass prediction failed: {e}")
|
||||||
|
self._arm_refresh_timer()
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
# Cancel existing scheduled timers (keep active/complete)
|
||||||
|
for obs in self._observations:
|
||||||
|
if obs.status == 'scheduled':
|
||||||
|
if obs._start_timer:
|
||||||
|
obs._start_timer.cancel()
|
||||||
|
if obs._stop_timer:
|
||||||
|
obs._stop_timer.cancel()
|
||||||
|
|
||||||
|
history = [o for o in self._observations if o.status in ('complete', 'capturing', 'failed')]
|
||||||
|
self._observations = history
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
buf = CAPTURE_BUFFER_SECONDS
|
||||||
|
|
||||||
|
for obs in passes_by_profile:
|
||||||
|
capture_start = obs.aos_dt - timedelta(seconds=buf)
|
||||||
|
capture_end = obs.los_dt + timedelta(seconds=buf)
|
||||||
|
|
||||||
|
if capture_end <= now:
|
||||||
|
continue
|
||||||
|
if any(h.id == obs.id for h in history):
|
||||||
|
continue
|
||||||
|
|
||||||
|
delay = max(0.0, (capture_start - now).total_seconds())
|
||||||
|
obs._start_timer = threading.Timer(
|
||||||
|
delay, self._execute_observation, args=[obs]
|
||||||
|
)
|
||||||
|
obs._start_timer.daemon = True
|
||||||
|
obs._start_timer.start()
|
||||||
|
self._observations.append(obs)
|
||||||
|
|
||||||
|
scheduled = sum(1 for o in self._observations if o.status == 'scheduled')
|
||||||
|
logger.info(f"Ground station scheduler refreshed: {scheduled} observations scheduled")
|
||||||
|
|
||||||
|
self._arm_refresh_timer()
|
||||||
|
|
||||||
|
def _arm_refresh_timer(self) -> None:
|
||||||
|
if self._refresh_timer:
|
||||||
|
self._refresh_timer.cancel()
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
self._refresh_timer = threading.Timer(
|
||||||
|
SCHEDULE_REFRESH_MINUTES * 60, self._refresh_schedule
|
||||||
|
)
|
||||||
|
self._refresh_timer.daemon = True
|
||||||
|
self._refresh_timer.start()
|
||||||
|
|
||||||
|
def _predict_passes_for_profiles(
|
||||||
|
self, profiles: list
|
||||||
|
) -> list[ScheduledObservation]:
|
||||||
|
"""Predict passes for each profile and return ScheduledObservation list."""
|
||||||
|
from skyfield.api import load, wgs84
|
||||||
|
|
||||||
|
from utils.satellite_predict import predict_passes as _predict_passes
|
||||||
|
|
||||||
|
try:
|
||||||
|
ts = load.timescale(builtin=True)
|
||||||
|
except Exception:
|
||||||
|
from skyfield.api import load as _load
|
||||||
|
ts = _load.timescale(builtin=True)
|
||||||
|
|
||||||
|
observer = wgs84.latlon(self._lat, self._lon)
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
import datetime as _dt
|
||||||
|
t0 = ts.utc(now)
|
||||||
|
t1 = ts.utc(now + _dt.timedelta(hours=24))
|
||||||
|
|
||||||
|
observations: list[ScheduledObservation] = []
|
||||||
|
|
||||||
|
for profile in profiles:
|
||||||
|
tle = _find_tle_by_norad(profile.norad_id)
|
||||||
|
if tle is None:
|
||||||
|
logger.warning(
|
||||||
|
f"No TLE for NORAD {profile.norad_id} ({profile.name}) — skipping"
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
passes = _predict_passes(
|
||||||
|
tle_data=tle,
|
||||||
|
observer=observer,
|
||||||
|
ts=ts,
|
||||||
|
t0=t0,
|
||||||
|
t1=t1,
|
||||||
|
min_el=profile.min_elevation,
|
||||||
|
include_trajectory=False,
|
||||||
|
include_ground_track=False,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Pass prediction failed for {profile.name}: {e}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
for p in passes:
|
||||||
|
obs = ScheduledObservation(
|
||||||
|
profile_norad_id=profile.norad_id,
|
||||||
|
satellite_name=profile.name,
|
||||||
|
aos_iso=p.get('startTimeISO', ''),
|
||||||
|
los_iso=p.get('endTimeISO', ''),
|
||||||
|
max_el=float(p.get('maxEl', 0.0)),
|
||||||
|
)
|
||||||
|
observations.append(obs)
|
||||||
|
|
||||||
|
return observations
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Capture execution
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _execute_observation(self, obs: ScheduledObservation) -> None:
|
||||||
|
"""Called at AOS (+ buffer) to start IQ capture."""
|
||||||
|
if not self._enabled:
|
||||||
|
return
|
||||||
|
if obs.status == 'scheduled':
|
||||||
|
obs.status = 'capturing'
|
||||||
|
else:
|
||||||
|
return # already cancelled / complete
|
||||||
|
|
||||||
|
from utils.ground_station.observation_profile import get_profile
|
||||||
|
profile = get_profile(obs.profile_norad_id)
|
||||||
|
if not profile or not profile.enabled:
|
||||||
|
obs.status = 'failed'
|
||||||
|
return
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
try:
|
||||||
|
import app as _app
|
||||||
|
err = _app.claim_sdr_device(self._device, 'ground_station_iq_bus', self._sdr_type)
|
||||||
|
if err:
|
||||||
|
logger.warning(f"Ground station: SDR busy — skipping {obs.satellite_name}: {err}")
|
||||||
|
obs.status = 'failed'
|
||||||
|
self._emit_event({'type': 'observation_skipped', 'observation': obs.to_dict(), 'reason': 'device_busy'})
|
||||||
|
return
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create DB record
|
||||||
|
obs_db_id = _insert_observation_record(obs, profile)
|
||||||
|
|
||||||
|
# Build IQ bus
|
||||||
|
from utils.ground_station.iq_bus import IQBus
|
||||||
|
bus = IQBus(
|
||||||
|
center_mhz=profile.frequency_mhz,
|
||||||
|
sample_rate=profile.iq_sample_rate,
|
||||||
|
gain=profile.gain,
|
||||||
|
device_index=self._device,
|
||||||
|
sdr_type=self._sdr_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Attach waterfall consumer (always)
|
||||||
|
from utils.ground_station.consumers.waterfall import WaterfallConsumer
|
||||||
|
wf_consumer = WaterfallConsumer(output_queue=self.waterfall_queue)
|
||||||
|
bus.add_consumer(wf_consumer)
|
||||||
|
|
||||||
|
# Attach decoder consumers
|
||||||
|
self._attach_decoder_consumers(bus, profile, obs_db_id, obs)
|
||||||
|
|
||||||
|
# Attach SigMF consumer when explicitly requested or required by tasks
|
||||||
|
if _profile_requires_iq_recording(profile):
|
||||||
|
self._attach_sigmf_consumer(bus, profile, obs_db_id)
|
||||||
|
|
||||||
|
# Start bus
|
||||||
|
ok, err_msg = bus.start()
|
||||||
|
if not ok:
|
||||||
|
logger.error(f"Ground station: failed to start IQBus for {obs.satellite_name}: {err_msg}")
|
||||||
|
obs.status = 'failed'
|
||||||
|
try:
|
||||||
|
import app as _app
|
||||||
|
_app.release_sdr_device(self._device, self._sdr_type)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
self._emit_event({'type': 'observation_failed', 'observation': obs.to_dict(), 'reason': err_msg})
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._active_obs = obs
|
||||||
|
self._active_iq_bus = bus
|
||||||
|
self._active_waterfall_consumer = wf_consumer
|
||||||
|
self._active_profile = profile
|
||||||
|
|
||||||
|
# Emit iq_bus_started SSE event (used by Phase 5 waterfall)
|
||||||
|
span_mhz = profile.iq_sample_rate / 1e6
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'iq_bus_started',
|
||||||
|
'observation': obs.to_dict(),
|
||||||
|
'center_mhz': profile.frequency_mhz,
|
||||||
|
'span_mhz': span_mhz,
|
||||||
|
})
|
||||||
|
self._emit_event({'type': 'observation_started', 'observation': obs.to_dict()})
|
||||||
|
logger.info(f"Ground station: observation started for {obs.satellite_name} (NORAD {obs.profile_norad_id})")
|
||||||
|
|
||||||
|
# Start Doppler correction thread
|
||||||
|
self._start_doppler_thread(profile, obs)
|
||||||
|
|
||||||
|
# Schedule stop at LOS + buffer
|
||||||
|
now = datetime.now(timezone.utc)
|
||||||
|
stop_delay = (obs.los_dt + timedelta(seconds=CAPTURE_BUFFER_SECONDS) - now).total_seconds()
|
||||||
|
if stop_delay > 0:
|
||||||
|
obs._stop_timer = threading.Timer(
|
||||||
|
stop_delay, self._stop_active_capture, kwargs={'reason': 'los'}
|
||||||
|
)
|
||||||
|
obs._stop_timer.daemon = True
|
||||||
|
obs._stop_timer.start()
|
||||||
|
else:
|
||||||
|
self._stop_active_capture(reason='los_immediate')
|
||||||
|
|
||||||
|
def _stop_active_capture(self, *, reason: str = 'manual') -> None:
|
||||||
|
"""Stop the currently active capture and release the SDR device."""
|
||||||
|
with self._lock:
|
||||||
|
bus = self._active_iq_bus
|
||||||
|
obs = self._active_obs
|
||||||
|
self._active_iq_bus = None
|
||||||
|
self._active_obs = None
|
||||||
|
self._active_waterfall_consumer = None
|
||||||
|
self._active_profile = None
|
||||||
|
self._active_doppler_tracker = None
|
||||||
|
|
||||||
|
self._doppler_stop.set()
|
||||||
|
|
||||||
|
if bus and bus.running:
|
||||||
|
bus.stop()
|
||||||
|
|
||||||
|
if obs:
|
||||||
|
obs.status = 'complete'
|
||||||
|
_update_observation_status(obs, 'complete')
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'observation_complete',
|
||||||
|
'observation': obs.to_dict(),
|
||||||
|
'reason': reason,
|
||||||
|
})
|
||||||
|
self._emit_event({'type': 'iq_bus_stopped', 'observation': obs.to_dict()})
|
||||||
|
|
||||||
|
try:
|
||||||
|
import app as _app
|
||||||
|
_app.release_sdr_device(self._device, self._sdr_type)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.info(f"Ground station: observation stopped ({reason})")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Consumer attachment helpers
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _attach_decoder_consumers(self, bus, profile, obs_db_id: int | None, obs) -> None:
|
||||||
|
"""Attach consumers for all telemetry tasks on the profile."""
|
||||||
|
import shutil
|
||||||
|
|
||||||
|
tasks = _get_profile_tasks(profile)
|
||||||
|
|
||||||
|
if 'telemetry_ax25' in tasks:
|
||||||
|
if shutil.which('direwolf'):
|
||||||
|
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||||
|
consumer = FMDemodConsumer(
|
||||||
|
decoder_cmd=[
|
||||||
|
'direwolf', '-r', '48000', '-n', '1', '-b', '16', '-',
|
||||||
|
],
|
||||||
|
modulation='fm',
|
||||||
|
on_decoded=lambda line: self._on_packet_decoded(
|
||||||
|
line, obs_db_id, obs, source='direwolf'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bus.add_consumer(consumer)
|
||||||
|
logger.info("Ground station: attached direwolf AX.25 decoder")
|
||||||
|
else:
|
||||||
|
logger.warning("direwolf not found — AX.25 decoding disabled")
|
||||||
|
|
||||||
|
if 'telemetry_gmsk' in tasks:
|
||||||
|
if shutil.which('multimon-ng'):
|
||||||
|
from utils.ground_station.consumers.fm_demod import FMDemodConsumer
|
||||||
|
consumer = FMDemodConsumer(
|
||||||
|
decoder_cmd=['multimon-ng', '-t', 'raw', '-a', 'GMSK', '-'],
|
||||||
|
modulation='fm',
|
||||||
|
on_decoded=lambda line: self._on_packet_decoded(
|
||||||
|
line, obs_db_id, obs, source='multimon-ng'
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bus.add_consumer(consumer)
|
||||||
|
logger.info("Ground station: attached multimon-ng GMSK decoder")
|
||||||
|
else:
|
||||||
|
logger.warning("multimon-ng not found — GMSK decoding disabled")
|
||||||
|
|
||||||
|
if 'telemetry_bpsk' in tasks:
|
||||||
|
from utils.ground_station.consumers.gr_satellites import GrSatConsumer
|
||||||
|
consumer = GrSatConsumer(
|
||||||
|
satellite_name=profile.name,
|
||||||
|
on_decoded=lambda pkt: self._on_packet_decoded(
|
||||||
|
pkt,
|
||||||
|
obs_db_id,
|
||||||
|
obs,
|
||||||
|
source='gr_satellites',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
bus.add_consumer(consumer)
|
||||||
|
|
||||||
|
def _attach_sigmf_consumer(self, bus, profile, obs_db_id: int | None) -> None:
|
||||||
|
"""Attach a SigMFConsumer for raw IQ recording."""
|
||||||
|
from utils.ground_station.consumers.sigmf_writer import SigMFConsumer
|
||||||
|
from utils.sigmf import SigMFMetadata
|
||||||
|
|
||||||
|
meta = SigMFMetadata(
|
||||||
|
sample_rate=profile.iq_sample_rate,
|
||||||
|
center_frequency_hz=profile.frequency_mhz * 1e6,
|
||||||
|
satellite_name=profile.name,
|
||||||
|
norad_id=profile.norad_id,
|
||||||
|
latitude=self._lat,
|
||||||
|
longitude=self._lon,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _on_recording_complete(meta_path, data_path):
|
||||||
|
_insert_recording_record(obs_db_id, meta_path, data_path, profile)
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'recording_complete',
|
||||||
|
'norad_id': profile.norad_id,
|
||||||
|
'data_path': str(data_path),
|
||||||
|
'meta_path': str(meta_path),
|
||||||
|
})
|
||||||
|
if 'weather_meteor_lrpt' in _get_profile_tasks(profile):
|
||||||
|
try:
|
||||||
|
from utils.ground_station.meteor_backend import launch_meteor_decode
|
||||||
|
launch_meteor_decode(
|
||||||
|
obs_db_id=obs_db_id,
|
||||||
|
norad_id=profile.norad_id,
|
||||||
|
satellite_name=profile.name,
|
||||||
|
sample_rate=profile.iq_sample_rate,
|
||||||
|
data_path=Path(data_path),
|
||||||
|
emit_event=self._emit_event,
|
||||||
|
register_output=_insert_output_record,
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to launch Meteor decode backend: {e}")
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'weather_decode_failed',
|
||||||
|
'norad_id': profile.norad_id,
|
||||||
|
'satellite': profile.name,
|
||||||
|
'backend': 'meteor_lrpt',
|
||||||
|
'message': str(e),
|
||||||
|
})
|
||||||
|
|
||||||
|
consumer = SigMFConsumer(metadata=meta, on_complete=_on_recording_complete)
|
||||||
|
bus.add_consumer(consumer)
|
||||||
|
logger.info(f"Ground station: SigMF recording enabled for {profile.name}")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Doppler correction (Phase 2)
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _start_doppler_thread(self, profile, obs: ScheduledObservation) -> None:
|
||||||
|
"""Start the Doppler tracking/retune thread for an active capture."""
|
||||||
|
from utils.doppler import DopplerTracker
|
||||||
|
|
||||||
|
tle = _find_tle_by_norad(profile.norad_id)
|
||||||
|
if tle is None:
|
||||||
|
logger.info(f"Ground station: no TLE for {profile.name} — Doppler disabled")
|
||||||
|
return
|
||||||
|
|
||||||
|
tracker = DopplerTracker(satellite_name=profile.name, tle_data=tle)
|
||||||
|
if not tracker.configure(self._lat, self._lon):
|
||||||
|
logger.info(f"Ground station: Doppler tracking not available for {profile.name}")
|
||||||
|
return
|
||||||
|
|
||||||
|
with self._lock:
|
||||||
|
self._active_doppler_tracker = tracker
|
||||||
|
|
||||||
|
self._doppler_stop.clear()
|
||||||
|
t = threading.Thread(
|
||||||
|
target=self._doppler_loop,
|
||||||
|
args=[profile, tracker],
|
||||||
|
daemon=True,
|
||||||
|
name='gs-doppler',
|
||||||
|
)
|
||||||
|
t.start()
|
||||||
|
self._doppler_thread = t
|
||||||
|
logger.info(f"Ground station: Doppler tracking started for {profile.name}")
|
||||||
|
|
||||||
|
def _doppler_loop(self, profile, tracker) -> None:
|
||||||
|
"""Periodically compute Doppler shift and retune if necessary."""
|
||||||
|
while not self._doppler_stop.wait(DOPPLER_INTERVAL_SECONDS):
|
||||||
|
with self._lock:
|
||||||
|
bus = self._active_iq_bus
|
||||||
|
|
||||||
|
if bus is None or not bus.running:
|
||||||
|
break
|
||||||
|
|
||||||
|
info = tracker.calculate(profile.frequency_mhz)
|
||||||
|
if info is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Retune if shift exceeds threshold
|
||||||
|
if abs(info.shift_hz) >= GS_DOPPLER_THRESHOLD_HZ:
|
||||||
|
corrected_mhz = info.frequency_hz / 1_000_000
|
||||||
|
logger.info(
|
||||||
|
f"Ground station: Doppler retune {info.shift_hz:+.1f} Hz → "
|
||||||
|
f"{corrected_mhz:.6f} MHz (el={info.elevation:.1f}°)"
|
||||||
|
)
|
||||||
|
bus.retune(corrected_mhz)
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'doppler_update',
|
||||||
|
'norad_id': profile.norad_id,
|
||||||
|
**info.to_dict(),
|
||||||
|
})
|
||||||
|
|
||||||
|
# Rotator control (Phase 6)
|
||||||
|
try:
|
||||||
|
from utils.rotator import get_rotator
|
||||||
|
rotator = get_rotator()
|
||||||
|
if rotator.enabled:
|
||||||
|
rotator.point_to(info.azimuth, info.elevation)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
logger.debug("Ground station: Doppler loop exited")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Packet / event callbacks
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _on_packet_decoded(
|
||||||
|
self,
|
||||||
|
payload,
|
||||||
|
obs_db_id: int | None,
|
||||||
|
obs: ScheduledObservation,
|
||||||
|
*,
|
||||||
|
source: str = 'decoder',
|
||||||
|
) -> None:
|
||||||
|
"""Handle a decoded packet payload from a decoder consumer."""
|
||||||
|
if payload is None or payload == '':
|
||||||
|
return
|
||||||
|
|
||||||
|
packet_event = _build_packet_event(payload, source)
|
||||||
|
_insert_event_record(obs_db_id, 'packet', json.dumps(packet_event))
|
||||||
|
self._emit_event({
|
||||||
|
'type': 'packet_decoded',
|
||||||
|
'norad_id': obs.profile_norad_id,
|
||||||
|
'satellite': obs.satellite_name,
|
||||||
|
**packet_event,
|
||||||
|
})
|
||||||
|
|
||||||
|
def _emit_event(self, event: dict[str, Any]) -> None:
|
||||||
|
if self._event_callback:
|
||||||
|
try:
|
||||||
|
self._event_callback(event)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Event callback error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# DB helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_observation_record(obs: ScheduledObservation, profile) -> int | None:
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute('''
|
||||||
|
INSERT INTO ground_station_observations
|
||||||
|
(profile_id, norad_id, satellite, aos_time, los_time, status, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
profile.id,
|
||||||
|
obs.profile_norad_id,
|
||||||
|
obs.satellite_name,
|
||||||
|
obs.aos_iso,
|
||||||
|
obs.los_iso,
|
||||||
|
'capturing',
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
))
|
||||||
|
return cur.lastrowid
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to insert observation record: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _update_observation_status(obs: ScheduledObservation, status: str) -> None:
|
||||||
|
try:
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute(
|
||||||
|
'UPDATE ground_station_observations SET status=? WHERE norad_id=? AND status=?',
|
||||||
|
(status, obs.profile_norad_id, 'capturing'),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to update observation status: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_event_record(obs_db_id: int | None, event_type: str, payload: str) -> None:
|
||||||
|
if obs_db_id is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO ground_station_events (observation_id, event_type, payload_json, timestamp)
|
||||||
|
VALUES (?, ?, ?, ?)
|
||||||
|
''', (obs_db_id, event_type, payload, datetime.now(timezone.utc).isoformat()))
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Failed to insert event record: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _get_profile_tasks(profile) -> list[str]:
|
||||||
|
get_tasks = getattr(profile, 'get_tasks', None)
|
||||||
|
if callable(get_tasks):
|
||||||
|
return get_tasks()
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def _profile_requires_iq_recording(profile) -> bool:
|
||||||
|
tasks = _get_profile_tasks(profile)
|
||||||
|
return bool(getattr(profile, 'record_iq', False) or 'record_iq' in tasks or 'weather_meteor_lrpt' in tasks)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_packet_event(payload, source: str) -> dict[str, Any]:
|
||||||
|
event: dict[str, Any] = {
|
||||||
|
'source': source,
|
||||||
|
'data': payload if isinstance(payload, str) else json.dumps(payload),
|
||||||
|
'parsed': None,
|
||||||
|
}
|
||||||
|
|
||||||
|
if isinstance(payload, dict):
|
||||||
|
event['parsed'] = payload
|
||||||
|
event['protocol'] = payload.get('protocol') or payload.get('type') or source
|
||||||
|
return event
|
||||||
|
|
||||||
|
text = str(payload).strip()
|
||||||
|
event['data'] = text
|
||||||
|
|
||||||
|
parsed = None
|
||||||
|
if source == 'gr_satellites':
|
||||||
|
try:
|
||||||
|
candidate = json.loads(text)
|
||||||
|
if isinstance(candidate, dict):
|
||||||
|
parsed = candidate
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
if parsed is None:
|
||||||
|
try:
|
||||||
|
import base64
|
||||||
|
|
||||||
|
from utils.satellite_telemetry import auto_parse
|
||||||
|
|
||||||
|
for token in text.replace(',', ' ').split():
|
||||||
|
cleaned = token.strip()
|
||||||
|
if not cleaned or len(cleaned) < 8:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
raw = base64.b64decode(cleaned, validate=True)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
maybe = auto_parse(raw)
|
||||||
|
if maybe:
|
||||||
|
parsed = maybe
|
||||||
|
break
|
||||||
|
except Exception:
|
||||||
|
parsed = None
|
||||||
|
|
||||||
|
event['parsed'] = parsed
|
||||||
|
if isinstance(parsed, dict):
|
||||||
|
event['protocol'] = parsed.get('protocol') or source
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_recording_record(obs_db_id: int | None, meta_path: Path, data_path: Path, profile) -> None:
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
size = data_path.stat().st_size if data_path.exists() else 0
|
||||||
|
with get_db() as conn:
|
||||||
|
conn.execute('''
|
||||||
|
INSERT INTO sigmf_recordings
|
||||||
|
(observation_id, sigmf_data_path, sigmf_meta_path, size_bytes,
|
||||||
|
sample_rate, center_freq_hz, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''', (
|
||||||
|
obs_db_id,
|
||||||
|
str(data_path),
|
||||||
|
str(meta_path),
|
||||||
|
size,
|
||||||
|
profile.iq_sample_rate,
|
||||||
|
int(profile.frequency_mhz * 1e6),
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
))
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to insert recording record: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _insert_output_record(
|
||||||
|
*,
|
||||||
|
observation_id: int | None,
|
||||||
|
norad_id: int | None,
|
||||||
|
output_type: str,
|
||||||
|
backend: str,
|
||||||
|
file_path: Path,
|
||||||
|
preview_path: Path | None = None,
|
||||||
|
metadata: dict[str, Any] | None = None,
|
||||||
|
) -> int | None:
|
||||||
|
try:
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from utils.database import get_db
|
||||||
|
|
||||||
|
with get_db() as conn:
|
||||||
|
cur = conn.execute(
|
||||||
|
'''
|
||||||
|
INSERT INTO ground_station_outputs
|
||||||
|
(observation_id, norad_id, output_type, backend, file_path,
|
||||||
|
preview_path, metadata_json, created_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
''',
|
||||||
|
(
|
||||||
|
observation_id,
|
||||||
|
norad_id,
|
||||||
|
output_type,
|
||||||
|
backend,
|
||||||
|
str(file_path),
|
||||||
|
str(preview_path) if preview_path else None,
|
||||||
|
json.dumps(metadata or {}),
|
||||||
|
datetime.now(timezone.utc).isoformat(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return cur.lastrowid
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to insert output record: {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TLE lookup helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _find_tle_by_norad(norad_id: int) -> tuple[str, str, str] | None:
|
||||||
|
"""Search TLE cache for a given NORAD catalog number."""
|
||||||
|
# Try live cache first
|
||||||
|
sources = []
|
||||||
|
try:
|
||||||
|
from routes.satellite import _tle_cache # type: ignore[import]
|
||||||
|
if _tle_cache:
|
||||||
|
sources.append(_tle_cache)
|
||||||
|
except (ImportError, AttributeError):
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
from data.satellites import TLE_SATELLITES
|
||||||
|
sources.append(TLE_SATELLITES)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
target_id = str(norad_id).zfill(5)
|
||||||
|
|
||||||
|
for source in sources:
|
||||||
|
for _key, tle in source.items():
|
||||||
|
if not isinstance(tle, (tuple, list)) or len(tle) < 3:
|
||||||
|
continue
|
||||||
|
line1 = str(tle[1])
|
||||||
|
# NORAD catalog number occupies chars 2-6 (0-indexed) of TLE line 1
|
||||||
|
if len(line1) > 7:
|
||||||
|
catalog_str = line1[2:7].strip()
|
||||||
|
if catalog_str == target_id:
|
||||||
|
return (str(tle[0]), str(tle[1]), str(tle[2]))
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Timestamp parser (mirrors weather_sat_scheduler)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_utc_iso(value: str) -> datetime:
|
||||||
|
text = str(value).strip().replace('+00:00Z', 'Z')
|
||||||
|
if text.endswith('Z'):
|
||||||
|
text = text[:-1] + '+00:00'
|
||||||
|
dt = datetime.fromisoformat(text)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
dt = dt.replace(tzinfo=timezone.utc)
|
||||||
|
else:
|
||||||
|
dt = dt.astimezone(timezone.utc)
|
||||||
|
return dt
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_scheduler: GroundStationScheduler | None = None
|
||||||
|
_scheduler_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_ground_station_scheduler() -> GroundStationScheduler:
|
||||||
|
"""Get or create the global ground station scheduler."""
|
||||||
|
global _scheduler
|
||||||
|
if _scheduler is None:
|
||||||
|
with _scheduler_lock:
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = GroundStationScheduler()
|
||||||
|
return _scheduler
|
||||||
@@ -0,0 +1,194 @@
|
|||||||
|
"""Hamlib rotctld TCP client for antenna rotator control.
|
||||||
|
|
||||||
|
Communicates with a running ``rotctld`` daemon over TCP using the simple
|
||||||
|
line-based Hamlib protocol::
|
||||||
|
|
||||||
|
Client → ``P <azimuth> <elevation>\\n``
|
||||||
|
Server → ``RPRT 0\\n`` (success)
|
||||||
|
|
||||||
|
If ``rotctld`` is not reachable the controller silently operates in a
|
||||||
|
disabled state — the rest of the system functions normally.
|
||||||
|
|
||||||
|
Usage::
|
||||||
|
|
||||||
|
rotator = get_rotator()
|
||||||
|
if rotator.connect('127.0.0.1', 4533):
|
||||||
|
rotator.point_to(az=180.0, el=30.0)
|
||||||
|
rotator.park()
|
||||||
|
rotator.disconnect()
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import socket
|
||||||
|
import threading
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.rotator')
|
||||||
|
|
||||||
|
DEFAULT_HOST = '127.0.0.1'
|
||||||
|
DEFAULT_PORT = 4533
|
||||||
|
DEFAULT_TIMEOUT = 2.0 # seconds
|
||||||
|
|
||||||
|
|
||||||
|
class RotatorController:
|
||||||
|
"""Thin wrapper around the rotctld TCP protocol."""
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
self._sock: socket.socket | None = None
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._host = DEFAULT_HOST
|
||||||
|
self._port = DEFAULT_PORT
|
||||||
|
self._enabled = False
|
||||||
|
self._current_az: float = 0.0
|
||||||
|
self._current_el: float = 0.0
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Connection management
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def connect(self, host: str = DEFAULT_HOST, port: int = DEFAULT_PORT) -> bool:
|
||||||
|
"""Connect to rotctld. Returns True on success."""
|
||||||
|
with self._lock:
|
||||||
|
self._host = host
|
||||||
|
self._port = port
|
||||||
|
try:
|
||||||
|
s = socket.create_connection((host, port), timeout=DEFAULT_TIMEOUT)
|
||||||
|
s.settimeout(DEFAULT_TIMEOUT)
|
||||||
|
self._sock = s
|
||||||
|
self._enabled = True
|
||||||
|
logger.info(f"Rotator connected to rotctld at {host}:{port}")
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.warning(f"Could not connect to rotctld at {host}:{port}: {e}")
|
||||||
|
self._sock = None
|
||||||
|
self._enabled = False
|
||||||
|
return False
|
||||||
|
|
||||||
|
def disconnect(self) -> None:
|
||||||
|
"""Close the TCP connection."""
|
||||||
|
with self._lock:
|
||||||
|
if self._sock:
|
||||||
|
try:
|
||||||
|
self._sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._sock = None
|
||||||
|
self._enabled = False
|
||||||
|
logger.info("Rotator disconnected")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Commands
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def point_to(self, az: float, el: float) -> bool:
|
||||||
|
"""Send a ``P`` (set position) command.
|
||||||
|
|
||||||
|
Azimuth and elevation are clamped to valid ranges before sending.
|
||||||
|
|
||||||
|
Returns True if the command was acknowledged.
|
||||||
|
"""
|
||||||
|
az = max(0.0, min(360.0, float(az)))
|
||||||
|
el = max(0.0, min(90.0, float(el)))
|
||||||
|
|
||||||
|
ok = self._send_command(f'P {az:.1f} {el:.1f}')
|
||||||
|
if ok:
|
||||||
|
self._current_az = az
|
||||||
|
self._current_el = el
|
||||||
|
return ok
|
||||||
|
|
||||||
|
def park(self) -> bool:
|
||||||
|
"""Send rotator to park position (0° az, 0° el)."""
|
||||||
|
return self.point_to(0.0, 0.0)
|
||||||
|
|
||||||
|
def get_position(self) -> tuple[float, float] | None:
|
||||||
|
"""Query current position. Returns (az, el) or None on failure."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._enabled or self._sock is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
self._sock.sendall(b'p\n')
|
||||||
|
resp = self._recv_line()
|
||||||
|
if resp and 'RPRT' not in resp:
|
||||||
|
parts = resp.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
return float(parts[0]), float(parts[1])
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Rotator get_position failed: {e}")
|
||||||
|
self._enabled = False
|
||||||
|
self._sock = None
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Status
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@property
|
||||||
|
def enabled(self) -> bool:
|
||||||
|
return self._enabled
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
return {
|
||||||
|
'enabled': self._enabled,
|
||||||
|
'host': self._host,
|
||||||
|
'port': self._port,
|
||||||
|
'current_az': self._current_az,
|
||||||
|
'current_el': self._current_el,
|
||||||
|
}
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Internal
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _send_command(self, cmd: str) -> bool:
|
||||||
|
with self._lock:
|
||||||
|
if not self._enabled or self._sock is None:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
self._sock.sendall((cmd + '\n').encode())
|
||||||
|
resp = self._recv_line()
|
||||||
|
if resp and 'RPRT 0' in resp:
|
||||||
|
return True
|
||||||
|
logger.warning(f"Rotator unexpected response to '{cmd}': {resp!r}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Rotator command '{cmd}' failed: {e}")
|
||||||
|
self._enabled = False
|
||||||
|
try:
|
||||||
|
self._sock.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
self._sock = None
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _recv_line(self, max_bytes: int = 256) -> str:
|
||||||
|
"""Read until newline (already holding _lock)."""
|
||||||
|
buf = b''
|
||||||
|
assert self._sock is not None
|
||||||
|
while len(buf) < max_bytes:
|
||||||
|
c = self._sock.recv(1)
|
||||||
|
if not c:
|
||||||
|
break
|
||||||
|
buf += c
|
||||||
|
if c == b'\n':
|
||||||
|
break
|
||||||
|
return buf.decode('ascii', errors='replace').strip()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Singleton
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_rotator: RotatorController | None = None
|
||||||
|
_rotator_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
|
def get_rotator() -> RotatorController:
|
||||||
|
"""Get or create the global rotator controller instance."""
|
||||||
|
global _rotator
|
||||||
|
if _rotator is None:
|
||||||
|
with _rotator_lock:
|
||||||
|
if _rotator is None:
|
||||||
|
_rotator = RotatorController()
|
||||||
|
return _rotator
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
"""Shared satellite pass prediction utility.
|
||||||
|
|
||||||
|
Used by both the satellite tracking dashboard and the weather satellite scheduler.
|
||||||
|
Uses Skyfield's find_events() for accurate AOS/TCA/LOS event detection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.satellite_predict')
|
||||||
|
|
||||||
|
|
||||||
|
def predict_passes(
|
||||||
|
tle_data: tuple,
|
||||||
|
observer, # skyfield wgs84.latlon object
|
||||||
|
ts, # skyfield timescale
|
||||||
|
t0, # skyfield Time start
|
||||||
|
t1, # skyfield Time end
|
||||||
|
min_el: float = 10.0,
|
||||||
|
include_trajectory: bool = True,
|
||||||
|
include_ground_track: bool = True,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
"""Predict satellite passes over an observer location.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
tle_data: (name, line1, line2) tuple
|
||||||
|
observer: Skyfield wgs84.latlon observer
|
||||||
|
ts: Skyfield timescale
|
||||||
|
t0: Start time (Skyfield Time)
|
||||||
|
t1: End time (Skyfield Time)
|
||||||
|
min_el: Minimum peak elevation in degrees to include pass
|
||||||
|
include_trajectory: Include 30-point az/el trajectory for polar plot
|
||||||
|
include_ground_track: Include 60-point lat/lon ground track for map
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of pass dicts sorted by AOS time. Each dict contains:
|
||||||
|
aosTime, aosAz, aosEl,
|
||||||
|
tcaTime, tcaEl, tcaAz,
|
||||||
|
losTime, losAz, losEl,
|
||||||
|
duration (minutes, float),
|
||||||
|
startTime (human-readable UTC),
|
||||||
|
startTimeISO (ISO string),
|
||||||
|
endTimeISO (ISO string),
|
||||||
|
maxEl (float, same as tcaEl),
|
||||||
|
trajectory (list of {az, el} if include_trajectory),
|
||||||
|
groundTrack (list of {lat, lon} if include_ground_track)
|
||||||
|
"""
|
||||||
|
from skyfield.api import EarthSatellite, wgs84
|
||||||
|
|
||||||
|
# Filter decaying satellites by checking ndot from TLE line1 chars 33-43
|
||||||
|
try:
|
||||||
|
line1 = tle_data[1]
|
||||||
|
ndot_str = line1[33:43].strip()
|
||||||
|
ndot = float(ndot_str)
|
||||||
|
if abs(ndot) > 0.01:
|
||||||
|
logger.debug(
|
||||||
|
'Skipping decaying satellite %s (ndot=%s)', tle_data[0], ndot
|
||||||
|
)
|
||||||
|
return []
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
# Don't skip on parse error
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Create EarthSatellite object
|
||||||
|
try:
|
||||||
|
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug('Failed to create EarthSatellite for %s: %s', tle_data[0], exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Find events using Skyfield's native find_events()
|
||||||
|
# Event types: 0=AOS, 1=TCA, 2=LOS
|
||||||
|
try:
|
||||||
|
times, events = satellite.find_events(
|
||||||
|
observer, t0, t1, altitude_degrees=min_el
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug('find_events failed for %s: %s', tle_data[0], exc)
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Group events into AOS->TCA->LOS triplets
|
||||||
|
passes = []
|
||||||
|
i = 0
|
||||||
|
total = len(events)
|
||||||
|
|
||||||
|
# Skip any leading non-AOS events (satellite already above horizon at t0)
|
||||||
|
while i < total and events[i] != 0:
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
while i < total:
|
||||||
|
# Expect AOS (0)
|
||||||
|
if events[i] != 0:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
aos_time = times[i]
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Collect TCA and LOS, watching for premature next AOS
|
||||||
|
tca_time = None
|
||||||
|
los_time = None
|
||||||
|
|
||||||
|
while i < total and events[i] != 0:
|
||||||
|
if events[i] == 1:
|
||||||
|
tca_time = times[i]
|
||||||
|
elif events[i] == 2:
|
||||||
|
los_time = times[i]
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Must have both AOS and LOS to form a valid pass
|
||||||
|
if los_time is None:
|
||||||
|
# Incomplete pass — skip
|
||||||
|
continue
|
||||||
|
|
||||||
|
# If TCA is missing, derive from midpoint between AOS and LOS
|
||||||
|
if tca_time is None:
|
||||||
|
aos_tt = aos_time.tt
|
||||||
|
los_tt = los_time.tt
|
||||||
|
tca_time = ts.tt_jd((aos_tt + los_tt) / 2.0)
|
||||||
|
|
||||||
|
# Compute topocentric positions at AOS, TCA, LOS
|
||||||
|
try:
|
||||||
|
aos_topo = (satellite - observer).at(aos_time)
|
||||||
|
tca_topo = (satellite - observer).at(tca_time)
|
||||||
|
los_topo = (satellite - observer).at(los_time)
|
||||||
|
|
||||||
|
aos_alt, aos_az, _ = aos_topo.altaz()
|
||||||
|
tca_alt, tca_az, _ = tca_topo.altaz()
|
||||||
|
los_alt, los_az, _ = los_topo.altaz()
|
||||||
|
|
||||||
|
aos_dt = aos_time.utc_datetime()
|
||||||
|
tca_dt = tca_time.utc_datetime()
|
||||||
|
los_dt = los_time.utc_datetime()
|
||||||
|
|
||||||
|
duration = (los_dt - aos_dt).total_seconds() / 60.0
|
||||||
|
|
||||||
|
pass_dict: dict[str, Any] = {
|
||||||
|
'aosTime': aos_dt.isoformat(),
|
||||||
|
'aosAz': round(float(aos_az.degrees), 1),
|
||||||
|
'aosEl': round(float(aos_alt.degrees), 1),
|
||||||
|
'tcaTime': tca_dt.isoformat(),
|
||||||
|
'tcaAz': round(float(tca_az.degrees), 1),
|
||||||
|
'tcaEl': round(float(tca_alt.degrees), 1),
|
||||||
|
'losTime': los_dt.isoformat(),
|
||||||
|
'losAz': round(float(los_az.degrees), 1),
|
||||||
|
'losEl': round(float(los_alt.degrees), 1),
|
||||||
|
'duration': round(duration, 1),
|
||||||
|
# Backwards-compatible fields
|
||||||
|
'startTime': aos_dt.strftime('%Y-%m-%d %H:%M UTC'),
|
||||||
|
'startTimeISO': aos_dt.isoformat(),
|
||||||
|
'endTimeISO': los_dt.isoformat(),
|
||||||
|
'maxEl': round(float(tca_alt.degrees), 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build 30-point az/el trajectory for polar plot
|
||||||
|
if include_trajectory:
|
||||||
|
trajectory = []
|
||||||
|
for step in range(30):
|
||||||
|
frac = step / 29.0
|
||||||
|
t_pt = ts.tt_jd(
|
||||||
|
aos_time.tt + frac * (los_time.tt - aos_time.tt)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
pt_alt, pt_az, _ = (satellite - observer).at(t_pt).altaz()
|
||||||
|
trajectory.append({
|
||||||
|
'az': round(float(pt_az.degrees), 1),
|
||||||
|
'el': round(float(max(0.0, pt_alt.degrees)), 1),
|
||||||
|
})
|
||||||
|
except Exception as pt_exc:
|
||||||
|
logger.debug(
|
||||||
|
'Trajectory point error for %s: %s', tle_data[0], pt_exc
|
||||||
|
)
|
||||||
|
pass_dict['trajectory'] = trajectory
|
||||||
|
|
||||||
|
# Build 60-point lat/lon ground track for map
|
||||||
|
if include_ground_track:
|
||||||
|
ground_track = []
|
||||||
|
for step in range(60):
|
||||||
|
frac = step / 59.0
|
||||||
|
t_pt = ts.tt_jd(
|
||||||
|
aos_time.tt + frac * (los_time.tt - aos_time.tt)
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
geocentric = satellite.at(t_pt)
|
||||||
|
subpoint = wgs84.subpoint(geocentric)
|
||||||
|
ground_track.append({
|
||||||
|
'lat': round(float(subpoint.latitude.degrees), 4),
|
||||||
|
'lon': round(float(subpoint.longitude.degrees), 4),
|
||||||
|
})
|
||||||
|
except Exception as gt_exc:
|
||||||
|
logger.debug(
|
||||||
|
'Ground track point error for %s: %s', tle_data[0], gt_exc
|
||||||
|
)
|
||||||
|
pass_dict['groundTrack'] = ground_track
|
||||||
|
|
||||||
|
passes.append(pass_dict)
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.debug(
|
||||||
|
'Failed to compute pass details for %s: %s', tle_data[0], exc
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
|
passes.sort(key=lambda p: p['startTimeISO'])
|
||||||
|
return passes
|
||||||
@@ -0,0 +1,435 @@
|
|||||||
|
"""Satellite telemetry packet parsers.
|
||||||
|
|
||||||
|
Provides pure-Python decoders for common amateur/CubeSat protocols:
|
||||||
|
- AX.25 (callsign-addressed frames)
|
||||||
|
- CSP (CubeSat Space Protocol)
|
||||||
|
- CCSDS TM (space packet primary header)
|
||||||
|
|
||||||
|
Also provides a PayloadAnalyzer that generates multi-interpretation
|
||||||
|
views of raw binary data (hex dump, float32, uint16/32, strings).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import string
|
||||||
|
import struct
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# AX.25 parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _decode_ax25_callsign(addr_bytes: bytes) -> str:
|
||||||
|
"""Decode a 7-byte AX.25 address field into a 'CALL-SSID' string.
|
||||||
|
|
||||||
|
The first 6 bytes encode the callsign (each ASCII character left-shifted
|
||||||
|
by 1 bit). The 7th byte encodes the SSID in bits 4-1.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
addr_bytes: Exactly 7 bytes of raw address data.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A callsign string such as ``"N0CALL-3"`` or ``"N0CALL"`` (no suffix
|
||||||
|
when SSID is 0).
|
||||||
|
"""
|
||||||
|
callsign = "".join(chr(b >> 1) for b in addr_bytes[:6]).rstrip()
|
||||||
|
ssid = (addr_bytes[6] >> 1) & 0x0F
|
||||||
|
return f"{callsign}-{ssid}" if ssid else callsign
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ax25(data: bytes) -> dict | None:
|
||||||
|
"""Parse an AX.25 frame from raw bytes.
|
||||||
|
|
||||||
|
Decodes destination and source callsigns, optional repeater addresses,
|
||||||
|
control byte, optional PID byte, and payload.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw bytes of the AX.25 frame (without HDLC flags or FCS).
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with parsed fields or ``None`` if the frame is too short or
|
||||||
|
cannot be decoded.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
# Minimum: 7 (dest) + 7 (src) + 1 (control) = 15 bytes
|
||||||
|
if len(data) < 15:
|
||||||
|
return None
|
||||||
|
|
||||||
|
destination = _decode_ax25_callsign(data[0:7])
|
||||||
|
source = _decode_ax25_callsign(data[7:14])
|
||||||
|
|
||||||
|
# Walk repeater addresses. The H-bit (LSB of byte 6 in each address)
|
||||||
|
# being set means this is the last address in the chain.
|
||||||
|
offset = 14 # byte index of the last byte in the source field
|
||||||
|
repeaters: list[str] = []
|
||||||
|
|
||||||
|
if not (data[offset] & 0x01):
|
||||||
|
# More addresses follow; read up to 8 repeaters.
|
||||||
|
for _ in range(8):
|
||||||
|
rep_start = offset + 1
|
||||||
|
rep_end = rep_start + 7
|
||||||
|
if rep_end > len(data):
|
||||||
|
break
|
||||||
|
repeaters.append(_decode_ax25_callsign(data[rep_start:rep_end]))
|
||||||
|
offset = rep_end - 1 # last byte of this repeater field
|
||||||
|
if data[offset] & 0x01:
|
||||||
|
# H-bit set — this was the final address
|
||||||
|
break
|
||||||
|
|
||||||
|
# Control byte follows the last address field
|
||||||
|
ctrl_offset = offset + 1
|
||||||
|
if ctrl_offset >= len(data):
|
||||||
|
return None
|
||||||
|
|
||||||
|
control = data[ctrl_offset]
|
||||||
|
payload_offset = ctrl_offset + 1
|
||||||
|
|
||||||
|
# PID byte is present for I-frames (bits 0-1 == 0b00) and
|
||||||
|
# UI-frames (bits 0-5 == 0b000011). More generally: absent only
|
||||||
|
# for pure unnumbered frames where (control & 0x03) == 0x03 AND
|
||||||
|
# control is not 0x03 itself (UI).
|
||||||
|
pid: int | None = None
|
||||||
|
is_unnumbered = (control & 0x03) == 0x03
|
||||||
|
is_ui = control == 0x03
|
||||||
|
|
||||||
|
if not is_unnumbered or is_ui:
|
||||||
|
if payload_offset < len(data):
|
||||||
|
pid = data[payload_offset]
|
||||||
|
payload_offset += 1
|
||||||
|
|
||||||
|
payload = data[payload_offset:]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"protocol": "AX.25",
|
||||||
|
"destination": destination,
|
||||||
|
"source": source,
|
||||||
|
"repeaters": repeaters,
|
||||||
|
"control": control,
|
||||||
|
"pid": pid,
|
||||||
|
"payload": payload,
|
||||||
|
"payload_hex": payload.hex(),
|
||||||
|
"payload_length": len(payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CSP parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_csp(data: bytes) -> dict | None:
|
||||||
|
"""Parse a CSP v1 (CubeSat Space Protocol) header.
|
||||||
|
|
||||||
|
The first 4 bytes form a big-endian 32-bit header word with the
|
||||||
|
following bit layout::
|
||||||
|
|
||||||
|
bits 31-27 priority (5 bits)
|
||||||
|
bits 26-22 source (5 bits)
|
||||||
|
bits 21-17 destination (5 bits)
|
||||||
|
bits 16-12 dest_port (5 bits)
|
||||||
|
bits 11-6 src_port (6 bits)
|
||||||
|
bits 5-0 flags (6 bits)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw bytes starting from the CSP header.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with parsed CSP fields and payload, or ``None`` on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if len(data) < 4:
|
||||||
|
return None
|
||||||
|
|
||||||
|
header: int = struct.unpack(">I", data[:4])[0]
|
||||||
|
|
||||||
|
priority = (header >> 27) & 0x1F
|
||||||
|
source = (header >> 22) & 0x1F
|
||||||
|
destination = (header >> 17) & 0x1F
|
||||||
|
dest_port = (header >> 12) & 0x1F
|
||||||
|
src_port = (header >> 6) & 0x3F
|
||||||
|
raw_flags = header & 0x3F
|
||||||
|
|
||||||
|
flags = {
|
||||||
|
"frag": bool(raw_flags & 0x10),
|
||||||
|
"hmac": bool(raw_flags & 0x08),
|
||||||
|
"xtea": bool(raw_flags & 0x04),
|
||||||
|
"rdp": bool(raw_flags & 0x02),
|
||||||
|
"crc": bool(raw_flags & 0x01),
|
||||||
|
}
|
||||||
|
|
||||||
|
payload = data[4:]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"protocol": "CSP",
|
||||||
|
"priority": priority,
|
||||||
|
"source": source,
|
||||||
|
"destination": destination,
|
||||||
|
"dest_port": dest_port,
|
||||||
|
"src_port": src_port,
|
||||||
|
"flags": flags,
|
||||||
|
"payload": payload,
|
||||||
|
"payload_hex": payload.hex(),
|
||||||
|
"payload_length": len(payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# CCSDS parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def parse_ccsds(data: bytes) -> dict | None:
|
||||||
|
"""Parse a CCSDS Space Packet primary header (6 bytes).
|
||||||
|
|
||||||
|
Header layout::
|
||||||
|
|
||||||
|
bytes 0-1: version (3 bits) | packet_type (1 bit) |
|
||||||
|
secondary_header_flag (1 bit) | APID (11 bits)
|
||||||
|
bytes 2-3: sequence_flags (2 bits) | sequence_count (14 bits)
|
||||||
|
bytes 4-5: data_length field (16 bits, = actual_payload_length - 1)
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw bytes starting from the CCSDS primary header.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with parsed CCSDS fields and payload, or ``None`` on failure.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
if len(data) < 6:
|
||||||
|
return None
|
||||||
|
|
||||||
|
word0: int = struct.unpack(">H", data[0:2])[0]
|
||||||
|
word1: int = struct.unpack(">H", data[2:4])[0]
|
||||||
|
word2: int = struct.unpack(">H", data[4:6])[0]
|
||||||
|
|
||||||
|
version = (word0 >> 13) & 0x07
|
||||||
|
packet_type = (word0 >> 12) & 0x01
|
||||||
|
secondary_header_flag = bool((word0 >> 11) & 0x01)
|
||||||
|
apid = word0 & 0x07FF
|
||||||
|
|
||||||
|
sequence_flags = (word1 >> 14) & 0x03
|
||||||
|
sequence_count = word1 & 0x3FFF
|
||||||
|
|
||||||
|
data_length = word2 # raw field; actual user data bytes = data_length + 1
|
||||||
|
|
||||||
|
payload = data[6:]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"protocol": "CCSDS_TM",
|
||||||
|
"version": version,
|
||||||
|
"packet_type": packet_type,
|
||||||
|
"secondary_header": secondary_header_flag,
|
||||||
|
"apid": apid,
|
||||||
|
"sequence_flags": sequence_flags,
|
||||||
|
"sequence_count": sequence_count,
|
||||||
|
"data_length": data_length,
|
||||||
|
"payload": payload,
|
||||||
|
"payload_hex": payload.hex(),
|
||||||
|
"payload_length": len(payload),
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Payload analyzer
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PRINTABLE = set(string.printable) - set("\t\n\r\x0b\x0c")
|
||||||
|
|
||||||
|
|
||||||
|
def _hex_dump(data: bytes) -> str:
|
||||||
|
"""Format bytes as an annotated hex dump, 16 bytes per line.
|
||||||
|
|
||||||
|
Each line is formatted as::
|
||||||
|
|
||||||
|
OOOO: XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX XX ASCII
|
||||||
|
|
||||||
|
where ``OOOO`` is the hex offset and ``ASCII`` shows printable characters
|
||||||
|
(non-printable replaced with ``'.'``).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Bytes to format.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Multi-line hex dump string (trailing newline on each line).
|
||||||
|
"""
|
||||||
|
lines: list[str] = []
|
||||||
|
for row in range(0, len(data), 16):
|
||||||
|
chunk = data[row : row + 16]
|
||||||
|
# Build groups of 4 bytes separated by two spaces
|
||||||
|
groups: list[str] = []
|
||||||
|
for g in range(0, 16, 4):
|
||||||
|
group_bytes = chunk[g : g + 4]
|
||||||
|
groups.append(" ".join(f"{b:02X}" for b in group_bytes))
|
||||||
|
hex_part = " ".join(groups)
|
||||||
|
# Pad to fixed width: 16 bytes × 3 chars - 1 space + 3 group separators
|
||||||
|
# Maximum width: 11+2+11+2+11+2+11 = 50 chars; pad to 50
|
||||||
|
hex_part = hex_part.ljust(50)
|
||||||
|
ascii_part = "".join(chr(b) if chr(b) in _PRINTABLE else "." for b in chunk)
|
||||||
|
lines.append(f"{row:04X}: {hex_part} {ascii_part}\n")
|
||||||
|
return "".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_strings(data: bytes, min_len: int = 3) -> list[str]:
|
||||||
|
"""Extract runs of printable ASCII characters of at least ``min_len``."""
|
||||||
|
results: list[str] = []
|
||||||
|
current: list[str] = []
|
||||||
|
for b in data:
|
||||||
|
ch = chr(b)
|
||||||
|
if ch in _PRINTABLE:
|
||||||
|
current.append(ch)
|
||||||
|
else:
|
||||||
|
if len(current) >= min_len:
|
||||||
|
results.append("".join(current))
|
||||||
|
current = []
|
||||||
|
if len(current) >= min_len:
|
||||||
|
results.append("".join(current))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def analyze_payload(data: bytes) -> dict:
|
||||||
|
"""Generate a multi-interpretation analysis of raw bytes.
|
||||||
|
|
||||||
|
Produces a hex dump, several numeric/string interpretations, and a
|
||||||
|
list of heuristic observations about plausible sensor values.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw bytes to analyze.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict containing ``hex_dump``, ``length``, ``interpretations``,
|
||||||
|
and ``heuristics`` keys. Never raises an exception.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
hex_dump = _hex_dump(data)
|
||||||
|
length = len(data)
|
||||||
|
|
||||||
|
# --- float32 (little-endian) ---
|
||||||
|
float32_values: list[float] = []
|
||||||
|
for i in range(0, length - 3, 4):
|
||||||
|
(val,) = struct.unpack_from("<f", data, i)
|
||||||
|
if not math.isnan(val) and abs(val) <= 1e9:
|
||||||
|
float32_values.append(val)
|
||||||
|
|
||||||
|
# --- uint16 little-endian ---
|
||||||
|
uint16_values: list[int] = []
|
||||||
|
for i in range(0, length - 1, 2):
|
||||||
|
(val,) = struct.unpack_from("<H", data, i)
|
||||||
|
uint16_values.append(val)
|
||||||
|
|
||||||
|
# --- uint32 little-endian ---
|
||||||
|
uint32_values: list[int] = []
|
||||||
|
for i in range(0, length - 3, 4):
|
||||||
|
(val,) = struct.unpack_from("<I", data, i)
|
||||||
|
uint32_values.append(val)
|
||||||
|
|
||||||
|
# --- printable string runs ---
|
||||||
|
strings = _extract_strings(data, min_len=3)
|
||||||
|
|
||||||
|
interpretations = {
|
||||||
|
"float32": float32_values,
|
||||||
|
"uint16_le": uint16_values,
|
||||||
|
"uint32_le": uint32_values,
|
||||||
|
"strings": strings,
|
||||||
|
}
|
||||||
|
|
||||||
|
# --- heuristics ---
|
||||||
|
heuristics: list[str] = []
|
||||||
|
used_as_voltage: set[int] = set()
|
||||||
|
|
||||||
|
for idx, v in enumerate(float32_values):
|
||||||
|
# Voltage: small positive float
|
||||||
|
if 0.0 < v < 10.0:
|
||||||
|
heuristics.append(f"Possible voltage: {v:.3f} V (index {idx})")
|
||||||
|
used_as_voltage.add(idx)
|
||||||
|
|
||||||
|
for idx, v in enumerate(float32_values):
|
||||||
|
# Temperature: plausible range, not already flagged as voltage, not zero
|
||||||
|
if -50.0 < v < 120.0 and idx not in used_as_voltage and v != 0.0:
|
||||||
|
heuristics.append(f"Possible temperature: {v:.1f}°C (index {idx})")
|
||||||
|
|
||||||
|
for idx, v in enumerate(float32_values):
|
||||||
|
# Current: small positive float not already flagged as voltage
|
||||||
|
if 0.0 < v < 5.0 and idx not in used_as_voltage:
|
||||||
|
heuristics.append(f"Possible current: {v:.3f} A (index {idx})")
|
||||||
|
|
||||||
|
for idx, v in enumerate(float32_values):
|
||||||
|
# Unix timestamp: plausible range (roughly 2001–2033)
|
||||||
|
if 1_000_000_000.0 < v < 2_000_000_000.0:
|
||||||
|
ts = datetime.utcfromtimestamp(v)
|
||||||
|
heuristics.append(f"Possible Unix timestamp: {ts} (index {idx})")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hex_dump": hex_dump,
|
||||||
|
"length": length,
|
||||||
|
"interpretations": interpretations,
|
||||||
|
"heuristics": heuristics,
|
||||||
|
}
|
||||||
|
|
||||||
|
except Exception: # noqa: BLE001
|
||||||
|
# Guarantee a safe return even on completely malformed input
|
||||||
|
return {
|
||||||
|
"hex_dump": "",
|
||||||
|
"length": len(data) if isinstance(data, (bytes, bytearray)) else 0,
|
||||||
|
"interpretations": {"float32": [], "uint16_le": [], "uint32_le": [], "strings": []},
|
||||||
|
"heuristics": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Auto-parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def auto_parse(data: bytes) -> dict:
|
||||||
|
"""Attempt to decode a packet using each supported protocol in turn.
|
||||||
|
|
||||||
|
Tries parsers in priority order: CSP → CCSDS → AX.25. Returns the
|
||||||
|
first successful parse merged with a ``payload_analysis`` key produced
|
||||||
|
by :func:`analyze_payload`.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
data: Raw bytes of the packet.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict with parsed protocol fields plus ``payload_analysis``, or a
|
||||||
|
fallback dict with ``protocol: 'unknown'`` and a top-level
|
||||||
|
``analysis`` key if no parser succeeds.
|
||||||
|
"""
|
||||||
|
# CSP: 4-byte header minimum
|
||||||
|
if len(data) >= 4:
|
||||||
|
result = parse_csp(data)
|
||||||
|
if result is not None:
|
||||||
|
result["payload_analysis"] = analyze_payload(result["payload"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
# CCSDS: 6-byte header minimum
|
||||||
|
if len(data) >= 6:
|
||||||
|
result = parse_ccsds(data)
|
||||||
|
if result is not None:
|
||||||
|
result["payload_analysis"] = analyze_payload(result["payload"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
# AX.25: 15-byte frame minimum
|
||||||
|
if len(data) >= 15:
|
||||||
|
result = parse_ax25(data)
|
||||||
|
if result is not None:
|
||||||
|
result["payload_analysis"] = analyze_payload(result["payload"])
|
||||||
|
return result
|
||||||
|
|
||||||
|
# Nothing matched — return a raw analysis
|
||||||
|
return {
|
||||||
|
"protocol": "unknown",
|
||||||
|
"raw_hex": data.hex(),
|
||||||
|
"analysis": analyze_payload(data),
|
||||||
|
}
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
"""SatNOGS transmitter data.
|
||||||
|
|
||||||
|
Fetches downlink/uplink frequency data from the SatNOGS database,
|
||||||
|
keyed by NORAD ID. Cached for 24 hours to avoid hammering the API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
import urllib.request
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger("intercept.satnogs")
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Module-level cache
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_transmitters: dict[int, list[dict]] = {}
|
||||||
|
_fetched_at: float = 0.0
|
||||||
|
_CACHE_TTL = 86400 # 24 hours in seconds
|
||||||
|
_fetch_lock = threading.Lock()
|
||||||
|
_prefetch_started = False
|
||||||
|
|
||||||
|
_SATNOGS_URL = "https://db.satnogs.org/api/transmitters/?format=json"
|
||||||
|
_REQUEST_TIMEOUT = 6 # seconds
|
||||||
|
|
||||||
|
_BUILTIN_TRANSMITTERS: dict[int, list[dict]] = {
|
||||||
|
25544: [
|
||||||
|
{
|
||||||
|
"description": "APRS digipeater",
|
||||||
|
"downlink_low": 145.825,
|
||||||
|
"downlink_high": 145.825,
|
||||||
|
"uplink_low": None,
|
||||||
|
"uplink_high": None,
|
||||||
|
"mode": "FM AX.25",
|
||||||
|
"baud": 1200,
|
||||||
|
"status": "active",
|
||||||
|
"type": "beacon",
|
||||||
|
"service": "Packet",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "SSTV events",
|
||||||
|
"downlink_low": 145.800,
|
||||||
|
"downlink_high": 145.800,
|
||||||
|
"uplink_low": None,
|
||||||
|
"uplink_high": None,
|
||||||
|
"mode": "FM",
|
||||||
|
"baud": None,
|
||||||
|
"status": "active",
|
||||||
|
"type": "image",
|
||||||
|
"service": "SSTV",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
40069: [
|
||||||
|
{
|
||||||
|
"description": "Meteor LRPT weather downlink",
|
||||||
|
"downlink_low": 137.900,
|
||||||
|
"downlink_high": 137.900,
|
||||||
|
"uplink_low": None,
|
||||||
|
"uplink_high": None,
|
||||||
|
"mode": "LRPT",
|
||||||
|
"baud": 72000,
|
||||||
|
"status": "active",
|
||||||
|
"type": "image",
|
||||||
|
"service": "Weather",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
57166: [
|
||||||
|
{
|
||||||
|
"description": "Meteor LRPT weather downlink",
|
||||||
|
"downlink_low": 137.900,
|
||||||
|
"downlink_high": 137.900,
|
||||||
|
"uplink_low": None,
|
||||||
|
"uplink_high": None,
|
||||||
|
"mode": "LRPT",
|
||||||
|
"baud": 72000,
|
||||||
|
"status": "active",
|
||||||
|
"type": "image",
|
||||||
|
"service": "Weather",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
59051: [
|
||||||
|
{
|
||||||
|
"description": "Meteor LRPT weather downlink",
|
||||||
|
"downlink_low": 137.900,
|
||||||
|
"downlink_high": 137.900,
|
||||||
|
"uplink_low": None,
|
||||||
|
"uplink_high": None,
|
||||||
|
"mode": "LRPT",
|
||||||
|
"baud": 72000,
|
||||||
|
"status": "active",
|
||||||
|
"type": "image",
|
||||||
|
"service": "Weather",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Internal helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _hz_to_mhz(value: float | int | None) -> float | None:
|
||||||
|
"""Convert a frequency in Hz to MHz, returning None if value is None."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return float(value) / 1_000_000.0
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value: object) -> float | None:
|
||||||
|
"""Return a float or None, silently swallowing conversion errors."""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
try:
|
||||||
|
return float(value) # type: ignore[arg-type]
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_transmitters() -> dict[int, list[dict]]:
|
||||||
|
"""Fetch transmitter records from the SatNOGS database API.
|
||||||
|
|
||||||
|
Makes a single HTTP GET to the SatNOGS transmitters endpoint, groups
|
||||||
|
results by NORAD catalogue ID, and converts all frequency fields from
|
||||||
|
Hz to MHz.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict mapping NORAD ID (int) to a list of transmitter dicts.
|
||||||
|
Returns an empty dict on any network or parse error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
logger.info("Fetching SatNOGS transmitter data from %s", _SATNOGS_URL)
|
||||||
|
with urllib.request.urlopen(_SATNOGS_URL, timeout=_REQUEST_TIMEOUT) as resp:
|
||||||
|
raw = resp.read()
|
||||||
|
|
||||||
|
records: list[dict] = json.loads(raw)
|
||||||
|
|
||||||
|
grouped: dict[int, list[dict]] = {}
|
||||||
|
for item in records:
|
||||||
|
norad_id = item.get("norad_cat_id")
|
||||||
|
if norad_id is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
norad_id = int(norad_id)
|
||||||
|
|
||||||
|
entry: dict = {
|
||||||
|
"description": str(item.get("description") or ""),
|
||||||
|
"downlink_low": _hz_to_mhz(_safe_float(item.get("downlink_low"))),
|
||||||
|
"downlink_high": _hz_to_mhz(_safe_float(item.get("downlink_high"))),
|
||||||
|
"uplink_low": _hz_to_mhz(_safe_float(item.get("uplink_low"))),
|
||||||
|
"uplink_high": _hz_to_mhz(_safe_float(item.get("uplink_high"))),
|
||||||
|
"mode": str(item.get("mode") or ""),
|
||||||
|
"baud": _safe_float(item.get("baud")),
|
||||||
|
"status": str(item.get("status") or ""),
|
||||||
|
"type": str(item.get("type") or ""),
|
||||||
|
"service": str(item.get("service") or ""),
|
||||||
|
}
|
||||||
|
|
||||||
|
grouped.setdefault(norad_id, []).append(entry)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"SatNOGS fetch complete: %d satellites with transmitter data",
|
||||||
|
len(grouped),
|
||||||
|
)
|
||||||
|
return grouped
|
||||||
|
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
logger.warning("Failed to fetch SatNOGS transmitter data: %s", exc)
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def get_transmitters(norad_id: int) -> list[dict]:
|
||||||
|
"""Return cached transmitter records for a given NORAD catalogue ID.
|
||||||
|
|
||||||
|
Refreshes the in-memory cache from the SatNOGS API when the cache is
|
||||||
|
empty or older than ``_CACHE_TTL`` seconds (24 hours).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
norad_id: The NORAD catalogue ID of the satellite.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A (possibly empty) list of transmitter dicts for that satellite.
|
||||||
|
"""
|
||||||
|
global _transmitters, _fetched_at # noqa: PLW0603
|
||||||
|
|
||||||
|
sat_id = int(norad_id)
|
||||||
|
age = time.time() - _fetched_at
|
||||||
|
|
||||||
|
# Fast path: serve warm cache immediately.
|
||||||
|
if _transmitters and age <= _CACHE_TTL:
|
||||||
|
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
|
||||||
|
|
||||||
|
# Avoid blocking the UI behind a long-running background refresh.
|
||||||
|
if not _fetch_lock.acquire(blocking=False):
|
||||||
|
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
|
||||||
|
|
||||||
|
try:
|
||||||
|
age = time.time() - _fetched_at
|
||||||
|
if not _transmitters or age > _CACHE_TTL:
|
||||||
|
fetched = fetch_transmitters()
|
||||||
|
if fetched:
|
||||||
|
_transmitters = fetched
|
||||||
|
_fetched_at = time.time()
|
||||||
|
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
|
||||||
|
finally:
|
||||||
|
_fetch_lock.release()
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_transmitters() -> int:
|
||||||
|
"""Force-refresh the transmitter cache regardless of TTL.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The number of satellites (unique NORAD IDs) with transmitter data
|
||||||
|
after the refresh.
|
||||||
|
"""
|
||||||
|
global _transmitters, _fetched_at # noqa: PLW0603
|
||||||
|
|
||||||
|
with _fetch_lock:
|
||||||
|
fetched = fetch_transmitters()
|
||||||
|
if fetched:
|
||||||
|
_transmitters = fetched
|
||||||
|
_fetched_at = time.time()
|
||||||
|
return len(_transmitters)
|
||||||
|
|
||||||
|
|
||||||
|
def prefetch_transmitters() -> None:
|
||||||
|
"""Kick off a background thread to warm the transmitter cache at startup.
|
||||||
|
|
||||||
|
Safe to call multiple times — only spawns one thread.
|
||||||
|
"""
|
||||||
|
global _prefetch_started # noqa: PLW0603
|
||||||
|
|
||||||
|
with _fetch_lock:
|
||||||
|
if _prefetch_started:
|
||||||
|
return
|
||||||
|
_prefetch_started = True
|
||||||
|
|
||||||
|
def _run() -> None:
|
||||||
|
logger.info("Pre-fetching SatNOGS transmitter data in background...")
|
||||||
|
global _transmitters, _fetched_at # noqa: PLW0603
|
||||||
|
data = fetch_transmitters()
|
||||||
|
with _fetch_lock:
|
||||||
|
_transmitters = data
|
||||||
|
_fetched_at = time.time()
|
||||||
|
logger.info("SatNOGS prefetch complete: %d satellites cached", len(data))
|
||||||
|
|
||||||
|
t = threading.Thread(target=_run, name="satnogs-prefetch", daemon=True)
|
||||||
|
t.start()
|
||||||
+36
-4
@@ -46,6 +46,35 @@ def _rtl_tool_supports_bias_t(tool_path: str) -> bool:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def enable_bias_t_via_rtl_biast(device_index: int = 0) -> bool:
|
||||||
|
"""Enable bias-t power using rtl_biast (RTL-SDR Blog drivers).
|
||||||
|
|
||||||
|
Runs rtl_biast to set the bias-t register on the device, then exits.
|
||||||
|
The setting persists across device opens until the device is reset.
|
||||||
|
|
||||||
|
Returns True if bias-t was enabled successfully.
|
||||||
|
"""
|
||||||
|
rtl_biast_path = get_tool_path('rtl_biast') or 'rtl_biast'
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[rtl_biast_path, '-b', '1', '-d', str(device_index)],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=5
|
||||||
|
)
|
||||||
|
if result.returncode == 0:
|
||||||
|
logger.info(f"Bias-t enabled via rtl_biast on device {device_index}")
|
||||||
|
return True
|
||||||
|
logger.warning(f"rtl_biast failed (exit {result.returncode}): {result.stderr.strip()}")
|
||||||
|
return False
|
||||||
|
except FileNotFoundError:
|
||||||
|
logger.warning("rtl_biast not found — install RTL-SDR Blog drivers for bias-t support")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Failed to enable bias-t via rtl_biast: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None:
|
def _get_dump1090_bias_t_flag(dump1090_path: str) -> str | None:
|
||||||
"""Detect the correct bias-t flag for the installed dump1090 variant.
|
"""Detect the correct bias-t flag for the installed dump1090 variant.
|
||||||
|
|
||||||
@@ -197,10 +226,13 @@ class RTLSDRCommandBuilder(CommandBuilder):
|
|||||||
if bias_t_flag:
|
if bias_t_flag:
|
||||||
cmd.append(bias_t_flag)
|
cmd.append(bias_t_flag)
|
||||||
else:
|
else:
|
||||||
logger.warning(
|
# Fallback: use rtl_biast to set bias-t before starting dump1090
|
||||||
f"Bias-t requested but {dump1090_path} does not support it. "
|
if not enable_bias_t_via_rtl_biast(device.index):
|
||||||
"Consider using dump1090-fa or readsb for bias-t support."
|
logger.warning(
|
||||||
)
|
f"Bias-t requested but {dump1090_path} does not support it "
|
||||||
|
"and rtl_biast is not available. Install RTL-SDR Blog drivers "
|
||||||
|
"or use dump1090-fa/readsb for bias-t support."
|
||||||
|
)
|
||||||
|
|
||||||
return cmd
|
return cmd
|
||||||
|
|
||||||
|
|||||||
+208
@@ -0,0 +1,208 @@
|
|||||||
|
"""SigMF metadata and writer for IQ recordings.
|
||||||
|
|
||||||
|
Writes raw CU8 I/Q data to ``.sigmf-data`` files and companion
|
||||||
|
``.sigmf-meta`` JSON metadata files conforming to the SigMF spec v1.x.
|
||||||
|
|
||||||
|
Output directory: ``instance/ground_station/recordings/``
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import shutil
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.sigmf')
|
||||||
|
|
||||||
|
# Abort recording if less than this many bytes are free on the disk
|
||||||
|
DEFAULT_MIN_FREE_BYTES = 500 * 1024 * 1024 # 500 MB
|
||||||
|
|
||||||
|
OUTPUT_DIR = Path('instance/ground_station/recordings')
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class SigMFMetadata:
|
||||||
|
"""SigMF metadata block.
|
||||||
|
|
||||||
|
Covers the fields most relevant for ground-station recordings. The
|
||||||
|
``global`` block is always written; an ``annotations`` list is built
|
||||||
|
incrementally if callers add annotation events.
|
||||||
|
"""
|
||||||
|
|
||||||
|
sample_rate: int
|
||||||
|
center_frequency_hz: float
|
||||||
|
datatype: str = 'cu8' # unsigned 8-bit I/Q (rtlsdr native)
|
||||||
|
description: str = ''
|
||||||
|
author: str = 'INTERCEPT ground station'
|
||||||
|
recorder: str = 'INTERCEPT'
|
||||||
|
hw: str = ''
|
||||||
|
norad_id: int = 0
|
||||||
|
satellite_name: str = ''
|
||||||
|
latitude: float = 0.0
|
||||||
|
longitude: float = 0.0
|
||||||
|
annotations: list[dict[str, Any]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def to_dict(self) -> dict[str, Any]:
|
||||||
|
global_block: dict[str, Any] = {
|
||||||
|
'core:datatype': self.datatype,
|
||||||
|
'core:sample_rate': self.sample_rate,
|
||||||
|
'core:version': '1.0.0',
|
||||||
|
'core:recorder': self.recorder,
|
||||||
|
}
|
||||||
|
if self.description:
|
||||||
|
global_block['core:description'] = self.description
|
||||||
|
if self.author:
|
||||||
|
global_block['core:author'] = self.author
|
||||||
|
if self.hw:
|
||||||
|
global_block['core:hw'] = self.hw
|
||||||
|
if self.latitude or self.longitude:
|
||||||
|
global_block['core:geolocation'] = {
|
||||||
|
'type': 'Point',
|
||||||
|
'coordinates': [self.longitude, self.latitude],
|
||||||
|
}
|
||||||
|
|
||||||
|
captures = [
|
||||||
|
{
|
||||||
|
'core:sample_start': 0,
|
||||||
|
'core:frequency': self.center_frequency_hz,
|
||||||
|
'core:datetime': datetime.now(timezone.utc).strftime('%Y-%m-%dT%H:%M:%SZ'),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
return {
|
||||||
|
'global': global_block,
|
||||||
|
'captures': captures,
|
||||||
|
'annotations': self.annotations,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SigMFWriter:
|
||||||
|
"""Streams raw CU8 IQ bytes to a SigMF recording pair."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
metadata: SigMFMetadata,
|
||||||
|
output_dir: Path | str | None = None,
|
||||||
|
stem: str | None = None,
|
||||||
|
min_free_bytes: int = DEFAULT_MIN_FREE_BYTES,
|
||||||
|
):
|
||||||
|
self._metadata = metadata
|
||||||
|
self._output_dir = Path(output_dir) if output_dir else OUTPUT_DIR
|
||||||
|
self._stem = stem or _default_stem(metadata)
|
||||||
|
self._min_free_bytes = min_free_bytes
|
||||||
|
|
||||||
|
self._data_path: Path | None = None
|
||||||
|
self._meta_path: Path | None = None
|
||||||
|
self._data_file = None
|
||||||
|
self._bytes_written = 0
|
||||||
|
self._aborted = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Public API
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def open(self) -> None:
|
||||||
|
"""Create output directory and open the data file for writing."""
|
||||||
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._data_path = self._output_dir / f'{self._stem}.sigmf-data'
|
||||||
|
self._meta_path = self._output_dir / f'{self._stem}.sigmf-meta'
|
||||||
|
self._data_file = open(self._data_path, 'wb')
|
||||||
|
self._bytes_written = 0
|
||||||
|
self._aborted = False
|
||||||
|
logger.info(f"SigMFWriter opened: {self._data_path}")
|
||||||
|
|
||||||
|
def write_chunk(self, raw: bytes) -> bool:
|
||||||
|
"""Write a chunk of raw CU8 bytes.
|
||||||
|
|
||||||
|
Returns False (and sets ``aborted``) if disk space drops below
|
||||||
|
the minimum threshold.
|
||||||
|
"""
|
||||||
|
if self._aborted or self._data_file is None:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check free space before writing
|
||||||
|
try:
|
||||||
|
usage = shutil.disk_usage(self._output_dir)
|
||||||
|
if usage.free < self._min_free_bytes:
|
||||||
|
logger.warning(
|
||||||
|
f"SigMF recording aborted — disk free "
|
||||||
|
f"({usage.free // (1024**2)} MB) below "
|
||||||
|
f"{self._min_free_bytes // (1024**2)} MB threshold"
|
||||||
|
)
|
||||||
|
self._aborted = True
|
||||||
|
self._data_file.close()
|
||||||
|
self._data_file = None
|
||||||
|
return False
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
self._data_file.write(raw)
|
||||||
|
self._bytes_written += len(raw)
|
||||||
|
return True
|
||||||
|
|
||||||
|
def close(self) -> tuple[Path, Path] | None:
|
||||||
|
"""Flush data, write .sigmf-meta, close file.
|
||||||
|
|
||||||
|
Returns ``(meta_path, data_path)`` on success, *None* if never
|
||||||
|
opened or already aborted before any data was written.
|
||||||
|
"""
|
||||||
|
if self._data_file is not None:
|
||||||
|
try:
|
||||||
|
self._data_file.flush()
|
||||||
|
self._data_file.close()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._data_file = None
|
||||||
|
|
||||||
|
if self._data_path is None or self._meta_path is None:
|
||||||
|
return None
|
||||||
|
if self._bytes_written == 0 and not self._aborted:
|
||||||
|
# Nothing written — clean up empty file
|
||||||
|
self._data_path.unlink(missing_ok=True)
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
meta_dict = self._metadata.to_dict()
|
||||||
|
self._meta_path.write_text(
|
||||||
|
json.dumps(meta_dict, indent=2), encoding='utf-8'
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to write SigMF metadata: {e}")
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"SigMFWriter closed: {self._bytes_written} bytes → {self._data_path}"
|
||||||
|
)
|
||||||
|
return self._meta_path, self._data_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def bytes_written(self) -> int:
|
||||||
|
return self._bytes_written
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aborted(self) -> bool:
|
||||||
|
return self._aborted
|
||||||
|
|
||||||
|
@property
|
||||||
|
def data_path(self) -> Path | None:
|
||||||
|
return self._data_path
|
||||||
|
|
||||||
|
@property
|
||||||
|
def meta_path(self) -> Path | None:
|
||||||
|
return self._meta_path
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helpers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _default_stem(meta: SigMFMetadata) -> str:
|
||||||
|
ts = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ')
|
||||||
|
sat = (meta.satellite_name or 'unknown').replace(' ', '_').replace('/', '-')
|
||||||
|
freq_khz = int(meta.center_frequency_hz / 1000)
|
||||||
|
return f'{ts}_{sat}_{freq_khz}kHz'
|
||||||
@@ -152,6 +152,10 @@ def sse_stream_fanout(
|
|||||||
)
|
)
|
||||||
last_keepalive = time.time()
|
last_keepalive = time.time()
|
||||||
|
|
||||||
|
# Send an immediate keepalive so the browser receives response headers
|
||||||
|
# right away (Werkzeug dev server buffers headers until first body byte).
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
if stop_check and stop_check():
|
if stop_check and stop_check():
|
||||||
|
|||||||
+15
-110
@@ -3,8 +3,8 @@
|
|||||||
Provides the SSTVDecoder class that manages the full pipeline:
|
Provides the SSTVDecoder class that manages the full pipeline:
|
||||||
rtl_fm subprocess -> audio stream -> VIS detection -> image decoding -> PNG output.
|
rtl_fm subprocess -> audio stream -> VIS detection -> image decoding -> PNG output.
|
||||||
|
|
||||||
Also contains DopplerTracker and supporting dataclasses migrated from the
|
DopplerTracker and DopplerInfo live in utils/doppler.py and are re-exported
|
||||||
original monolithic utils/sstv.py.
|
here for backwards compatibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -16,15 +16,20 @@ import subprocess
|
|||||||
import threading
|
import threading
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime, timedelta, timezone
|
from datetime import datetime, timezone
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
# DopplerTracker/DopplerInfo now live in the shared utils/doppler module.
|
||||||
|
# Import them here so existing code that does
|
||||||
|
# ``from utils.sstv.sstv_decoder import DopplerTracker``
|
||||||
|
# continues to work unchanged.
|
||||||
|
from utils.doppler import DopplerInfo, DopplerTracker # noqa: F401
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
|
||||||
from .constants import ISS_SSTV_FREQ, SAMPLE_RATE, SPEED_OF_LIGHT
|
from .constants import ISS_SSTV_FREQ, SAMPLE_RATE
|
||||||
from .dsp import goertzel_mag, normalize_audio
|
from .dsp import goertzel_mag, normalize_audio
|
||||||
from .image_decoder import SSTVImageDecoder
|
from .image_decoder import SSTVImageDecoder
|
||||||
from .modes import get_mode
|
from .modes import get_mode
|
||||||
@@ -42,25 +47,10 @@ except ImportError:
|
|||||||
# Dataclasses
|
# Dataclasses
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@dataclass
|
# DopplerInfo is now defined in utils/doppler and imported at the top of
|
||||||
class DopplerInfo:
|
# this module. The re-export keeps any code that does
|
||||||
"""Doppler shift information."""
|
# from utils.sstv.sstv_decoder import DopplerInfo
|
||||||
frequency_hz: float
|
# working without changes.
|
||||||
shift_hz: float
|
|
||||||
range_rate_km_s: float
|
|
||||||
elevation: float
|
|
||||||
azimuth: float
|
|
||||||
timestamp: datetime
|
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
|
||||||
return {
|
|
||||||
'frequency_hz': self.frequency_hz,
|
|
||||||
'shift_hz': round(self.shift_hz, 1),
|
|
||||||
'range_rate_km_s': round(self.range_rate_km_s, 3),
|
|
||||||
'elevation': round(self.elevation, 1),
|
|
||||||
'azimuth': round(self.azimuth, 1),
|
|
||||||
'timestamp': self.timestamp.isoformat(),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -133,93 +123,8 @@ def _encode_scope_waveform(raw_samples: np.ndarray, window_size: int = 256) -> l
|
|||||||
return packed.tolist()
|
return packed.tolist()
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# DopplerTracker is now imported from utils/doppler at the top of this module.
|
||||||
# DopplerTracker
|
# Nothing to define here.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
|
|
||||||
class DopplerTracker:
|
|
||||||
"""Real-time Doppler shift calculator for satellite tracking.
|
|
||||||
|
|
||||||
Uses skyfield to calculate the range rate between observer and satellite,
|
|
||||||
then computes the Doppler-shifted receive frequency.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def __init__(self, satellite_name: str = 'ISS'):
|
|
||||||
self._satellite_name = satellite_name
|
|
||||||
self._observer_lat: float | None = None
|
|
||||||
self._observer_lon: float | None = None
|
|
||||||
self._satellite = None
|
|
||||||
self._observer = None
|
|
||||||
self._ts = None
|
|
||||||
self._enabled = False
|
|
||||||
|
|
||||||
def configure(self, latitude: float, longitude: float) -> bool:
|
|
||||||
"""Configure the Doppler tracker with observer location."""
|
|
||||||
try:
|
|
||||||
from skyfield.api import EarthSatellite, load, wgs84
|
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
|
||||||
|
|
||||||
tle_data = TLE_SATELLITES.get(self._satellite_name)
|
|
||||||
if not tle_data:
|
|
||||||
logger.error(f"No TLE data for satellite: {self._satellite_name}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
self._ts = load.timescale()
|
|
||||||
self._satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], self._ts)
|
|
||||||
self._observer = wgs84.latlon(latitude, longitude)
|
|
||||||
self._observer_lat = latitude
|
|
||||||
self._observer_lon = longitude
|
|
||||||
self._enabled = True
|
|
||||||
|
|
||||||
logger.info(f"Doppler tracker configured for {self._satellite_name} at ({latitude}, {longitude})")
|
|
||||||
return True
|
|
||||||
|
|
||||||
except ImportError:
|
|
||||||
logger.warning("skyfield not available - Doppler tracking disabled")
|
|
||||||
return False
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to configure Doppler tracker: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
@property
|
|
||||||
def is_enabled(self) -> bool:
|
|
||||||
return self._enabled
|
|
||||||
|
|
||||||
def calculate(self, nominal_freq_mhz: float) -> DopplerInfo | None:
|
|
||||||
"""Calculate current Doppler-shifted frequency."""
|
|
||||||
if not self._enabled or not self._satellite or not self._observer:
|
|
||||||
return None
|
|
||||||
|
|
||||||
try:
|
|
||||||
t = self._ts.now()
|
|
||||||
difference = self._satellite - self._observer
|
|
||||||
topocentric = difference.at(t)
|
|
||||||
alt, az, distance = topocentric.altaz()
|
|
||||||
|
|
||||||
dt_seconds = 1.0
|
|
||||||
t_future = self._ts.utc(t.utc_datetime() + timedelta(seconds=dt_seconds))
|
|
||||||
topocentric_future = difference.at(t_future)
|
|
||||||
_, _, distance_future = topocentric_future.altaz()
|
|
||||||
|
|
||||||
range_rate_km_s = (distance_future.km - distance.km) / dt_seconds
|
|
||||||
nominal_freq_hz = nominal_freq_mhz * 1_000_000
|
|
||||||
doppler_factor = 1 - (range_rate_km_s * 1000 / SPEED_OF_LIGHT)
|
|
||||||
corrected_freq_hz = nominal_freq_hz * doppler_factor
|
|
||||||
shift_hz = corrected_freq_hz - nominal_freq_hz
|
|
||||||
|
|
||||||
return DopplerInfo(
|
|
||||||
frequency_hz=corrected_freq_hz,
|
|
||||||
shift_hz=shift_hz,
|
|
||||||
range_rate_km_s=range_rate_km_s,
|
|
||||||
elevation=alt.degrees,
|
|
||||||
azimuth=az.degrees,
|
|
||||||
timestamp=datetime.now(timezone.utc)
|
|
||||||
)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Doppler calculation failed: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
+33
-17
@@ -1,16 +1,13 @@
|
|||||||
"""Weather Satellite decoder for NOAA APT and Meteor LRPT imagery.
|
"""Weather satellite decoder focused on Meteor LRPT workflows.
|
||||||
|
|
||||||
Provides automated capture and decoding of weather satellite images using SatDump.
|
Provides automated capture and decoding of weather imagery using SatDump.
|
||||||
|
|
||||||
Supported satellites:
|
Active satellites:
|
||||||
- NOAA-15: 137.620 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
|
|
||||||
- NOAA-18: 137.9125 MHz (APT) [DEFUNCT - decommissioned Jun 2025]
|
|
||||||
- NOAA-19: 137.100 MHz (APT) [DEFUNCT - decommissioned Aug 2025]
|
|
||||||
- Meteor-M2-3: 137.900 MHz (LRPT)
|
- Meteor-M2-3: 137.900 MHz (LRPT)
|
||||||
- Meteor-M2-4: 137.900 MHz (LRPT)
|
- Meteor-M2-4: 137.900 MHz (LRPT)
|
||||||
|
|
||||||
Uses SatDump CLI for live SDR capture and decoding, with fallback to
|
Legacy NOAA APT entries remain in ``WEATHER_SATELLITES`` for compatibility
|
||||||
rtl_fm capture for manual decoding when SatDump is unavailable.
|
and historical metadata, but they are no longer active operational targets.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -34,8 +31,15 @@ from utils.process import register_process, safe_terminate
|
|||||||
|
|
||||||
logger = get_logger('intercept.weather_sat')
|
logger = get_logger('intercept.weather_sat')
|
||||||
|
|
||||||
|
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
ALLOWED_OFFLINE_INPUT_DIRS = (
|
||||||
|
PROJECT_ROOT / 'data',
|
||||||
|
PROJECT_ROOT / 'instance' / 'ground_station' / 'recordings',
|
||||||
|
)
|
||||||
|
|
||||||
# Weather satellite definitions
|
|
||||||
|
# Weather satellite definitions.
|
||||||
|
# NOAA APT entries are retained as inactive compatibility metadata.
|
||||||
WEATHER_SATELLITES = {
|
WEATHER_SATELLITES = {
|
||||||
'NOAA-15': {
|
'NOAA-15': {
|
||||||
'name': 'NOAA 15',
|
'name': 'NOAA 15',
|
||||||
@@ -152,8 +156,8 @@ class CaptureProgress:
|
|||||||
class WeatherSatDecoder:
|
class WeatherSatDecoder:
|
||||||
"""Weather satellite decoder using SatDump CLI.
|
"""Weather satellite decoder using SatDump CLI.
|
||||||
|
|
||||||
Manages live SDR capture and decoding of NOAA APT and Meteor LRPT
|
Manages live SDR capture and offline decode for the active Meteor LRPT
|
||||||
satellite transmissions.
|
workflow, while preserving compatibility with older weather-sat metadata.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, output_dir: str | Path | None = None):
|
def __init__(self, output_dir: str | Path | None = None):
|
||||||
@@ -177,6 +181,8 @@ class WeatherSatDecoder:
|
|||||||
self._capture_output_dir: Path | None = None
|
self._capture_output_dir: Path | None = None
|
||||||
self._on_complete_callback: Callable[[], None] | None = None
|
self._on_complete_callback: Callable[[], None] | None = None
|
||||||
self._capture_phase: str = 'idle'
|
self._capture_phase: str = 'idle'
|
||||||
|
self._last_error_message: str = ''
|
||||||
|
self._last_process_returncode: int | None = None
|
||||||
|
|
||||||
# Ensure output directory exists
|
# Ensure output directory exists
|
||||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -245,7 +251,7 @@ class WeatherSatDecoder:
|
|||||||
No SDR hardware is required — SatDump runs in offline mode.
|
No SDR hardware is required — SatDump runs in offline mode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3')
|
satellite: Satellite key (for example ``'METEOR-M2-3'``)
|
||||||
input_file: Path to IQ baseband or WAV audio file
|
input_file: Path to IQ baseband or WAV audio file
|
||||||
sample_rate: Sample rate of the recording in Hz
|
sample_rate: Sample rate of the recording in Hz
|
||||||
|
|
||||||
@@ -277,13 +283,13 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
input_path = Path(input_file)
|
input_path = Path(input_file)
|
||||||
|
|
||||||
# Security: restrict to data directory
|
# Security: restrict offline decode inputs to application-owned
|
||||||
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
# capture directories so external paths cannot be injected.
|
||||||
try:
|
try:
|
||||||
resolved = input_path.resolve()
|
resolved = input_path.resolve()
|
||||||
if not resolved.is_relative_to(allowed_base):
|
if not any(resolved.is_relative_to(base) for base in ALLOWED_OFFLINE_INPUT_DIRS):
|
||||||
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
|
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
|
||||||
msg = 'Input file must be under the data/ directory'
|
msg = 'Input file must be under INTERCEPT data or ground-station recordings'
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
status='error',
|
status='error',
|
||||||
message=msg,
|
message=msg,
|
||||||
@@ -312,6 +318,8 @@ class WeatherSatDecoder:
|
|||||||
self._device_index = -1 # Offline decode does not claim an SDR device
|
self._device_index = -1 # Offline decode does not claim an SDR device
|
||||||
self._capture_start_time = time.time()
|
self._capture_start_time = time.time()
|
||||||
self._capture_phase = 'decoding'
|
self._capture_phase = 'decoding'
|
||||||
|
self._last_error_message = ''
|
||||||
|
self._last_process_returncode = None
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -360,7 +368,7 @@ class WeatherSatDecoder:
|
|||||||
"""Start weather satellite capture and decode.
|
"""Start weather satellite capture and decode.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3')
|
satellite: Satellite key (for example ``'METEOR-M2-3'``)
|
||||||
device_index: RTL-SDR device index
|
device_index: RTL-SDR device index
|
||||||
gain: SDR gain in dB
|
gain: SDR gain in dB
|
||||||
sample_rate: Sample rate in Hz
|
sample_rate: Sample rate in Hz
|
||||||
@@ -406,6 +414,8 @@ class WeatherSatDecoder:
|
|||||||
self._device_index = device_index
|
self._device_index = device_index
|
||||||
self._capture_start_time = time.time()
|
self._capture_start_time = time.time()
|
||||||
self._capture_phase = 'tuning'
|
self._capture_phase = 'tuning'
|
||||||
|
self._last_error_message = ''
|
||||||
|
self._last_process_returncode = None
|
||||||
self._stop_event.clear()
|
self._stop_event.clear()
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -887,6 +897,7 @@ class WeatherSatDecoder:
|
|||||||
process.kill()
|
process.kill()
|
||||||
process.wait()
|
process.wait()
|
||||||
retcode = process.returncode if process else None
|
retcode = process.returncode if process else None
|
||||||
|
self._last_process_returncode = retcode
|
||||||
if retcode and retcode != 0:
|
if retcode and retcode != 0:
|
||||||
self._capture_phase = 'error'
|
self._capture_phase = 'error'
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
@@ -1134,6 +1145,8 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
def _emit_progress(self, progress: CaptureProgress) -> None:
|
def _emit_progress(self, progress: CaptureProgress) -> None:
|
||||||
"""Emit progress update to callback."""
|
"""Emit progress update to callback."""
|
||||||
|
if progress.status == 'error' and progress.message:
|
||||||
|
self._last_error_message = str(progress.message)
|
||||||
if self._callback:
|
if self._callback:
|
||||||
try:
|
try:
|
||||||
self._callback(progress)
|
self._callback(progress)
|
||||||
@@ -1153,8 +1166,11 @@ class WeatherSatDecoder:
|
|||||||
'satellite': self._current_satellite,
|
'satellite': self._current_satellite,
|
||||||
'frequency': self._current_frequency,
|
'frequency': self._current_frequency,
|
||||||
'mode': self._current_mode,
|
'mode': self._current_mode,
|
||||||
|
'capture_phase': self._capture_phase,
|
||||||
'elapsed_seconds': elapsed,
|
'elapsed_seconds': elapsed,
|
||||||
'image_count': len(self._images),
|
'image_count': len(self._images),
|
||||||
|
'last_error': self._last_error_message,
|
||||||
|
'last_returncode': self._last_process_returncode,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
+170
-160
@@ -1,38 +1,45 @@
|
|||||||
"""Weather satellite pass prediction utility.
|
"""Weather satellite pass prediction utility.
|
||||||
|
|
||||||
Shared prediction logic used by both the API endpoint and the auto-scheduler.
|
Self-contained pass prediction for NOAA/Meteor weather satellites. Uses
|
||||||
|
Skyfield's find_discrete() for AOS/LOS detection, then enriches results
|
||||||
|
with weather-satellite-specific metadata (name, frequency, mode, quality).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import time
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from skyfield.api import EarthSatellite, load, wgs84
|
||||||
|
from skyfield.searchlib import find_discrete
|
||||||
|
|
||||||
|
from data.satellites import TLE_SATELLITES
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.weather_sat import WEATHER_SATELLITES
|
from utils.weather_sat import WEATHER_SATELLITES
|
||||||
|
|
||||||
logger = get_logger('intercept.weather_sat_predict')
|
logger = get_logger('intercept.weather_sat_predict')
|
||||||
|
|
||||||
# Cache skyfield timescale to avoid re-downloading/re-parsing per request
|
# Live TLE cache — populated by routes/satellite.py at startup.
|
||||||
_cached_timescale = None
|
# Module-level so tests can patch it with patch('utils.weather_sat_predict._tle_cache', ...).
|
||||||
|
_tle_cache: dict = {}
|
||||||
|
|
||||||
def _get_timescale():
|
|
||||||
global _cached_timescale
|
|
||||||
if _cached_timescale is None:
|
|
||||||
from skyfield.api import load
|
|
||||||
_cached_timescale = load.timescale()
|
|
||||||
return _cached_timescale
|
|
||||||
|
|
||||||
|
|
||||||
def _format_utc_iso(dt: datetime.datetime) -> str:
|
def _format_utc_iso(dt: datetime.datetime) -> str:
|
||||||
"""Return an ISO8601 UTC timestamp with a single timezone designator."""
|
"""Format a datetime as a UTC ISO 8601 string ending with 'Z'.
|
||||||
if dt.tzinfo is None:
|
|
||||||
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
Handles both aware (UTC) and naive (assumed UTC) datetimes, producing a
|
||||||
else:
|
consistent ``YYYY-MM-DDTHH:MM:SSZ`` string without ``+00:00`` suffixes.
|
||||||
dt = dt.astimezone(datetime.timezone.utc)
|
"""
|
||||||
return dt.isoformat().replace('+00:00', 'Z')
|
if dt.tzinfo is not None:
|
||||||
|
dt = dt.astimezone(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
|
return dt.strftime('%Y-%m-%dT%H:%M:%SZ')
|
||||||
|
|
||||||
|
|
||||||
|
def _get_tle_source() -> dict:
|
||||||
|
"""Return the best available TLE source (live cache preferred over static data)."""
|
||||||
|
if _tle_cache:
|
||||||
|
return _tle_cache
|
||||||
|
return TLE_SATELLITES
|
||||||
|
|
||||||
|
|
||||||
def predict_passes(
|
def predict_passes(
|
||||||
@@ -49,170 +56,173 @@ def predict_passes(
|
|||||||
lat: Observer latitude (-90 to 90)
|
lat: Observer latitude (-90 to 90)
|
||||||
lon: Observer longitude (-180 to 180)
|
lon: Observer longitude (-180 to 180)
|
||||||
hours: Hours ahead to predict (1-72)
|
hours: Hours ahead to predict (1-72)
|
||||||
min_elevation: Minimum max elevation in degrees (0-90)
|
min_elevation: Minimum peak elevation in degrees (0-90)
|
||||||
include_trajectory: Include az/el trajectory points (30 points)
|
include_trajectory: Include 30-point az/el trajectory for polar plot
|
||||||
include_ground_track: Include lat/lon ground track points (60 points)
|
include_ground_track: Include 60-point lat/lon ground track for map
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
List of pass dicts sorted by start time.
|
List of pass dicts sorted by start time, each containing:
|
||||||
|
id, satellite, name, frequency, mode, startTime, startTimeISO,
|
||||||
Raises:
|
endTimeISO, maxEl, maxElAz, riseAz, setAz, duration, quality,
|
||||||
ImportError: If skyfield is not installed.
|
and optionally trajectory/groundTrack.
|
||||||
"""
|
"""
|
||||||
from skyfield.almanac import find_discrete
|
# Raise ImportError early if skyfield has been disabled (e.g., in tests that
|
||||||
from skyfield.api import EarthSatellite, wgs84
|
# patch sys.modules to simulate skyfield being unavailable).
|
||||||
|
import skyfield # noqa: F401
|
||||||
|
|
||||||
from data.satellites import TLE_SATELLITES
|
ts = load.timescale(builtin=True)
|
||||||
|
|
||||||
# Use live TLE cache from satellite module if available (refreshed from CelesTrak).
|
|
||||||
# Cache the reference locally so repeated calls don't re-import each time.
|
|
||||||
tle_source = TLE_SATELLITES
|
|
||||||
if not hasattr(predict_passes, '_tle_ref') or \
|
|
||||||
(time.time() - getattr(predict_passes, '_tle_ref_ts', 0)) > 3600:
|
|
||||||
try:
|
|
||||||
from routes.satellite import _tle_cache
|
|
||||||
if _tle_cache:
|
|
||||||
predict_passes._tle_ref = _tle_cache
|
|
||||||
predict_passes._tle_ref_ts = time.time()
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
if hasattr(predict_passes, '_tle_ref') and predict_passes._tle_ref:
|
|
||||||
tle_source = predict_passes._tle_ref
|
|
||||||
|
|
||||||
ts = _get_timescale()
|
|
||||||
observer = wgs84.latlon(lat, lon)
|
observer = wgs84.latlon(lat, lon)
|
||||||
t0 = ts.now()
|
t0 = ts.now()
|
||||||
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
|
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
|
||||||
|
|
||||||
|
tle_source = _get_tle_source()
|
||||||
all_passes: list[dict[str, Any]] = []
|
all_passes: list[dict[str, Any]] = []
|
||||||
|
|
||||||
for sat_key, sat_info in WEATHER_SATELLITES.items():
|
for sat_key, sat_info in WEATHER_SATELLITES.items():
|
||||||
if not sat_info['active']:
|
if not sat_info['active']:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
tle_data = tle_source.get(sat_info['tle_key'])
|
|
||||||
if not tle_data:
|
|
||||||
continue
|
|
||||||
|
|
||||||
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
|
||||||
|
|
||||||
def above_horizon(t, _sat=satellite):
|
|
||||||
diff = _sat - observer
|
|
||||||
topocentric = diff.at(t)
|
|
||||||
alt, _, _ = topocentric.altaz()
|
|
||||||
return alt.degrees > 0
|
|
||||||
|
|
||||||
above_horizon.step_days = 1 / 720
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
times, events = find_discrete(t0, t1, above_horizon)
|
tle_data = tle_source.get(sat_info['tle_key'])
|
||||||
except Exception:
|
if not tle_data:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
i = 0
|
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
|
||||||
while i < len(times):
|
diff = satellite - observer
|
||||||
if i < len(events) and events[i]: # Rising
|
|
||||||
rise_time = times[i]
|
|
||||||
set_time = None
|
|
||||||
|
|
||||||
for j in range(i + 1, len(times)):
|
def above_horizon(t, _diff=diff, _el=min_elevation):
|
||||||
if not events[j]: # Setting
|
alt, _, _ = _diff.at(t).altaz()
|
||||||
set_time = times[j]
|
return alt.degrees > _el
|
||||||
i = j
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
if set_time is None:
|
above_horizon.rough_period = 0.5 # Approximate orbital period in days
|
||||||
i += 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
rise_dt = rise_time.utc_datetime()
|
times, is_rising = find_discrete(t0, t1, above_horizon)
|
||||||
set_dt = set_time.utc_datetime()
|
|
||||||
duration_seconds = (
|
|
||||||
set_dt - rise_dt
|
|
||||||
).total_seconds()
|
|
||||||
duration_minutes = round(duration_seconds / 60, 1)
|
|
||||||
|
|
||||||
# Calculate max elevation (always) and trajectory points (only if requested)
|
rise_t = None
|
||||||
max_el = 0.0
|
for t, rising in zip(times, is_rising):
|
||||||
max_el_az = 0.0
|
if rising:
|
||||||
trajectory: list[dict[str, float]] = []
|
rise_t = t
|
||||||
num_traj_points = 30
|
elif rise_t is not None:
|
||||||
|
_process_pass(
|
||||||
for k in range(num_traj_points):
|
sat_key, sat_info, satellite, diff, ts,
|
||||||
frac = k / (num_traj_points - 1)
|
rise_t, t, min_elevation,
|
||||||
t_point = ts.utc(
|
include_trajectory, include_ground_track,
|
||||||
rise_time.utc_datetime()
|
all_passes,
|
||||||
+ datetime.timedelta(seconds=duration_seconds * frac)
|
|
||||||
)
|
)
|
||||||
diff = satellite - observer
|
rise_t = None
|
||||||
topocentric = diff.at(t_point)
|
|
||||||
alt, az, _ = topocentric.altaz()
|
|
||||||
if alt.degrees > max_el:
|
|
||||||
max_el = alt.degrees
|
|
||||||
max_el_az = az.degrees
|
|
||||||
if include_trajectory:
|
|
||||||
trajectory.append({
|
|
||||||
'el': float(max(0, alt.degrees)),
|
|
||||||
'az': float(az.degrees),
|
|
||||||
})
|
|
||||||
|
|
||||||
if max_el < min_elevation:
|
except Exception as exc:
|
||||||
i += 1
|
logger.debug('Error predicting passes for %s: %s', sat_key, exc)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Rise/set azimuths
|
|
||||||
rise_topo = (satellite - observer).at(rise_time)
|
|
||||||
_, rise_az, _ = rise_topo.altaz()
|
|
||||||
|
|
||||||
set_topo = (satellite - observer).at(set_time)
|
|
||||||
_, set_az, _ = set_topo.altaz()
|
|
||||||
|
|
||||||
pass_data: dict[str, Any] = {
|
|
||||||
'id': f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}",
|
|
||||||
'satellite': sat_key,
|
|
||||||
'name': sat_info['name'],
|
|
||||||
'frequency': sat_info['frequency'],
|
|
||||||
'mode': sat_info['mode'],
|
|
||||||
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
|
|
||||||
'startTimeISO': _format_utc_iso(rise_dt),
|
|
||||||
'endTimeISO': _format_utc_iso(set_dt),
|
|
||||||
'maxEl': round(max_el, 1),
|
|
||||||
'maxElAz': round(max_el_az, 1),
|
|
||||||
'riseAz': round(rise_az.degrees, 1),
|
|
||||||
'setAz': round(set_az.degrees, 1),
|
|
||||||
'duration': duration_minutes,
|
|
||||||
'quality': (
|
|
||||||
'excellent' if max_el >= 60
|
|
||||||
else 'good' if max_el >= 30
|
|
||||||
else 'fair'
|
|
||||||
),
|
|
||||||
}
|
|
||||||
|
|
||||||
if include_trajectory:
|
|
||||||
pass_data['trajectory'] = trajectory
|
|
||||||
|
|
||||||
if include_ground_track:
|
|
||||||
ground_track: list[dict[str, float]] = []
|
|
||||||
for k in range(60):
|
|
||||||
frac = k / 59
|
|
||||||
t_point = ts.utc(
|
|
||||||
rise_time.utc_datetime()
|
|
||||||
+ datetime.timedelta(seconds=duration_seconds * frac)
|
|
||||||
)
|
|
||||||
geocentric = satellite.at(t_point)
|
|
||||||
subpoint = wgs84.subpoint(geocentric)
|
|
||||||
ground_track.append({
|
|
||||||
'lat': float(subpoint.latitude.degrees),
|
|
||||||
'lon': float(subpoint.longitude.degrees),
|
|
||||||
})
|
|
||||||
pass_data['groundTrack'] = ground_track
|
|
||||||
|
|
||||||
all_passes.append(pass_data)
|
|
||||||
|
|
||||||
i += 1
|
|
||||||
|
|
||||||
all_passes.sort(key=lambda p: p['startTimeISO'])
|
all_passes.sort(key=lambda p: p['startTimeISO'])
|
||||||
return all_passes
|
return all_passes
|
||||||
|
|
||||||
|
|
||||||
|
def _process_pass(
|
||||||
|
sat_key: str,
|
||||||
|
sat_info: dict,
|
||||||
|
satellite,
|
||||||
|
diff,
|
||||||
|
ts,
|
||||||
|
rise_t,
|
||||||
|
set_t,
|
||||||
|
min_elevation: float,
|
||||||
|
include_trajectory: bool,
|
||||||
|
include_ground_track: bool,
|
||||||
|
all_passes: list,
|
||||||
|
) -> None:
|
||||||
|
"""Sample a rise/set interval, build the pass dict, append to all_passes."""
|
||||||
|
rise_dt = rise_t.utc_datetime()
|
||||||
|
set_dt = set_t.utc_datetime()
|
||||||
|
duration_secs = (set_dt - rise_dt).total_seconds()
|
||||||
|
|
||||||
|
# Sample 30 points across the pass to find max elevation and trajectory
|
||||||
|
N_TRAJ = 30
|
||||||
|
max_el = 0.0
|
||||||
|
max_el_az = 0.0
|
||||||
|
traj_points = []
|
||||||
|
|
||||||
|
for i in range(N_TRAJ):
|
||||||
|
frac = i / (N_TRAJ - 1) if N_TRAJ > 1 else 0.0
|
||||||
|
t_pt = ts.tt_jd(rise_t.tt + frac * (set_t.tt - rise_t.tt))
|
||||||
|
try:
|
||||||
|
topo = diff.at(t_pt)
|
||||||
|
alt, az, _ = topo.altaz()
|
||||||
|
el = float(alt.degrees)
|
||||||
|
az_deg = float(az.degrees)
|
||||||
|
if el > max_el:
|
||||||
|
max_el = el
|
||||||
|
max_el_az = az_deg
|
||||||
|
if include_trajectory:
|
||||||
|
traj_points.append({'az': round(az_deg, 1), 'el': round(max(0.0, el), 1)})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Filter passes that never reach min_elevation
|
||||||
|
if max_el < min_elevation:
|
||||||
|
return
|
||||||
|
|
||||||
|
# AOS and LOS azimuths
|
||||||
|
try:
|
||||||
|
rise_az = float(diff.at(rise_t).altaz()[1].degrees)
|
||||||
|
except Exception:
|
||||||
|
rise_az = 0.0
|
||||||
|
|
||||||
|
try:
|
||||||
|
set_az = float(diff.at(set_t).altaz()[1].degrees)
|
||||||
|
except Exception:
|
||||||
|
set_az = 0.0
|
||||||
|
|
||||||
|
aos_iso = _format_utc_iso(rise_dt)
|
||||||
|
try:
|
||||||
|
pass_id = f"{sat_key}_{rise_dt.strftime('%Y%m%d%H%M%S')}"
|
||||||
|
except Exception:
|
||||||
|
pass_id = f"{sat_key}_{aos_iso}"
|
||||||
|
|
||||||
|
pass_dict: dict[str, Any] = {
|
||||||
|
'id': pass_id,
|
||||||
|
'satellite': sat_key,
|
||||||
|
'name': sat_info['name'],
|
||||||
|
'frequency': sat_info['frequency'],
|
||||||
|
'mode': sat_info['mode'],
|
||||||
|
'startTime': rise_dt.strftime('%Y-%m-%d %H:%M UTC'),
|
||||||
|
'startTimeISO': aos_iso,
|
||||||
|
'endTimeISO': _format_utc_iso(set_dt),
|
||||||
|
'maxEl': round(max_el, 1),
|
||||||
|
'maxElAz': round(max_el_az, 1),
|
||||||
|
'riseAz': round(rise_az, 1),
|
||||||
|
'setAz': round(set_az, 1),
|
||||||
|
'duration': round(duration_secs, 1),
|
||||||
|
'quality': (
|
||||||
|
'excellent' if max_el >= 60
|
||||||
|
else 'good' if max_el >= 30
|
||||||
|
else 'fair'
|
||||||
|
),
|
||||||
|
# Backwards-compatible aliases used by weather_sat_scheduler and the frontend
|
||||||
|
'aosAz': round(rise_az, 1),
|
||||||
|
'losAz': round(set_az, 1),
|
||||||
|
'tcaAz': round(max_el_az, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
if include_trajectory:
|
||||||
|
pass_dict['trajectory'] = traj_points
|
||||||
|
|
||||||
|
if include_ground_track:
|
||||||
|
ground_track = []
|
||||||
|
N_TRACK = 60
|
||||||
|
for i in range(N_TRACK):
|
||||||
|
frac = i / (N_TRACK - 1) if N_TRACK > 1 else 0.0
|
||||||
|
t_pt = ts.tt_jd(rise_t.tt + frac * (set_t.tt - rise_t.tt))
|
||||||
|
try:
|
||||||
|
geocentric = satellite.at(t_pt)
|
||||||
|
subpoint = wgs84.subpoint(geocentric)
|
||||||
|
ground_track.append({
|
||||||
|
'lat': round(float(subpoint.latitude.degrees), 4),
|
||||||
|
'lon': round(float(subpoint.longitude.degrees), 4),
|
||||||
|
})
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
pass_dict['groundTrack'] = ground_track
|
||||||
|
|
||||||
|
all_passes.append(pass_dict)
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ from .constants import (
|
|||||||
SCAN_MODE_QUICK,
|
SCAN_MODE_QUICK,
|
||||||
TOOL_TIMEOUT_DETECT,
|
TOOL_TIMEOUT_DETECT,
|
||||||
WIFI_EMA_ALPHA,
|
WIFI_EMA_ALPHA,
|
||||||
|
get_band_from_channel,
|
||||||
get_proximity_band,
|
get_proximity_band,
|
||||||
get_signal_band,
|
get_signal_band,
|
||||||
get_vendor_from_mac,
|
get_vendor_from_mac,
|
||||||
@@ -821,6 +822,8 @@ class UnifiedWiFiScanner:
|
|||||||
cmd.extend(['--band', 'bg'])
|
cmd.extend(['--band', 'bg'])
|
||||||
elif band == '5':
|
elif band == '5':
|
||||||
cmd.extend(['--band', 'a'])
|
cmd.extend(['--band', 'a'])
|
||||||
|
else:
|
||||||
|
cmd.extend(['--band', 'abg'])
|
||||||
|
|
||||||
cmd.append(interface)
|
cmd.append(interface)
|
||||||
|
|
||||||
@@ -958,6 +961,12 @@ class UnifiedWiFiScanner:
|
|||||||
ap.last_seen = now
|
ap.last_seen = now
|
||||||
ap.seen_count += 1
|
ap.seen_count += 1
|
||||||
|
|
||||||
|
# Update channel/band if now known (airodump-ng may report -1 or 0 before resolving)
|
||||||
|
if obs.channel and not ap.channel:
|
||||||
|
ap.channel = obs.channel
|
||||||
|
ap.frequency_mhz = obs.frequency_mhz
|
||||||
|
ap.band = get_band_from_channel(obs.channel)
|
||||||
|
|
||||||
# Update ESSID if revealed
|
# Update ESSID if revealed
|
||||||
if obs.essid and ap.is_hidden:
|
if obs.essid and ap.is_hidden:
|
||||||
ap.revealed_essid = obs.essid
|
ap.revealed_essid = obs.essid
|
||||||
|
|||||||
Reference in New Issue
Block a user