mirror of
https://github.com/smittix/intercept.git
synced 2026-06-15 09:03:38 -07:00
Compare commits
118 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 |
@@ -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,13 @@
|
|||||||
|
|
||||||
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
|
## [2.26.10] - 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,15 +7,22 @@ import os
|
|||||||
import sys
|
import sys
|
||||||
|
|
||||||
# Application version
|
# Application version
|
||||||
VERSION = "2.26.10"
|
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.10",
|
"version": "2.26.12",
|
||||||
"date": "March 2026",
|
"date": "March 2026",
|
||||||
"highlights": [
|
"highlights": [
|
||||||
"Fix APRS stop timeout and inverted SDR device status",
|
"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",
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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.10"
|
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,
|
||||||
)
|
)
|
||||||
|
|||||||
+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
|
||||||
@@ -1720,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."
|
||||||
@@ -2012,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"
|
||||||
|
|||||||
+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()
|
||||||
+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