mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Add weather satellite decoder for NOAA APT and Meteor LRPT
New module for receiving and decoding weather satellite images using SatDump CLI. Supports NOAA-15/18/19 (APT) and Meteor-M2-3 (LRPT) with live SDR capture, pass prediction, and image gallery. Backend: - utils/weather_sat.py: SatDump process manager with image watcher - routes/weather_sat.py: API endpoints (start/stop/images/passes/stream) - SSE streaming for real-time capture progress - Pass prediction using existing skyfield + TLE data - SDR device registry integration (prevents conflicts) Frontend: - Sidebar panel with satellite selector and antenna build guide (V-dipole and QFH instructions for 137 MHz reception) - Stats strip with status, frequency, mode, location inputs - Split-panel layout: upcoming passes list + decoded image gallery - Full-size image modal viewer - SSE-driven progress updates during capture Infrastructure: - Dockerfile: Add SatDump build from source (headless CLI mode) with runtime deps (libpng, libtiff, libjemalloc, libvolk2, libnng) - Config: WEATHER_SAT_GAIN, SAMPLE_RATE, MIN_ELEVATION, PREDICTION_HOURS - Nav: Weather Sat entry in Space group (desktop + mobile) https://claude.ai/code/session_01FjLTkyELaqh27U1wEXngFQ
This commit is contained in:
32
Dockerfile
32
Dockerfile
@@ -23,6 +23,13 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
ffmpeg \
|
ffmpeg \
|
||||||
# SSTV decoder runtime libs
|
# SSTV decoder runtime libs
|
||||||
libsndfile1 \
|
libsndfile1 \
|
||||||
|
# SatDump runtime libs (weather satellite decoding)
|
||||||
|
libpng16-16 \
|
||||||
|
libtiff6 \
|
||||||
|
libjemalloc2 \
|
||||||
|
libvolk2-bin \
|
||||||
|
libnng1 \
|
||||||
|
libzstd1 \
|
||||||
# WiFi tools (aircrack-ng suite)
|
# WiFi tools (aircrack-ng suite)
|
||||||
aircrack-ng \
|
aircrack-ng \
|
||||||
iw \
|
iw \
|
||||||
@@ -64,6 +71,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libhackrf-dev \
|
libhackrf-dev \
|
||||||
liblimesuite-dev \
|
liblimesuite-dev \
|
||||||
libfftw3-dev \
|
libfftw3-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk2-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
libsqlite3-dev \
|
libsqlite3-dev \
|
||||||
libcurl4-openssl-dev \
|
libcurl4-openssl-dev \
|
||||||
zlib1g-dev \
|
zlib1g-dev \
|
||||||
@@ -121,6 +134,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& make \
|
&& make \
|
||||||
&& install -m 0755 slowrx /usr/local/bin/slowrx \
|
&& install -m 0755 slowrx /usr/local/bin/slowrx \
|
||||||
&& rm -rf /tmp/slowrx \
|
&& rm -rf /tmp/slowrx \
|
||||||
|
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT)
|
||||||
|
&& cd /tmp \
|
||||||
|
&& git clone --depth 1 https://github.com/SatDump/SatDump.git \
|
||||||
|
&& cd SatDump \
|
||||||
|
&& mkdir build && cd build \
|
||||||
|
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF .. \
|
||||||
|
&& make -j$(nproc) \
|
||||||
|
&& make install \
|
||||||
|
&& ldconfig \
|
||||||
|
&& cd /tmp \
|
||||||
|
&& rm -rf /tmp/SatDump \
|
||||||
# Cleanup build tools to reduce image size
|
# Cleanup build tools to reduce image size
|
||||||
&& apt-get remove -y \
|
&& apt-get remove -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
@@ -130,6 +154,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
libncurses-dev \
|
libncurses-dev \
|
||||||
libsndfile1-dev \
|
libsndfile1-dev \
|
||||||
libasound2-dev \
|
libasound2-dev \
|
||||||
|
libpng-dev \
|
||||||
|
libtiff-dev \
|
||||||
|
libjemalloc-dev \
|
||||||
|
libvolk2-dev \
|
||||||
|
libnng-dev \
|
||||||
|
libzstd-dev \
|
||||||
libsoapysdr-dev \
|
libsoapysdr-dev \
|
||||||
libhackrf-dev \
|
libhackrf-dev \
|
||||||
liblimesuite-dev \
|
liblimesuite-dev \
|
||||||
@@ -148,7 +178,7 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
# Create data directory for persistence
|
# Create data directory for persistence
|
||||||
RUN mkdir -p /app/data
|
RUN mkdir -p /app/data /app/data/weather_sat
|
||||||
|
|
||||||
# Expose web interface port
|
# Expose web interface port
|
||||||
EXPOSE 5050
|
EXPOSE 5050
|
||||||
|
|||||||
6
app.py
6
app.py
@@ -176,6 +176,10 @@ 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()
|
||||||
|
|
||||||
|
# Weather Satellite (NOAA/Meteor)
|
||||||
|
weather_sat_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
|
weather_sat_lock = threading.Lock()
|
||||||
|
|
||||||
# Deauth Attack Detection
|
# Deauth Attack Detection
|
||||||
deauth_detector = None
|
deauth_detector = None
|
||||||
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
deauth_detector_queue = queue.Queue(maxsize=QUEUE_MAX_SIZE)
|
||||||
@@ -663,7 +667,7 @@ def kill_all() -> Response:
|
|||||||
'rtl_fm', 'multimon-ng', 'rtl_433',
|
'rtl_fm', 'multimon-ng', 'rtl_433',
|
||||||
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
'airodump-ng', 'aireplay-ng', 'airmon-ng',
|
||||||
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
'dump1090', 'acarsdec', 'direwolf', 'AIS-catcher',
|
||||||
'hcitool', 'bluetoothctl'
|
'hcitool', 'bluetoothctl', 'satdump'
|
||||||
]
|
]
|
||||||
|
|
||||||
for proc in processes_to_kill:
|
for proc in processes_to_kill:
|
||||||
|
|||||||
@@ -191,6 +191,12 @@ SATELLITE_UPDATE_INTERVAL = _get_env_int('SATELLITE_UPDATE_INTERVAL', 30)
|
|||||||
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
SATELLITE_TRAJECTORY_POINTS = _get_env_int('SATELLITE_TRAJECTORY_POINTS', 30)
|
||||||
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
SATELLITE_ORBIT_MINUTES = _get_env_int('SATELLITE_ORBIT_MINUTES', 45)
|
||||||
|
|
||||||
|
# Weather satellite settings
|
||||||
|
WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
|
||||||
|
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
|
||||||
|
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
|
||||||
|
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
|
||||||
|
|
||||||
# Update checking
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
UPDATE_CHECK_ENABLED = _get_env_bool('UPDATE_CHECK_ENABLED', True)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ def register_blueprints(app):
|
|||||||
from .offline import offline_bp
|
from .offline import offline_bp
|
||||||
from .updater import updater_bp
|
from .updater import updater_bp
|
||||||
from .sstv import sstv_bp
|
from .sstv import sstv_bp
|
||||||
|
from .weather_sat import weather_sat_bp
|
||||||
|
|
||||||
app.register_blueprint(pager_bp)
|
app.register_blueprint(pager_bp)
|
||||||
app.register_blueprint(sensor_bp)
|
app.register_blueprint(sensor_bp)
|
||||||
@@ -51,6 +52,7 @@ def register_blueprints(app):
|
|||||||
app.register_blueprint(offline_bp) # Offline mode settings
|
app.register_blueprint(offline_bp) # Offline mode settings
|
||||||
app.register_blueprint(updater_bp) # GitHub update checking
|
app.register_blueprint(updater_bp) # GitHub update checking
|
||||||
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
app.register_blueprint(sstv_bp) # ISS SSTV decoder
|
||||||
|
app.register_blueprint(weather_sat_bp) # NOAA/Meteor weather satellite decoder
|
||||||
|
|
||||||
# Initialize TSCM state with queue and lock from app
|
# Initialize TSCM state with queue and lock from app
|
||||||
import app as app_module
|
import app as app_module
|
||||||
|
|||||||
494
routes/weather_sat.py
Normal file
494
routes/weather_sat.py
Normal file
@@ -0,0 +1,494 @@
|
|||||||
|
"""Weather Satellite decoder routes.
|
||||||
|
|
||||||
|
Provides endpoints for capturing and decoding weather satellite images
|
||||||
|
from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import queue
|
||||||
|
import time
|
||||||
|
from typing import Generator
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
from utils.sse import format_sse
|
||||||
|
from utils.weather_sat import (
|
||||||
|
get_weather_sat_decoder,
|
||||||
|
is_weather_sat_available,
|
||||||
|
CaptureProgress,
|
||||||
|
WEATHER_SATELLITES,
|
||||||
|
)
|
||||||
|
|
||||||
|
logger = get_logger('intercept.weather_sat')
|
||||||
|
|
||||||
|
weather_sat_bp = Blueprint('weather_sat', __name__, url_prefix='/weather-sat')
|
||||||
|
|
||||||
|
# Queue for SSE progress streaming
|
||||||
|
_weather_sat_queue: queue.Queue = queue.Queue(maxsize=100)
|
||||||
|
|
||||||
|
|
||||||
|
def _progress_callback(progress: CaptureProgress) -> None:
|
||||||
|
"""Callback to queue progress updates for SSE stream."""
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Full:
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
_weather_sat_queue.put_nowait(progress.to_dict())
|
||||||
|
except queue.Empty:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/status')
|
||||||
|
def get_status():
|
||||||
|
"""Get weather satellite decoder status.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with decoder availability and current status.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
return jsonify(decoder.get_status())
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/satellites')
|
||||||
|
def list_satellites():
|
||||||
|
"""Get list of supported weather satellites with frequencies.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with satellite definitions.
|
||||||
|
"""
|
||||||
|
satellites = []
|
||||||
|
for key, info in WEATHER_SATELLITES.items():
|
||||||
|
satellites.append({
|
||||||
|
'key': key,
|
||||||
|
'name': info['name'],
|
||||||
|
'frequency': info['frequency'],
|
||||||
|
'mode': info['mode'],
|
||||||
|
'description': info['description'],
|
||||||
|
'active': info['active'],
|
||||||
|
})
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'satellites': satellites,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/start', methods=['POST'])
|
||||||
|
def start_capture():
|
||||||
|
"""Start weather satellite capture and decode.
|
||||||
|
|
||||||
|
JSON body:
|
||||||
|
{
|
||||||
|
"satellite": "NOAA-18", // Required: satellite key
|
||||||
|
"device": 0, // RTL-SDR device index (default: 0)
|
||||||
|
"gain": 40.0, // SDR gain in dB (default: 40)
|
||||||
|
"bias_t": false // Enable bias-T for LNA (default: false)
|
||||||
|
}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with start status.
|
||||||
|
"""
|
||||||
|
if not is_weather_sat_available():
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'SatDump not installed. Build from source: https://github.com/SatDump/SatDump'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if decoder.is_running:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'already_running',
|
||||||
|
'satellite': decoder.current_satellite,
|
||||||
|
'frequency': decoder.current_frequency,
|
||||||
|
})
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
|
# Validate satellite
|
||||||
|
satellite = data.get('satellite')
|
||||||
|
if not satellite or satellite not in WEATHER_SATELLITES:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate device index
|
||||||
|
device_index = data.get('device', 0)
|
||||||
|
try:
|
||||||
|
device_index = int(device_index)
|
||||||
|
if not (0 <= device_index <= 255):
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid device index (0-255)'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
# Validate gain
|
||||||
|
gain = data.get('gain', 40.0)
|
||||||
|
try:
|
||||||
|
gain = float(gain)
|
||||||
|
if not (0 <= gain <= 50):
|
||||||
|
raise ValueError
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Invalid gain (0-50 dB)'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
bias_t = bool(data.get('bias_t', False))
|
||||||
|
|
||||||
|
# Claim SDR device
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
error = app_module.claim_sdr_device(device_index, 'weather_sat')
|
||||||
|
if error:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'error_type': 'DEVICE_BUSY',
|
||||||
|
'message': error,
|
||||||
|
}), 409
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Clear queue
|
||||||
|
while not _weather_sat_queue.empty():
|
||||||
|
try:
|
||||||
|
_weather_sat_queue.get_nowait()
|
||||||
|
except queue.Empty:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Set callback and start
|
||||||
|
decoder.set_callback(_progress_callback)
|
||||||
|
success = decoder.start(
|
||||||
|
satellite=satellite,
|
||||||
|
device_index=device_index,
|
||||||
|
gain=gain,
|
||||||
|
bias_t=bias_t,
|
||||||
|
)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
sat_info = WEATHER_SATELLITES[satellite]
|
||||||
|
return jsonify({
|
||||||
|
'status': 'started',
|
||||||
|
'satellite': satellite,
|
||||||
|
'frequency': sat_info['frequency'],
|
||||||
|
'mode': sat_info['mode'],
|
||||||
|
'device': device_index,
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
# Release device on failure
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'Failed to start capture'
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/stop', methods=['POST'])
|
||||||
|
def stop_capture():
|
||||||
|
"""Stop weather satellite capture.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
device_index = decoder._device_index
|
||||||
|
|
||||||
|
decoder.stop()
|
||||||
|
|
||||||
|
# Release SDR device
|
||||||
|
try:
|
||||||
|
import app as app_module
|
||||||
|
app_module.release_sdr_device(device_index)
|
||||||
|
except ImportError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({'status': 'stopped'})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images')
|
||||||
|
def list_images():
|
||||||
|
"""Get list of decoded weather satellite images.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
limit: Maximum number of images (default: all)
|
||||||
|
satellite: Filter by satellite key (optional)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with list of decoded images.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
images = decoder.get_images()
|
||||||
|
|
||||||
|
# Filter by satellite if specified
|
||||||
|
satellite_filter = request.args.get('satellite')
|
||||||
|
if satellite_filter:
|
||||||
|
images = [img for img in images if img.satellite == satellite_filter]
|
||||||
|
|
||||||
|
# Apply limit
|
||||||
|
limit = request.args.get('limit', type=int)
|
||||||
|
if limit and limit > 0:
|
||||||
|
images = images[-limit:]
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'images': [img.to_dict() for img in images],
|
||||||
|
'count': len(images),
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images/<filename>')
|
||||||
|
def get_image(filename: str):
|
||||||
|
"""Serve a decoded weather satellite image file.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Image file or 404.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
# Security: only allow safe filenames
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if not (filename.endswith('.png') or filename.endswith('.jpg') or filename.endswith('.jpeg')):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Only PNG/JPG files supported'}), 400
|
||||||
|
|
||||||
|
image_path = decoder._output_dir / filename
|
||||||
|
|
||||||
|
if not image_path.exists():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
mimetype = 'image/png' if filename.endswith('.png') else 'image/jpeg'
|
||||||
|
return send_file(image_path, mimetype=mimetype)
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/images/<filename>', methods=['DELETE'])
|
||||||
|
def delete_image(filename: str):
|
||||||
|
"""Delete a decoded image.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
filename: Image filename
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON confirmation.
|
||||||
|
"""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
|
||||||
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid filename'}), 400
|
||||||
|
|
||||||
|
if decoder.delete_image(filename):
|
||||||
|
return jsonify({'status': 'deleted', 'filename': filename})
|
||||||
|
else:
|
||||||
|
return jsonify({'status': 'error', 'message': 'Image not found'}), 404
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/stream')
|
||||||
|
def stream_progress():
|
||||||
|
"""SSE stream of capture/decode progress.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
SSE stream (text/event-stream)
|
||||||
|
"""
|
||||||
|
def generate() -> Generator[str, None, None]:
|
||||||
|
last_keepalive = time.time()
|
||||||
|
keepalive_interval = 30.0
|
||||||
|
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
progress = _weather_sat_queue.get(timeout=1)
|
||||||
|
last_keepalive = time.time()
|
||||||
|
yield format_sse(progress)
|
||||||
|
except queue.Empty:
|
||||||
|
now = time.time()
|
||||||
|
if now - last_keepalive >= keepalive_interval:
|
||||||
|
yield format_sse({'type': 'keepalive'})
|
||||||
|
last_keepalive = now
|
||||||
|
|
||||||
|
response = Response(generate(), mimetype='text/event-stream')
|
||||||
|
response.headers['Cache-Control'] = 'no-cache'
|
||||||
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
|
response.headers['Connection'] = 'keep-alive'
|
||||||
|
return response
|
||||||
|
|
||||||
|
|
||||||
|
@weather_sat_bp.route('/passes')
|
||||||
|
def get_passes():
|
||||||
|
"""Get upcoming weather satellite passes for observer location.
|
||||||
|
|
||||||
|
Query parameters:
|
||||||
|
latitude: Observer latitude (required)
|
||||||
|
longitude: Observer longitude (required)
|
||||||
|
hours: Hours to predict ahead (default: 24, max: 72)
|
||||||
|
min_elevation: Minimum elevation in degrees (default: 15)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
JSON with upcoming passes for all weather satellites.
|
||||||
|
"""
|
||||||
|
lat = request.args.get('latitude', type=float)
|
||||||
|
lon = request.args.get('longitude', type=float)
|
||||||
|
hours = request.args.get('hours', 24, type=int)
|
||||||
|
min_elevation = request.args.get('min_elevation', 15, type=float)
|
||||||
|
|
||||||
|
if lat is None or lon is None:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'latitude and longitude parameters required'
|
||||||
|
}), 400
|
||||||
|
|
||||||
|
if not (-90 <= lat <= 90):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid latitude'}), 400
|
||||||
|
if not (-180 <= lon <= 180):
|
||||||
|
return jsonify({'status': 'error', 'message': 'Invalid longitude'}), 400
|
||||||
|
|
||||||
|
hours = max(1, min(hours, 72))
|
||||||
|
min_elevation = max(0, min(min_elevation, 90))
|
||||||
|
|
||||||
|
try:
|
||||||
|
from skyfield.api import load, wgs84, EarthSatellite
|
||||||
|
from skyfield.almanac import find_discrete
|
||||||
|
from data.satellites import TLE_SATELLITES
|
||||||
|
|
||||||
|
ts = load.timescale()
|
||||||
|
observer = wgs84.latlon(lat, lon)
|
||||||
|
t0 = ts.now()
|
||||||
|
t1 = ts.utc(t0.utc_datetime() + __import__('datetime').timedelta(hours=hours))
|
||||||
|
|
||||||
|
all_passes = []
|
||||||
|
|
||||||
|
for sat_key, sat_info in WEATHER_SATELLITES.items():
|
||||||
|
if not sat_info['active']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
tle_data = TLE_SATELLITES.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:
|
||||||
|
times, events = find_discrete(t0, t1, above_horizon)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
|
||||||
|
i = 0
|
||||||
|
while i < len(times):
|
||||||
|
if i < len(events) and events[i]: # Rising
|
||||||
|
rise_time = times[i]
|
||||||
|
set_time = None
|
||||||
|
|
||||||
|
for j in range(i + 1, len(times)):
|
||||||
|
if not events[j]: # Setting
|
||||||
|
set_time = times[j]
|
||||||
|
i = j
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
if set_time is None:
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate max elevation
|
||||||
|
max_el = 0
|
||||||
|
max_el_az = 0
|
||||||
|
duration_seconds = (
|
||||||
|
set_time.utc_datetime() - rise_time.utc_datetime()
|
||||||
|
).total_seconds()
|
||||||
|
duration_minutes = round(duration_seconds / 60, 1)
|
||||||
|
|
||||||
|
for k in range(30):
|
||||||
|
frac = k / 29
|
||||||
|
t_point = ts.utc(
|
||||||
|
rise_time.utc_datetime()
|
||||||
|
+ __import__('datetime').timedelta(
|
||||||
|
seconds=duration_seconds * frac
|
||||||
|
)
|
||||||
|
)
|
||||||
|
diff = satellite - observer
|
||||||
|
topocentric = diff.at(t_point)
|
||||||
|
alt, az, _ = topocentric.altaz()
|
||||||
|
if alt.degrees > max_el:
|
||||||
|
max_el = alt.degrees
|
||||||
|
max_el_az = az.degrees
|
||||||
|
|
||||||
|
if max_el >= min_elevation:
|
||||||
|
# Calculate rise/set azimuth
|
||||||
|
rise_diff = satellite - observer
|
||||||
|
rise_topo = rise_diff.at(rise_time)
|
||||||
|
_, rise_az, _ = rise_topo.altaz()
|
||||||
|
|
||||||
|
set_diff = satellite - observer
|
||||||
|
set_topo = set_diff.at(set_time)
|
||||||
|
_, set_az, _ = set_topo.altaz()
|
||||||
|
|
||||||
|
pass_data = {
|
||||||
|
'satellite': sat_key,
|
||||||
|
'name': sat_info['name'],
|
||||||
|
'frequency': sat_info['frequency'],
|
||||||
|
'mode': sat_info['mode'],
|
||||||
|
'startTime': rise_time.utc_datetime().strftime(
|
||||||
|
'%Y-%m-%d %H:%M UTC'
|
||||||
|
),
|
||||||
|
'startTimeISO': rise_time.utc_datetime().isoformat(),
|
||||||
|
'endTimeISO': set_time.utc_datetime().isoformat(),
|
||||||
|
'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'
|
||||||
|
),
|
||||||
|
}
|
||||||
|
all_passes.append(pass_data)
|
||||||
|
|
||||||
|
i += 1
|
||||||
|
|
||||||
|
# Sort by start time
|
||||||
|
all_passes.sort(key=lambda p: p['startTimeISO'])
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
'status': 'ok',
|
||||||
|
'passes': all_passes,
|
||||||
|
'count': len(all_passes),
|
||||||
|
'observer': {'latitude': lat, 'longitude': lon},
|
||||||
|
'prediction_hours': hours,
|
||||||
|
'min_elevation': min_elevation,
|
||||||
|
})
|
||||||
|
|
||||||
|
except ImportError:
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': 'skyfield library not installed'
|
||||||
|
}), 503
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error predicting passes: {e}")
|
||||||
|
return jsonify({
|
||||||
|
'status': 'error',
|
||||||
|
'message': str(e)
|
||||||
|
}), 500
|
||||||
512
static/css/modes/weather-satellite.css
Normal file
512
static/css/modes/weather-satellite.css
Normal file
@@ -0,0 +1,512 @@
|
|||||||
|
/* Weather Satellite Mode Styles */
|
||||||
|
|
||||||
|
/* ===== Stats Strip ===== */
|
||||||
|
.wxsat-stats-strip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-status {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-dot.capturing {
|
||||||
|
background: #00ff88;
|
||||||
|
animation: wxsat-pulse 1.5s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-dot.decoding {
|
||||||
|
background: #00d4ff;
|
||||||
|
animation: wxsat-pulse 0.8s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes wxsat-pulse {
|
||||||
|
0%, 100% { opacity: 1; }
|
||||||
|
50% { opacity: 0.4; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-status-text {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-btn {
|
||||||
|
padding: 4px 12px;
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-btn:hover {
|
||||||
|
background: var(--bg-hover, #252a3a);
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-btn.stop {
|
||||||
|
border-color: #ff4444;
|
||||||
|
color: #ff4444;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-btn.stop:hover {
|
||||||
|
background: rgba(255, 68, 68, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-divider {
|
||||||
|
width: 1px;
|
||||||
|
height: 24px;
|
||||||
|
background: var(--border-color, #2a3040);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-stat {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-value {
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-label {
|
||||||
|
font-size: 9px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-strip-value.accent-cyan {
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Location inputs in strip ===== */
|
||||||
|
.wxsat-strip-location {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-loc-input {
|
||||||
|
width: 72px;
|
||||||
|
padding: 3px 6px;
|
||||||
|
background: var(--bg-primary, #0d1117);
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 3px;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
font-size: 11px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-loc-input:focus {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Main Layout ===== */
|
||||||
|
.wxsat-visuals-container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
width: 100%;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-content {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Pass Predictions Panel ===== */
|
||||||
|
.wxsat-passes-panel {
|
||||||
|
flex: 0 0 320px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--bg-secondary, #141820);
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-passes-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-passes-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-passes-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-passes-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-card {
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
background: var(--bg-primary, #0d1117);
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-card:hover {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
background: var(--bg-hover, #252a3a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-card.active {
|
||||||
|
border-color: #00ff88;
|
||||||
|
background: rgba(0, 255, 136, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-sat {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-sat-name {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-mode {
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-mode.apt {
|
||||||
|
background: rgba(0, 212, 255, 0.15);
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-mode.lrpt {
|
||||||
|
background: rgba(0, 255, 136, 0.15);
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-details {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-detail-label {
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-detail-value {
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-quality {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 10px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-quality.excellent {
|
||||||
|
background: rgba(0, 255, 136, 0.15);
|
||||||
|
color: #00ff88;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-quality.good {
|
||||||
|
background: rgba(0, 212, 255, 0.15);
|
||||||
|
color: #00d4ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-pass-quality.fair {
|
||||||
|
background: rgba(255, 187, 0, 0.15);
|
||||||
|
color: #ffbb00;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Image Gallery Panel ===== */
|
||||||
|
.wxsat-gallery-panel {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0;
|
||||||
|
background: var(--bg-secondary, #141820);
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-gallery-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 10px 14px;
|
||||||
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-gallery-title {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-gallery-count {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-gallery-grid {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 12px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
align-content: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-card {
|
||||||
|
background: var(--bg-primary, #0d1117);
|
||||||
|
border: 1px solid var(--border-color, #2a3040);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-card:hover {
|
||||||
|
border-color: var(--accent-cyan, #00d4ff);
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-preview {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 4/3;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-info {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-top: 1px solid var(--border-color, #2a3040);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-sat {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #e0e0e0);
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-product {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--accent-cyan, #00d4ff);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-timestamp {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Empty state */
|
||||||
|
.wxsat-gallery-empty {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
text-align: center;
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-gallery-empty svg {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-gallery-empty p {
|
||||||
|
font-size: 12px;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Capture Progress ===== */
|
||||||
|
.wxsat-capture-status {
|
||||||
|
padding: 12px 16px;
|
||||||
|
background: var(--bg-tertiary, #1a1f2e);
|
||||||
|
border-bottom: 1px solid var(--border-color, #2a3040);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-capture-status.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-capture-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-capture-message {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-capture-elapsed {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-dim, #666);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-progress-bar {
|
||||||
|
height: 3px;
|
||||||
|
background: var(--bg-primary, #0d1117);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-progress-bar .progress {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent-cyan, #00d4ff);
|
||||||
|
border-radius: 2px;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Image Modal ===== */
|
||||||
|
.wxsat-image-modal {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.9);
|
||||||
|
display: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 10000;
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-modal.show {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-image-modal img {
|
||||||
|
max-width: 95%;
|
||||||
|
max-height: 95vh;
|
||||||
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-modal-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 16px;
|
||||||
|
right: 24px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
font-size: 32px;
|
||||||
|
cursor: pointer;
|
||||||
|
z-index: 10001;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-modal-info {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 20px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(0, 0, 0, 0.8);
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 4px;
|
||||||
|
color: var(--text-secondary, #999);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Responsive ===== */
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.wxsat-content {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-passes-panel {
|
||||||
|
flex: none;
|
||||||
|
max-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.wxsat-gallery-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
563
static/js/modes/weather-satellite.js
Normal file
563
static/js/modes/weather-satellite.js
Normal file
@@ -0,0 +1,563 @@
|
|||||||
|
/**
|
||||||
|
* Weather Satellite Mode
|
||||||
|
* NOAA APT and Meteor LRPT decoder interface
|
||||||
|
*/
|
||||||
|
|
||||||
|
const WeatherSat = (function() {
|
||||||
|
// State
|
||||||
|
let isRunning = false;
|
||||||
|
let eventSource = null;
|
||||||
|
let images = [];
|
||||||
|
let passes = [];
|
||||||
|
let currentSatellite = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize the Weather Satellite mode
|
||||||
|
*/
|
||||||
|
function init() {
|
||||||
|
checkStatus();
|
||||||
|
loadImages();
|
||||||
|
loadLocationInputs();
|
||||||
|
loadPasses();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load observer location into input fields
|
||||||
|
*/
|
||||||
|
function loadLocationInputs() {
|
||||||
|
const latInput = document.getElementById('wxsatObsLat');
|
||||||
|
const lonInput = document.getElementById('wxsatObsLon');
|
||||||
|
|
||||||
|
let storedLat = localStorage.getItem('observerLat');
|
||||||
|
let storedLon = localStorage.getItem('observerLon');
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
const shared = ObserverLocation.getShared();
|
||||||
|
storedLat = shared.lat.toString();
|
||||||
|
storedLon = shared.lon.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latInput && storedLat) latInput.value = storedLat;
|
||||||
|
if (lonInput && storedLon) lonInput.value = storedLon;
|
||||||
|
|
||||||
|
if (latInput) latInput.addEventListener('change', saveLocationFromInputs);
|
||||||
|
if (lonInput) lonInput.addEventListener('change', saveLocationFromInputs);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save location from inputs and refresh passes
|
||||||
|
*/
|
||||||
|
function saveLocationFromInputs() {
|
||||||
|
const latInput = document.getElementById('wxsatObsLat');
|
||||||
|
const lonInput = document.getElementById('wxsatObsLon');
|
||||||
|
|
||||||
|
const lat = parseFloat(latInput?.value);
|
||||||
|
const lon = parseFloat(lonInput?.value);
|
||||||
|
|
||||||
|
if (!isNaN(lat) && lat >= -90 && lat <= 90 &&
|
||||||
|
!isNaN(lon) && lon >= -180 && lon <= 180) {
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
ObserverLocation.setShared({ lat, lon });
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('observerLat', lat.toString());
|
||||||
|
localStorage.setItem('observerLon', lon.toString());
|
||||||
|
}
|
||||||
|
loadPasses();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Use GPS for location
|
||||||
|
*/
|
||||||
|
function useGPS(btn) {
|
||||||
|
if (!navigator.geolocation) {
|
||||||
|
showNotification('Weather Sat', 'GPS not available in this browser');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const originalText = btn.innerHTML;
|
||||||
|
btn.innerHTML = '<span style="opacity: 0.7;">...</span>';
|
||||||
|
btn.disabled = true;
|
||||||
|
|
||||||
|
navigator.geolocation.getCurrentPosition(
|
||||||
|
(pos) => {
|
||||||
|
const latInput = document.getElementById('wxsatObsLat');
|
||||||
|
const lonInput = document.getElementById('wxsatObsLon');
|
||||||
|
|
||||||
|
const lat = pos.coords.latitude.toFixed(4);
|
||||||
|
const lon = pos.coords.longitude.toFixed(4);
|
||||||
|
|
||||||
|
if (latInput) latInput.value = lat;
|
||||||
|
if (lonInput) lonInput.value = lon;
|
||||||
|
|
||||||
|
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||||
|
ObserverLocation.setShared({ lat: parseFloat(lat), lon: parseFloat(lon) });
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('observerLat', lat);
|
||||||
|
localStorage.setItem('observerLon', lon);
|
||||||
|
}
|
||||||
|
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
showNotification('Weather Sat', 'Location updated');
|
||||||
|
loadPasses();
|
||||||
|
},
|
||||||
|
(err) => {
|
||||||
|
btn.innerHTML = originalText;
|
||||||
|
btn.disabled = false;
|
||||||
|
showNotification('Weather Sat', 'Failed to get location');
|
||||||
|
},
|
||||||
|
{ enableHighAccuracy: true, timeout: 10000 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check decoder status
|
||||||
|
*/
|
||||||
|
async function checkStatus() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/weather-sat/status');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (!data.available) {
|
||||||
|
updateStatusUI('unavailable', 'SatDump not installed');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (data.running) {
|
||||||
|
isRunning = true;
|
||||||
|
currentSatellite = data.satellite;
|
||||||
|
updateStatusUI('capturing', `Capturing ${data.satellite}...`);
|
||||||
|
startStream();
|
||||||
|
} else {
|
||||||
|
updateStatusUI('idle', 'Idle');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to check weather sat status:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start capture
|
||||||
|
*/
|
||||||
|
async function start() {
|
||||||
|
const satSelect = document.getElementById('weatherSatSelect');
|
||||||
|
const gainInput = document.getElementById('weatherSatGain');
|
||||||
|
const biasTInput = document.getElementById('weatherSatBiasT');
|
||||||
|
const deviceSelect = document.getElementById('deviceSelect');
|
||||||
|
|
||||||
|
const satellite = satSelect?.value || 'NOAA-18';
|
||||||
|
const gain = parseFloat(gainInput?.value || '40');
|
||||||
|
const biasT = biasTInput?.checked || false;
|
||||||
|
const device = parseInt(deviceSelect?.value || '0', 10);
|
||||||
|
|
||||||
|
updateStatusUI('connecting', 'Starting...');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch('/weather-sat/start', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
satellite,
|
||||||
|
device,
|
||||||
|
gain,
|
||||||
|
bias_t: biasT,
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'started' || data.status === 'already_running') {
|
||||||
|
isRunning = true;
|
||||||
|
currentSatellite = data.satellite || satellite;
|
||||||
|
updateStatusUI('capturing', `${data.satellite} ${data.frequency} MHz`);
|
||||||
|
updateFreqDisplay(data.frequency, data.mode);
|
||||||
|
startStream();
|
||||||
|
showNotification('Weather Sat', `Capturing ${data.satellite} on ${data.frequency} MHz`);
|
||||||
|
} else {
|
||||||
|
updateStatusUI('idle', 'Start failed');
|
||||||
|
showNotification('Weather Sat', data.message || 'Failed to start');
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to start weather sat:', err);
|
||||||
|
updateStatusUI('idle', 'Error');
|
||||||
|
showNotification('Weather Sat', 'Connection error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start capture for a specific pass
|
||||||
|
*/
|
||||||
|
function startPass(satellite) {
|
||||||
|
const satSelect = document.getElementById('weatherSatSelect');
|
||||||
|
if (satSelect) {
|
||||||
|
satSelect.value = satellite;
|
||||||
|
}
|
||||||
|
start();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop capture
|
||||||
|
*/
|
||||||
|
async function stop() {
|
||||||
|
try {
|
||||||
|
await fetch('/weather-sat/stop', { method: 'POST' });
|
||||||
|
isRunning = false;
|
||||||
|
stopStream();
|
||||||
|
updateStatusUI('idle', 'Stopped');
|
||||||
|
showNotification('Weather Sat', 'Capture stopped');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to stop weather sat:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update status UI
|
||||||
|
*/
|
||||||
|
function updateStatusUI(status, text) {
|
||||||
|
const dot = document.getElementById('wxsatStripDot');
|
||||||
|
const statusText = document.getElementById('wxsatStripStatus');
|
||||||
|
const startBtn = document.getElementById('wxsatStartBtn');
|
||||||
|
const stopBtn = document.getElementById('wxsatStopBtn');
|
||||||
|
|
||||||
|
if (dot) {
|
||||||
|
dot.className = 'wxsat-strip-dot';
|
||||||
|
if (status === 'capturing') dot.classList.add('capturing');
|
||||||
|
else if (status === 'decoding') dot.classList.add('decoding');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusText) statusText.textContent = text || status;
|
||||||
|
|
||||||
|
if (startBtn && stopBtn) {
|
||||||
|
if (status === 'capturing' || status === 'decoding') {
|
||||||
|
startBtn.style.display = 'none';
|
||||||
|
stopBtn.style.display = 'inline-block';
|
||||||
|
} else {
|
||||||
|
startBtn.style.display = 'inline-block';
|
||||||
|
stopBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update frequency display in strip
|
||||||
|
*/
|
||||||
|
function updateFreqDisplay(freq, mode) {
|
||||||
|
const freqEl = document.getElementById('wxsatStripFreq');
|
||||||
|
const modeEl = document.getElementById('wxsatStripMode');
|
||||||
|
if (freqEl) freqEl.textContent = freq || '--';
|
||||||
|
if (modeEl) modeEl.textContent = mode || '--';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start SSE stream
|
||||||
|
*/
|
||||||
|
function startStream() {
|
||||||
|
if (eventSource) eventSource.close();
|
||||||
|
|
||||||
|
eventSource = new EventSource('/weather-sat/stream');
|
||||||
|
|
||||||
|
eventSource.onmessage = (e) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(e.data);
|
||||||
|
if (data.type === 'weather_sat_progress') {
|
||||||
|
handleProgress(data);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to parse SSE:', err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
eventSource.onerror = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (isRunning) startStream();
|
||||||
|
}, 3000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stop SSE stream
|
||||||
|
*/
|
||||||
|
function stopStream() {
|
||||||
|
if (eventSource) {
|
||||||
|
eventSource.close();
|
||||||
|
eventSource = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle progress update
|
||||||
|
*/
|
||||||
|
function handleProgress(data) {
|
||||||
|
const captureStatus = document.getElementById('wxsatCaptureStatus');
|
||||||
|
const captureMsg = document.getElementById('wxsatCaptureMsg');
|
||||||
|
const captureElapsed = document.getElementById('wxsatCaptureElapsed');
|
||||||
|
const progressBar = document.getElementById('wxsatProgressFill');
|
||||||
|
|
||||||
|
if (data.status === 'capturing' || data.status === 'decoding') {
|
||||||
|
updateStatusUI(data.status, `${data.status === 'decoding' ? 'Decoding' : 'Capturing'} ${data.satellite}...`);
|
||||||
|
|
||||||
|
if (captureStatus) captureStatus.classList.add('active');
|
||||||
|
if (captureMsg) captureMsg.textContent = data.message || '';
|
||||||
|
if (captureElapsed) captureElapsed.textContent = formatElapsed(data.elapsed_seconds || 0);
|
||||||
|
if (progressBar) progressBar.style.width = (data.progress || 0) + '%';
|
||||||
|
|
||||||
|
} else if (data.status === 'complete') {
|
||||||
|
if (data.image) {
|
||||||
|
images.unshift(data.image);
|
||||||
|
updateImageCount(images.length);
|
||||||
|
renderGallery();
|
||||||
|
showNotification('Weather Sat', `New image: ${data.image.product || data.image.satellite}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data.image) {
|
||||||
|
// Capture ended
|
||||||
|
isRunning = false;
|
||||||
|
stopStream();
|
||||||
|
updateStatusUI('idle', 'Capture complete');
|
||||||
|
if (captureStatus) captureStatus.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
} else if (data.status === 'error') {
|
||||||
|
updateStatusUI('idle', 'Error');
|
||||||
|
showNotification('Weather Sat', data.message || 'Capture error');
|
||||||
|
if (captureStatus) captureStatus.classList.remove('active');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format elapsed seconds
|
||||||
|
*/
|
||||||
|
function formatElapsed(seconds) {
|
||||||
|
const m = Math.floor(seconds / 60);
|
||||||
|
const s = seconds % 60;
|
||||||
|
return `${m}:${s.toString().padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load pass predictions
|
||||||
|
*/
|
||||||
|
async function loadPasses() {
|
||||||
|
const storedLat = localStorage.getItem('observerLat');
|
||||||
|
const storedLon = localStorage.getItem('observerLon');
|
||||||
|
|
||||||
|
if (!storedLat || !storedLon) {
|
||||||
|
renderPasses([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15`;
|
||||||
|
const response = await fetch(url);
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
passes = data.passes || [];
|
||||||
|
renderPasses(passes);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load passes:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render pass predictions list
|
||||||
|
*/
|
||||||
|
function renderPasses(passList) {
|
||||||
|
const container = document.getElementById('wxsatPassesList');
|
||||||
|
const countEl = document.getElementById('wxsatPassesCount');
|
||||||
|
|
||||||
|
if (countEl) countEl.textContent = passList.length;
|
||||||
|
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (passList.length === 0) {
|
||||||
|
const hasLocation = localStorage.getItem('observerLat') !== null;
|
||||||
|
container.innerHTML = `
|
||||||
|
<div class="wxsat-gallery-empty">
|
||||||
|
<p>${hasLocation ? 'No passes in next 24h' : 'Set location to see pass predictions'}</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = passList.map((pass, idx) => {
|
||||||
|
const modeClass = pass.mode === 'APT' ? 'apt' : 'lrpt';
|
||||||
|
const timeStr = pass.startTime || '--';
|
||||||
|
const now = new Date();
|
||||||
|
const passStart = new Date(pass.startTimeISO);
|
||||||
|
const diffMs = passStart - now;
|
||||||
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
|
||||||
|
let countdown = '';
|
||||||
|
if (diffMs < 0) {
|
||||||
|
countdown = 'NOW';
|
||||||
|
} else if (diffMins < 60) {
|
||||||
|
countdown = `in ${diffMins}m`;
|
||||||
|
} else {
|
||||||
|
const hrs = Math.floor(diffMins / 60);
|
||||||
|
const mins = diffMins % 60;
|
||||||
|
countdown = `in ${hrs}h${mins}m`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="wxsat-pass-card" onclick="WeatherSat.startPass('${escapeHtml(pass.satellite)}')">
|
||||||
|
<div class="wxsat-pass-sat">
|
||||||
|
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
|
||||||
|
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-pass-details">
|
||||||
|
<span class="wxsat-pass-detail-label">Time</span>
|
||||||
|
<span class="wxsat-pass-detail-value">${escapeHtml(timeStr)}</span>
|
||||||
|
<span class="wxsat-pass-detail-label">Max El</span>
|
||||||
|
<span class="wxsat-pass-detail-value">${pass.maxEl}°</span>
|
||||||
|
<span class="wxsat-pass-detail-label">Duration</span>
|
||||||
|
<span class="wxsat-pass-detail-value">${pass.duration} min</span>
|
||||||
|
<span class="wxsat-pass-detail-label">Freq</span>
|
||||||
|
<span class="wxsat-pass-detail-value">${pass.frequency} MHz</span>
|
||||||
|
</div>
|
||||||
|
<div style="display: flex; align-items: center; justify-content: space-between; margin-top: 4px;">
|
||||||
|
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
|
||||||
|
<span style="font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace;">${countdown}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load decoded images
|
||||||
|
*/
|
||||||
|
async function loadImages() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('/weather-sat/images');
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.status === 'ok') {
|
||||||
|
images = data.images || [];
|
||||||
|
updateImageCount(images.length);
|
||||||
|
renderGallery();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to load weather sat images:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update image count
|
||||||
|
*/
|
||||||
|
function updateImageCount(count) {
|
||||||
|
const countEl = document.getElementById('wxsatImageCount');
|
||||||
|
const stripCount = document.getElementById('wxsatStripImageCount');
|
||||||
|
if (countEl) countEl.textContent = count;
|
||||||
|
if (stripCount) stripCount.textContent = count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render image gallery
|
||||||
|
*/
|
||||||
|
function renderGallery() {
|
||||||
|
const gallery = document.getElementById('wxsatGallery');
|
||||||
|
if (!gallery) return;
|
||||||
|
|
||||||
|
if (images.length === 0) {
|
||||||
|
gallery.innerHTML = `
|
||||||
|
<div class="wxsat-gallery-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M2 12h20"/>
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||||
|
</svg>
|
||||||
|
<p>No images decoded yet</p>
|
||||||
|
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
gallery.innerHTML = images.map(img => `
|
||||||
|
<div class="wxsat-image-card" onclick="WeatherSat.showImage('${escapeHtml(img.url)}', '${escapeHtml(img.satellite)}', '${escapeHtml(img.product)}')">
|
||||||
|
<img src="${escapeHtml(img.url)}" alt="${escapeHtml(img.satellite)} ${escapeHtml(img.product)}" class="wxsat-image-preview" loading="lazy">
|
||||||
|
<div class="wxsat-image-info">
|
||||||
|
<div class="wxsat-image-sat">${escapeHtml(img.satellite)}</div>
|
||||||
|
<div class="wxsat-image-product">${escapeHtml(img.product || img.mode)}</div>
|
||||||
|
<div class="wxsat-image-timestamp">${formatTimestamp(img.timestamp)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show full-size image
|
||||||
|
*/
|
||||||
|
function showImage(url, satellite, product) {
|
||||||
|
let modal = document.getElementById('wxsatImageModal');
|
||||||
|
if (!modal) {
|
||||||
|
modal = document.createElement('div');
|
||||||
|
modal.id = 'wxsatImageModal';
|
||||||
|
modal.className = 'wxsat-image-modal';
|
||||||
|
modal.innerHTML = `
|
||||||
|
<button class="wxsat-modal-close" onclick="WeatherSat.closeImage()">×</button>
|
||||||
|
<img src="" alt="Weather Satellite Image">
|
||||||
|
<div class="wxsat-modal-info"></div>
|
||||||
|
`;
|
||||||
|
modal.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modal) closeImage();
|
||||||
|
});
|
||||||
|
document.body.appendChild(modal);
|
||||||
|
}
|
||||||
|
|
||||||
|
modal.querySelector('img').src = url;
|
||||||
|
const info = modal.querySelector('.wxsat-modal-info');
|
||||||
|
if (info) {
|
||||||
|
info.textContent = `${satellite || ''} ${product ? '// ' + product : ''}`;
|
||||||
|
}
|
||||||
|
modal.classList.add('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Close image modal
|
||||||
|
*/
|
||||||
|
function closeImage() {
|
||||||
|
const modal = document.getElementById('wxsatImageModal');
|
||||||
|
if (modal) modal.classList.remove('show');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format timestamp
|
||||||
|
*/
|
||||||
|
function formatTimestamp(isoString) {
|
||||||
|
if (!isoString) return '--';
|
||||||
|
try {
|
||||||
|
return new Date(isoString).toLocaleString();
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Escape HTML
|
||||||
|
*/
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public API
|
||||||
|
return {
|
||||||
|
init,
|
||||||
|
start,
|
||||||
|
stop,
|
||||||
|
startPass,
|
||||||
|
loadImages,
|
||||||
|
loadPasses,
|
||||||
|
showImage,
|
||||||
|
closeImage,
|
||||||
|
useGPS,
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
// Initialization happens via selectMode when weather-satellite mode is activated
|
||||||
|
});
|
||||||
@@ -57,6 +57,7 @@
|
|||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/spy-stations.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/meshtastic.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/sstv.css') }}">
|
||||||
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/modes/weather-satellite.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/settings.css') }}">
|
||||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
<link rel="stylesheet" href="{{ url_for('static', filename='css/components/toast.css') }}">
|
||||||
</head>
|
</head>
|
||||||
@@ -224,6 +225,10 @@
|
|||||||
<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>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="mode-card mode-card-sm" onclick="selectMode('weathersat')">
|
||||||
|
<span class="mode-icon icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg></span>
|
||||||
|
<span class="mode-name">Weather Sat</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -506,6 +511,8 @@
|
|||||||
|
|
||||||
{% include 'partials/modes/sstv.html' %}
|
{% include 'partials/modes/sstv.html' %}
|
||||||
|
|
||||||
|
{% include 'partials/modes/weather-satellite.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/listening-post.html' %}
|
{% include 'partials/modes/listening-post.html' %}
|
||||||
|
|
||||||
{% include 'partials/modes/tscm.html' %}
|
{% include 'partials/modes/tscm.html' %}
|
||||||
@@ -1880,6 +1887,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Weather Satellite visuals (pass predictions + image gallery) -->
|
||||||
|
<div id="weatherSatVisuals" class="wxsat-visuals-container" style="display: none;">
|
||||||
|
<!-- Stats strip -->
|
||||||
|
<div class="wxsat-stats-strip">
|
||||||
|
<div class="wxsat-strip-group">
|
||||||
|
<div class="wxsat-strip-status">
|
||||||
|
<span class="wxsat-strip-dot" id="wxsatStripDot"></span>
|
||||||
|
<span class="wxsat-strip-status-text" id="wxsatStripStatus">Idle</span>
|
||||||
|
</div>
|
||||||
|
<button class="wxsat-strip-btn start" id="wxsatStartBtn" onclick="WeatherSat.start()">Start</button>
|
||||||
|
<button class="wxsat-strip-btn stop" id="wxsatStopBtn" onclick="WeatherSat.stop()" style="display: none;">Stop</button>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-strip-divider"></div>
|
||||||
|
<div class="wxsat-strip-group">
|
||||||
|
<div class="wxsat-strip-stat">
|
||||||
|
<span class="wxsat-strip-value accent-cyan" id="wxsatStripFreq">--</span>
|
||||||
|
<span class="wxsat-strip-label">MHZ</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-strip-stat">
|
||||||
|
<span class="wxsat-strip-value" id="wxsatStripMode">--</span>
|
||||||
|
<span class="wxsat-strip-label">MODE</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-strip-stat">
|
||||||
|
<span class="wxsat-strip-value" id="wxsatStripImageCount">0</span>
|
||||||
|
<span class="wxsat-strip-label">IMAGES</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-strip-divider"></div>
|
||||||
|
<div class="wxsat-strip-group">
|
||||||
|
<div class="wxsat-strip-location">
|
||||||
|
<span class="wxsat-strip-label" style="margin-right: 6px;">LOC</span>
|
||||||
|
<input type="number" id="wxsatObsLat" class="wxsat-loc-input" step="0.0001" placeholder="Lat" title="Latitude">
|
||||||
|
<input type="number" id="wxsatObsLon" class="wxsat-loc-input" step="0.0001" placeholder="Lon" title="Longitude">
|
||||||
|
<button class="wxsat-strip-btn gps" onclick="WeatherSat.useGPS(this)" title="Use GPS location">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="14" height="14"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Capture progress -->
|
||||||
|
<div class="wxsat-capture-status" id="wxsatCaptureStatus">
|
||||||
|
<div class="wxsat-capture-info">
|
||||||
|
<span class="wxsat-capture-message" id="wxsatCaptureMsg">--</span>
|
||||||
|
<span class="wxsat-capture-elapsed" id="wxsatCaptureElapsed">0:00</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-progress-bar">
|
||||||
|
<div class="progress" id="wxsatProgressFill" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main content: passes + gallery -->
|
||||||
|
<div class="wxsat-content">
|
||||||
|
<!-- Pass predictions -->
|
||||||
|
<div class="wxsat-passes-panel">
|
||||||
|
<div class="wxsat-passes-header">
|
||||||
|
<span class="wxsat-passes-title">Upcoming Passes</span>
|
||||||
|
<span class="wxsat-passes-count" id="wxsatPassesCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-passes-list" id="wxsatPassesList">
|
||||||
|
<div class="wxsat-gallery-empty">
|
||||||
|
<p>Set location to see pass predictions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Image gallery -->
|
||||||
|
<div class="wxsat-gallery-panel">
|
||||||
|
<div class="wxsat-gallery-header">
|
||||||
|
<span class="wxsat-gallery-title">Decoded Images</span>
|
||||||
|
<span class="wxsat-gallery-count" id="wxsatImageCount">0</span>
|
||||||
|
</div>
|
||||||
|
<div class="wxsat-gallery-grid" id="wxsatGallery">
|
||||||
|
<div class="wxsat-gallery-empty">
|
||||||
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<circle cx="12" cy="12" r="10"/>
|
||||||
|
<path d="M2 12h20"/>
|
||||||
|
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>
|
||||||
|
</svg>
|
||||||
|
<p>No images decoded yet</p>
|
||||||
|
<p style="margin-top: 4px; font-size: 11px;">Select a satellite pass and start capturing</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
<!-- Device Intelligence Dashboard (above waterfall for prominence) -->
|
||||||
<div class="recon-panel collapsed" id="reconPanel">
|
<div class="recon-panel collapsed" id="reconPanel">
|
||||||
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
<div class="recon-header" onclick="toggleReconCollapse()" style="cursor: pointer;">
|
||||||
@@ -1967,6 +2061,7 @@
|
|||||||
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/spy-stations.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/meshtastic.js') }}"></script>
|
||||||
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
|
<script src="{{ url_for('static', filename='js/modes/sstv.js') }}"></script>
|
||||||
|
<script src="{{ url_for('static', filename='js/modes/weather-satellite.js') }}"></script>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
// ============================================
|
// ============================================
|
||||||
@@ -2102,7 +2197,7 @@
|
|||||||
const validModes = new Set([
|
const validModes = new Set([
|
||||||
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
'pager', 'sensor', 'rtlamr', 'aprs', 'listening',
|
||||||
'spystations', 'meshtastic', 'wifi', 'bluetooth',
|
'spystations', 'meshtastic', 'wifi', 'bluetooth',
|
||||||
'tscm', 'satellite', 'sstv'
|
'tscm', 'satellite', 'sstv', 'weathersat'
|
||||||
]);
|
]);
|
||||||
|
|
||||||
function getModeFromQuery() {
|
function getModeFromQuery() {
|
||||||
@@ -2524,7 +2619,7 @@
|
|||||||
'tscm': 'security',
|
'tscm': 'security',
|
||||||
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
'rtlamr': 'sdr', 'ais': 'sdr', 'spystations': 'sdr',
|
||||||
'meshtastic': 'sdr',
|
'meshtastic': 'sdr',
|
||||||
'satellite': 'space', 'sstv': 'space'
|
'satellite': 'space', 'sstv': 'space', 'weathersat': 'space'
|
||||||
};
|
};
|
||||||
|
|
||||||
// Remove has-active from all dropdowns
|
// Remove has-active from all dropdowns
|
||||||
@@ -2606,6 +2701,7 @@
|
|||||||
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
document.getElementById('rtlamrMode')?.classList.toggle('active', mode === 'rtlamr');
|
||||||
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
document.getElementById('satelliteMode')?.classList.toggle('active', mode === 'satellite');
|
||||||
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
document.getElementById('sstvMode')?.classList.toggle('active', mode === 'sstv');
|
||||||
|
document.getElementById('weatherSatMode')?.classList.toggle('active', mode === 'weathersat');
|
||||||
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
document.getElementById('wifiMode')?.classList.toggle('active', mode === 'wifi');
|
||||||
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
document.getElementById('bluetoothMode')?.classList.toggle('active', mode === 'bluetooth');
|
||||||
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
document.getElementById('listeningPostMode')?.classList.toggle('active', mode === 'listening');
|
||||||
@@ -2640,6 +2736,7 @@
|
|||||||
'rtlamr': 'METERS',
|
'rtlamr': 'METERS',
|
||||||
'satellite': 'SATELLITE',
|
'satellite': 'SATELLITE',
|
||||||
'sstv': 'ISS SSTV',
|
'sstv': 'ISS SSTV',
|
||||||
|
'weathersat': 'WEATHER SAT',
|
||||||
'wifi': 'WIFI',
|
'wifi': 'WIFI',
|
||||||
'bluetooth': 'BLUETOOTH',
|
'bluetooth': 'BLUETOOTH',
|
||||||
'listening': 'LISTENING POST',
|
'listening': 'LISTENING POST',
|
||||||
@@ -2660,6 +2757,7 @@
|
|||||||
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
const spyStationsVisuals = document.getElementById('spyStationsVisuals');
|
||||||
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
const meshtasticVisuals = document.getElementById('meshtasticVisuals');
|
||||||
const sstvVisuals = document.getElementById('sstvVisuals');
|
const sstvVisuals = document.getElementById('sstvVisuals');
|
||||||
|
const weatherSatVisuals = document.getElementById('weatherSatVisuals');
|
||||||
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
if (wifiLayoutContainer) wifiLayoutContainer.style.display = mode === 'wifi' ? 'flex' : 'none';
|
||||||
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
if (btLayoutContainer) btLayoutContainer.style.display = mode === 'bluetooth' ? 'flex' : 'none';
|
||||||
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
if (satelliteVisuals) satelliteVisuals.style.display = mode === 'satellite' ? 'block' : 'none';
|
||||||
@@ -2669,6 +2767,7 @@
|
|||||||
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
if (spyStationsVisuals) spyStationsVisuals.style.display = mode === 'spystations' ? 'flex' : 'none';
|
||||||
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
if (meshtasticVisuals) meshtasticVisuals.style.display = mode === 'meshtastic' ? 'flex' : 'none';
|
||||||
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
|
if (sstvVisuals) sstvVisuals.style.display = mode === 'sstv' ? 'flex' : 'none';
|
||||||
|
if (weatherSatVisuals) weatherSatVisuals.style.display = mode === 'weathersat' ? 'flex' : 'none';
|
||||||
|
|
||||||
// Hide sidebar by default for Meshtastic mode, show for others
|
// Hide sidebar by default for Meshtastic mode, show for others
|
||||||
const mainContent = document.querySelector('.main-content');
|
const mainContent = document.querySelector('.main-content');
|
||||||
@@ -2693,6 +2792,7 @@
|
|||||||
'rtlamr': 'Utility Meter Monitor',
|
'rtlamr': 'Utility Meter Monitor',
|
||||||
'satellite': 'Satellite Monitor',
|
'satellite': 'Satellite Monitor',
|
||||||
'sstv': 'ISS SSTV Decoder',
|
'sstv': 'ISS SSTV Decoder',
|
||||||
|
'weathersat': 'Weather Satellite Decoder',
|
||||||
'wifi': 'WiFi Scanner',
|
'wifi': 'WiFi Scanner',
|
||||||
'bluetooth': 'Bluetooth Scanner',
|
'bluetooth': 'Bluetooth Scanner',
|
||||||
'listening': 'Listening Post',
|
'listening': 'Listening Post',
|
||||||
@@ -2718,7 +2818,7 @@
|
|||||||
const reconBtn = document.getElementById('reconBtn');
|
const reconBtn = document.getElementById('reconBtn');
|
||||||
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
const intelBtn = document.querySelector('[onclick="exportDeviceDB()"]');
|
||||||
const reconPanel = document.getElementById('reconPanel');
|
const reconPanel = document.getElementById('reconPanel');
|
||||||
if (mode === 'satellite' || mode === 'sstv' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
|
if (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'listening' || mode === 'aprs' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') {
|
||||||
if (reconPanel) reconPanel.style.display = 'none';
|
if (reconPanel) reconPanel.style.display = 'none';
|
||||||
if (reconBtn) reconBtn.style.display = 'none';
|
if (reconBtn) reconBtn.style.display = 'none';
|
||||||
if (intelBtn) intelBtn.style.display = 'none';
|
if (intelBtn) intelBtn.style.display = 'none';
|
||||||
@@ -2738,7 +2838,7 @@
|
|||||||
|
|
||||||
// Show RTL-SDR device section for modes that use it
|
// Show RTL-SDR device section for modes that use it
|
||||||
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
const rtlDeviceSection = document.getElementById('rtlDeviceSection');
|
||||||
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv') ? 'block' : 'none';
|
if (rtlDeviceSection) rtlDeviceSection.style.display = (mode === 'pager' || mode === 'sensor' || mode === 'rtlamr' || mode === 'listening' || mode === 'aprs' || mode === 'sstv' || mode === 'weathersat') ? 'block' : 'none';
|
||||||
|
|
||||||
// Toggle mode-specific tool status displays
|
// Toggle mode-specific tool status displays
|
||||||
const toolStatusPager = document.getElementById('toolStatusPager');
|
const toolStatusPager = document.getElementById('toolStatusPager');
|
||||||
@@ -2749,7 +2849,7 @@
|
|||||||
// Hide output console for modes with their own visualizations
|
// Hide output console for modes with their own visualizations
|
||||||
const outputEl = document.getElementById('output');
|
const outputEl = document.getElementById('output');
|
||||||
const statusBar = document.querySelector('.status-bar');
|
const statusBar = document.querySelector('.status-bar');
|
||||||
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') ? 'none' : 'block';
|
if (outputEl) outputEl.style.display = (mode === 'satellite' || mode === 'sstv' || mode === 'weathersat' || mode === 'aprs' || mode === 'wifi' || mode === 'bluetooth' || mode === 'listening' || mode === 'tscm' || mode === 'spystations' || mode === 'meshtastic') ? 'none' : 'block';
|
||||||
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
|
if (statusBar) statusBar.style.display = (mode === 'satellite') ? 'none' : 'flex';
|
||||||
|
|
||||||
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
// Restore sidebar when leaving Meshtastic mode (user may have collapsed it)
|
||||||
@@ -2797,6 +2897,8 @@
|
|||||||
}, 100);
|
}, 100);
|
||||||
} else if (mode === 'sstv') {
|
} else if (mode === 'sstv') {
|
||||||
SSTV.init();
|
SSTV.init();
|
||||||
|
} else if (mode === 'weathersat') {
|
||||||
|
WeatherSat.init();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
82
templates/partials/modes/weather-satellite.html
Normal file
82
templates/partials/modes/weather-satellite.html
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
<!-- WEATHER SATELLITE MODE -->
|
||||||
|
<div id="weatherSatMode" class="mode-content">
|
||||||
|
<div class="section">
|
||||||
|
<h3>Weather Satellite Decoder</h3>
|
||||||
|
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 12px;">
|
||||||
|
Receive and decode weather images from NOAA and Meteor satellites.
|
||||||
|
Uses SatDump for live SDR capture and image processing.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Satellite</h3>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Select Satellite</label>
|
||||||
|
<select id="weatherSatSelect" class="mode-select">
|
||||||
|
<option value="NOAA-15">NOAA-15 (137.620 MHz APT)</option>
|
||||||
|
<option value="NOAA-18" selected>NOAA-18 (137.9125 MHz APT)</option>
|
||||||
|
<option value="NOAA-19">NOAA-19 (137.100 MHz APT)</option>
|
||||||
|
<option value="METEOR-M2-3">Meteor-M2-3 (137.900 MHz LRPT)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Gain (dB)</label>
|
||||||
|
<input type="number" id="weatherSatGain" value="40" step="0.1" min="0" max="50">
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label style="display: flex; align-items: center; gap: 6px;">
|
||||||
|
<input type="checkbox" id="weatherSatBiasT" style="width: auto;">
|
||||||
|
Bias-T (power LNA)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Antenna Guide (137 MHz)</h3>
|
||||||
|
<div style="font-size: 11px; color: var(--text-dim);">
|
||||||
|
<p style="margin-bottom: 8px; color: var(--accent-cyan);">Weather satellites transmit at ~137 MHz. Your stock SDR antenna likely won't work well at this frequency.</p>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<strong style="color: var(--text-primary);">V-Dipole (Easiest Build)</strong>
|
||||||
|
<ul style="margin: 4px 0 0 16px; padding: 0;">
|
||||||
|
<li>Two elements, ~53.4 cm each (quarter wavelength)</li>
|
||||||
|
<li>Spread at 120 angle, laid flat or tilted</li>
|
||||||
|
<li>Connect to SDR via coax with a BNC/SMA adapter</li>
|
||||||
|
<li>Cost: ~$5 in wire</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<strong style="color: var(--text-primary);">QFH Antenna (Best)</strong>
|
||||||
|
<ul style="margin: 4px 0 0 16px; padding: 0;">
|
||||||
|
<li>Quadrifilar helix - omnidirectional RHCP</li>
|
||||||
|
<li>Best for overhead passes, rejects ground noise</li>
|
||||||
|
<li>Build from copper pipe or coax</li>
|
||||||
|
<li>Cost: ~$20-30 in materials</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style="margin-bottom: 8px;">
|
||||||
|
<strong style="color: var(--text-primary);">Tips</strong>
|
||||||
|
<ul style="margin: 4px 0 0 16px; padding: 0;">
|
||||||
|
<li>Outdoors with clear sky view is critical</li>
|
||||||
|
<li>LNA (e.g. Nooelec SAWbird) helps a lot</li>
|
||||||
|
<li>Enable Bias-T if using a powered LNA</li>
|
||||||
|
<li>Passes >30 elevation give best images</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="section">
|
||||||
|
<h3>Resources</h3>
|
||||||
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
<a href="https://github.com/SatDump/SatDump" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
SatDump Documentation
|
||||||
|
</a>
|
||||||
|
<a href="https://www.rtl-sdr.com/rtl-sdr-tutorial-receiving-noaa-weather-satellite-images/" target="_blank" rel="noopener" class="preset-btn" style="text-decoration: none; text-align: center;">
|
||||||
|
NOAA Reception Guide
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -116,6 +116,7 @@
|
|||||||
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
|
{{ mode_item('satellite', 'Satellite', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 7L9 3 5 7l4 4"/><path d="m17 11 4 4-4 4-4-4"/><path d="m8 12 4 4 6-6-4-4-6 6"/><path d="m16 8 3-3"/><path d="M9 21a6 6 0 0 0-6-6"/></svg>', '/satellite/dashboard') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ mode_item('sstv', 'ISS SSTV', '<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>') }}
|
{{ mode_item('sstv', 'ISS SSTV', '<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>') }}
|
||||||
|
{{ mode_item('weathersat', 'Weather Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -182,6 +183,7 @@
|
|||||||
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>', '/satellite/dashboard') }}
|
{{ mobile_item('satellite', 'Sat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><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"/></svg>', '/satellite/dashboard') }}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
{{ mobile_item('sstv', 'SSTV', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="12" cy="12" r="3"/></svg>') }}
|
||||||
|
{{ mobile_item('weathersat', 'WxSat', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>') }}
|
||||||
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
{{ mobile_item('listening', 'Scanner', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/><path d="M3 9h18"/><path d="M9 21V9"/></svg>') }}
|
||||||
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
{{ mobile_item('spystations', 'Spy', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"/><circle cx="12" cy="12" r="2"/><path d="M19.1 4.9C23 8.8 23 15.1 19.1 19"/></svg>') }}
|
||||||
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
{{ mobile_item('meshtastic', 'Mesh', '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><circle cx="12" cy="12" r="3"/><path d="M12 2v4m0 12v4M2 12h4m12 0h4"/></svg>') }}
|
||||||
|
|||||||
609
utils/weather_sat.py
Normal file
609
utils/weather_sat.py
Normal file
@@ -0,0 +1,609 @@
|
|||||||
|
"""Weather Satellite decoder for NOAA APT and Meteor LRPT imagery.
|
||||||
|
|
||||||
|
Provides automated capture and decoding of weather satellite images using SatDump.
|
||||||
|
|
||||||
|
Supported satellites:
|
||||||
|
- NOAA-15: 137.620 MHz (APT)
|
||||||
|
- NOAA-18: 137.9125 MHz (APT)
|
||||||
|
- NOAA-19: 137.100 MHz (APT)
|
||||||
|
- Meteor-M2-3: 137.900 MHz (LRPT)
|
||||||
|
|
||||||
|
Uses SatDump CLI for live SDR capture and decoding, with fallback to
|
||||||
|
rtl_fm capture for manual decoding when SatDump is unavailable.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import shutil
|
||||||
|
import subprocess
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime, timezone, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from utils.logging import get_logger
|
||||||
|
|
||||||
|
logger = get_logger('intercept.weather_sat')
|
||||||
|
|
||||||
|
|
||||||
|
# Weather satellite definitions
|
||||||
|
WEATHER_SATELLITES = {
|
||||||
|
'NOAA-15': {
|
||||||
|
'name': 'NOAA 15',
|
||||||
|
'frequency': 137.620,
|
||||||
|
'mode': 'APT',
|
||||||
|
'pipeline': 'noaa_apt',
|
||||||
|
'tle_key': 'NOAA-15',
|
||||||
|
'description': 'NOAA-15 APT (analog weather imagery)',
|
||||||
|
'active': True,
|
||||||
|
},
|
||||||
|
'NOAA-18': {
|
||||||
|
'name': 'NOAA 18',
|
||||||
|
'frequency': 137.9125,
|
||||||
|
'mode': 'APT',
|
||||||
|
'pipeline': 'noaa_apt',
|
||||||
|
'tle_key': 'NOAA-18',
|
||||||
|
'description': 'NOAA-18 APT (analog weather imagery)',
|
||||||
|
'active': True,
|
||||||
|
},
|
||||||
|
'NOAA-19': {
|
||||||
|
'name': 'NOAA 19',
|
||||||
|
'frequency': 137.100,
|
||||||
|
'mode': 'APT',
|
||||||
|
'pipeline': 'noaa_apt',
|
||||||
|
'tle_key': 'NOAA-19',
|
||||||
|
'description': 'NOAA-19 APT (analog weather imagery)',
|
||||||
|
'active': True,
|
||||||
|
},
|
||||||
|
'METEOR-M2-3': {
|
||||||
|
'name': 'Meteor-M2-3',
|
||||||
|
'frequency': 137.900,
|
||||||
|
'mode': 'LRPT',
|
||||||
|
'pipeline': 'meteor_m2-x_lrpt',
|
||||||
|
'tle_key': 'METEOR-M2-3',
|
||||||
|
'description': 'Meteor-M2-3 LRPT (digital color imagery)',
|
||||||
|
'active': True,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# Default sample rate for weather satellite reception
|
||||||
|
DEFAULT_SAMPLE_RATE = 1000000 # 1 MHz
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WeatherSatImage:
|
||||||
|
"""Decoded weather satellite image."""
|
||||||
|
filename: str
|
||||||
|
path: Path
|
||||||
|
satellite: str
|
||||||
|
mode: str # APT or LRPT
|
||||||
|
timestamp: datetime
|
||||||
|
frequency: float
|
||||||
|
size_bytes: int = 0
|
||||||
|
product: str = '' # e.g. 'RGB', 'Thermal', 'Channel 1'
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
return {
|
||||||
|
'filename': self.filename,
|
||||||
|
'satellite': self.satellite,
|
||||||
|
'mode': self.mode,
|
||||||
|
'timestamp': self.timestamp.isoformat(),
|
||||||
|
'frequency': self.frequency,
|
||||||
|
'size_bytes': self.size_bytes,
|
||||||
|
'product': self.product,
|
||||||
|
'url': f'/weather-sat/images/{self.filename}',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CaptureProgress:
|
||||||
|
"""Weather satellite capture/decode progress update."""
|
||||||
|
status: str # 'idle', 'capturing', 'decoding', 'complete', 'error'
|
||||||
|
satellite: str = ''
|
||||||
|
frequency: float = 0.0
|
||||||
|
mode: str = ''
|
||||||
|
message: str = ''
|
||||||
|
progress_percent: int = 0
|
||||||
|
elapsed_seconds: int = 0
|
||||||
|
image: WeatherSatImage | None = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
result = {
|
||||||
|
'type': 'weather_sat_progress',
|
||||||
|
'status': self.status,
|
||||||
|
'satellite': self.satellite,
|
||||||
|
'frequency': self.frequency,
|
||||||
|
'mode': self.mode,
|
||||||
|
'message': self.message,
|
||||||
|
'progress': self.progress_percent,
|
||||||
|
'elapsed_seconds': self.elapsed_seconds,
|
||||||
|
}
|
||||||
|
if self.image:
|
||||||
|
result['image'] = self.image.to_dict()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherSatDecoder:
|
||||||
|
"""Weather satellite decoder using SatDump CLI.
|
||||||
|
|
||||||
|
Manages live SDR capture and decoding of NOAA APT and Meteor LRPT
|
||||||
|
satellite transmissions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, output_dir: str | Path | None = None):
|
||||||
|
self._process: subprocess.Popen | None = None
|
||||||
|
self._running = False
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._callback: Callable[[CaptureProgress], None] | None = None
|
||||||
|
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
|
||||||
|
self._images: list[WeatherSatImage] = []
|
||||||
|
self._reader_thread: threading.Thread | None = None
|
||||||
|
self._watcher_thread: threading.Thread | None = None
|
||||||
|
self._current_satellite: str = ''
|
||||||
|
self._current_frequency: float = 0.0
|
||||||
|
self._current_mode: str = ''
|
||||||
|
self._capture_start_time: float = 0
|
||||||
|
self._device_index: int = 0
|
||||||
|
self._capture_output_dir: Path | None = None
|
||||||
|
|
||||||
|
# Ensure output directory exists
|
||||||
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Detect available decoder
|
||||||
|
self._decoder = self._detect_decoder()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_running(self) -> bool:
|
||||||
|
return self._running
|
||||||
|
|
||||||
|
@property
|
||||||
|
def decoder_available(self) -> str | None:
|
||||||
|
"""Return name of available decoder or None."""
|
||||||
|
return self._decoder
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_satellite(self) -> str:
|
||||||
|
return self._current_satellite
|
||||||
|
|
||||||
|
@property
|
||||||
|
def current_frequency(self) -> float:
|
||||||
|
return self._current_frequency
|
||||||
|
|
||||||
|
def _detect_decoder(self) -> str | None:
|
||||||
|
"""Detect which weather satellite decoder is available."""
|
||||||
|
if shutil.which('satdump'):
|
||||||
|
logger.info("SatDump decoder detected")
|
||||||
|
return 'satdump'
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
"SatDump not found. Install SatDump for weather satellite decoding. "
|
||||||
|
"See: https://github.com/SatDump/SatDump"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
|
||||||
|
"""Set callback for capture progress updates."""
|
||||||
|
self._callback = callback
|
||||||
|
|
||||||
|
def start(
|
||||||
|
self,
|
||||||
|
satellite: str,
|
||||||
|
device_index: int = 0,
|
||||||
|
gain: float = 40.0,
|
||||||
|
sample_rate: int = DEFAULT_SAMPLE_RATE,
|
||||||
|
bias_t: bool = False,
|
||||||
|
) -> bool:
|
||||||
|
"""Start weather satellite capture and decode.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
satellite: Satellite key (e.g. 'NOAA-18', 'METEOR-M2-3')
|
||||||
|
device_index: RTL-SDR device index
|
||||||
|
gain: SDR gain in dB
|
||||||
|
sample_rate: Sample rate in Hz
|
||||||
|
bias_t: Enable bias-T power for LNA
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if started successfully
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
if self._running:
|
||||||
|
return True
|
||||||
|
|
||||||
|
if not self._decoder:
|
||||||
|
logger.error("No weather satellite decoder available")
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='error',
|
||||||
|
message='SatDump not installed. Build from source or install via package manager.'
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
|
sat_info = WEATHER_SATELLITES.get(satellite)
|
||||||
|
if not sat_info:
|
||||||
|
logger.error(f"Unknown satellite: {satellite}")
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='error',
|
||||||
|
message=f'Unknown satellite: {satellite}'
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
|
self._current_satellite = satellite
|
||||||
|
self._current_frequency = sat_info['frequency']
|
||||||
|
self._current_mode = sat_info['mode']
|
||||||
|
self._device_index = device_index
|
||||||
|
self._capture_start_time = time.time()
|
||||||
|
|
||||||
|
try:
|
||||||
|
self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t)
|
||||||
|
self._running = True
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Weather satellite capture started: {satellite} "
|
||||||
|
f"({sat_info['frequency']} MHz, {sat_info['mode']})"
|
||||||
|
)
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='capturing',
|
||||||
|
satellite=satellite,
|
||||||
|
frequency=sat_info['frequency'],
|
||||||
|
mode=sat_info['mode'],
|
||||||
|
message=f"Capturing {sat_info['name']} on {sat_info['frequency']} MHz ({sat_info['mode']})..."
|
||||||
|
))
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to start weather satellite capture: {e}")
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='error',
|
||||||
|
satellite=satellite,
|
||||||
|
message=str(e)
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _start_satdump(
|
||||||
|
self,
|
||||||
|
sat_info: dict,
|
||||||
|
device_index: int,
|
||||||
|
gain: float,
|
||||||
|
sample_rate: int,
|
||||||
|
bias_t: bool,
|
||||||
|
) -> None:
|
||||||
|
"""Start SatDump live capture and decode."""
|
||||||
|
# Create timestamped output directory for this capture
|
||||||
|
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||||
|
sat_name = sat_info['tle_key'].replace(' ', '_')
|
||||||
|
self._capture_output_dir = self._output_dir / f"{sat_name}_{timestamp}"
|
||||||
|
self._capture_output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
freq_hz = int(sat_info['frequency'] * 1_000_000)
|
||||||
|
|
||||||
|
cmd = [
|
||||||
|
'satdump', 'live',
|
||||||
|
sat_info['pipeline'],
|
||||||
|
'rtlsdr',
|
||||||
|
'--source_id', str(device_index),
|
||||||
|
'--frequency', str(freq_hz),
|
||||||
|
'--samplerate', str(sample_rate),
|
||||||
|
'--gain', str(gain),
|
||||||
|
'--output_folder', str(self._capture_output_dir),
|
||||||
|
]
|
||||||
|
|
||||||
|
if bias_t:
|
||||||
|
cmd.append('--bias')
|
||||||
|
|
||||||
|
logger.info(f"Starting SatDump: {' '.join(cmd)}")
|
||||||
|
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
cmd,
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT,
|
||||||
|
text=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Start reader thread to monitor output
|
||||||
|
self._reader_thread = threading.Thread(
|
||||||
|
target=self._read_satdump_output, daemon=True
|
||||||
|
)
|
||||||
|
self._reader_thread.start()
|
||||||
|
|
||||||
|
# Start image watcher thread
|
||||||
|
self._watcher_thread = threading.Thread(
|
||||||
|
target=self._watch_images, daemon=True
|
||||||
|
)
|
||||||
|
self._watcher_thread.start()
|
||||||
|
|
||||||
|
def _read_satdump_output(self) -> None:
|
||||||
|
"""Read SatDump stdout/stderr for progress updates."""
|
||||||
|
if not self._process or not self._process.stdout:
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
for line in iter(self._process.stdout.readline, ''):
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
line = line.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
|
||||||
|
logger.debug(f"satdump: {line}")
|
||||||
|
|
||||||
|
# Parse progress from SatDump output
|
||||||
|
elapsed = int(time.time() - self._capture_start_time)
|
||||||
|
|
||||||
|
# SatDump outputs progress info - parse key indicators
|
||||||
|
if 'Progress' in line or 'progress' in line:
|
||||||
|
# Try to extract percentage
|
||||||
|
match = re.search(r'(\d+(?:\.\d+)?)\s*%', line)
|
||||||
|
pct = int(float(match.group(1))) if match else 0
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='decoding',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=line,
|
||||||
|
progress_percent=pct,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
))
|
||||||
|
elif 'Saved' in line or 'saved' in line or 'Writing' in line:
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='decoding',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=line,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
))
|
||||||
|
elif 'error' in line.lower() or 'fail' in line.lower():
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='capturing',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=line,
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
# Generic progress update every ~10 seconds
|
||||||
|
if elapsed % 10 == 0:
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='capturing',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=f"Capturing... ({elapsed}s elapsed)",
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error reading SatDump output: {e}")
|
||||||
|
finally:
|
||||||
|
# Process ended
|
||||||
|
if self._running:
|
||||||
|
self._running = False
|
||||||
|
elapsed = int(time.time() - self._capture_start_time)
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='complete',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=f"Capture complete ({elapsed}s)",
|
||||||
|
elapsed_seconds=elapsed,
|
||||||
|
))
|
||||||
|
|
||||||
|
def _watch_images(self) -> None:
|
||||||
|
"""Watch output directory for new decoded images."""
|
||||||
|
if not self._capture_output_dir:
|
||||||
|
return
|
||||||
|
|
||||||
|
known_files: set[str] = set()
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Recursively scan for image files
|
||||||
|
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||||
|
for filepath in self._capture_output_dir.rglob(ext):
|
||||||
|
if filepath.name in known_files:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip tiny files (likely incomplete)
|
||||||
|
try:
|
||||||
|
stat = filepath.stat()
|
||||||
|
if stat.st_size < 1000:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
known_files.add(filepath.name)
|
||||||
|
|
||||||
|
# Determine product type from filename/path
|
||||||
|
product = self._parse_product_name(filepath)
|
||||||
|
|
||||||
|
# Copy image to main output dir for serving
|
||||||
|
serve_name = f"{self._current_satellite}_{filepath.stem}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.png"
|
||||||
|
serve_path = self._output_dir / serve_name
|
||||||
|
try:
|
||||||
|
shutil.copy2(filepath, serve_path)
|
||||||
|
except OSError:
|
||||||
|
serve_path = filepath
|
||||||
|
serve_name = filepath.name
|
||||||
|
|
||||||
|
image = WeatherSatImage(
|
||||||
|
filename=serve_name,
|
||||||
|
path=serve_path,
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
mode=self._current_mode,
|
||||||
|
timestamp=datetime.now(timezone.utc),
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
size_bytes=stat.st_size,
|
||||||
|
product=product,
|
||||||
|
)
|
||||||
|
self._images.append(image)
|
||||||
|
|
||||||
|
logger.info(f"New weather satellite image: {serve_name} ({product})")
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='complete',
|
||||||
|
satellite=self._current_satellite,
|
||||||
|
frequency=self._current_frequency,
|
||||||
|
mode=self._current_mode,
|
||||||
|
message=f'Image decoded: {product}',
|
||||||
|
image=image,
|
||||||
|
))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error watching images: {e}")
|
||||||
|
|
||||||
|
def _parse_product_name(self, filepath: Path) -> str:
|
||||||
|
"""Parse a human-readable product name from the image filepath."""
|
||||||
|
name = filepath.stem.lower()
|
||||||
|
parts = filepath.parts
|
||||||
|
|
||||||
|
# Common SatDump product names
|
||||||
|
if 'rgb' in name:
|
||||||
|
return 'RGB Composite'
|
||||||
|
if 'msa' in name or 'multispectral' in name:
|
||||||
|
return 'Multispectral Analysis'
|
||||||
|
if 'thermal' in name or 'temp' in name:
|
||||||
|
return 'Thermal'
|
||||||
|
if 'ndvi' in name:
|
||||||
|
return 'NDVI Vegetation'
|
||||||
|
if 'channel' in name or 'ch' in name:
|
||||||
|
match = re.search(r'(?:channel|ch)\s*(\d+)', name)
|
||||||
|
if match:
|
||||||
|
return f'Channel {match.group(1)}'
|
||||||
|
if 'avhrr' in name:
|
||||||
|
return 'AVHRR'
|
||||||
|
if 'msu' in name or 'mtvza' in name:
|
||||||
|
return 'MSU-MR'
|
||||||
|
|
||||||
|
# Check parent directories for clues
|
||||||
|
for part in parts:
|
||||||
|
if 'rgb' in part.lower():
|
||||||
|
return 'RGB Composite'
|
||||||
|
if 'channel' in part.lower():
|
||||||
|
return 'Channel Data'
|
||||||
|
|
||||||
|
return filepath.stem
|
||||||
|
|
||||||
|
def stop(self) -> None:
|
||||||
|
"""Stop weather satellite capture."""
|
||||||
|
with self._lock:
|
||||||
|
self._running = False
|
||||||
|
|
||||||
|
if self._process:
|
||||||
|
try:
|
||||||
|
self._process.terminate()
|
||||||
|
self._process.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self._process.kill()
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
self._process.kill()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._process = None
|
||||||
|
|
||||||
|
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||||
|
logger.info(f"Weather satellite capture stopped after {elapsed}s")
|
||||||
|
|
||||||
|
def get_images(self) -> list[WeatherSatImage]:
|
||||||
|
"""Get list of decoded images."""
|
||||||
|
self._scan_images()
|
||||||
|
return list(self._images)
|
||||||
|
|
||||||
|
def _scan_images(self) -> None:
|
||||||
|
"""Scan output directory for images not yet tracked."""
|
||||||
|
known_filenames = {img.filename for img in self._images}
|
||||||
|
|
||||||
|
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||||
|
for filepath in self._output_dir.glob(ext):
|
||||||
|
if filepath.name in known_filenames:
|
||||||
|
continue
|
||||||
|
# Skip tiny files
|
||||||
|
try:
|
||||||
|
stat = filepath.stat()
|
||||||
|
if stat.st_size < 1000:
|
||||||
|
continue
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parse satellite name from filename
|
||||||
|
satellite = 'Unknown'
|
||||||
|
for sat_key in WEATHER_SATELLITES:
|
||||||
|
if sat_key in filepath.name:
|
||||||
|
satellite = sat_key
|
||||||
|
break
|
||||||
|
|
||||||
|
sat_info = WEATHER_SATELLITES.get(satellite, {})
|
||||||
|
|
||||||
|
image = WeatherSatImage(
|
||||||
|
filename=filepath.name,
|
||||||
|
path=filepath,
|
||||||
|
satellite=satellite,
|
||||||
|
mode=sat_info.get('mode', 'Unknown'),
|
||||||
|
timestamp=datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc),
|
||||||
|
frequency=sat_info.get('frequency', 0.0),
|
||||||
|
size_bytes=stat.st_size,
|
||||||
|
product=self._parse_product_name(filepath),
|
||||||
|
)
|
||||||
|
self._images.append(image)
|
||||||
|
|
||||||
|
def delete_image(self, filename: str) -> bool:
|
||||||
|
"""Delete a decoded image."""
|
||||||
|
filepath = self._output_dir / filename
|
||||||
|
if filepath.exists():
|
||||||
|
try:
|
||||||
|
filepath.unlink()
|
||||||
|
self._images = [img for img in self._images if img.filename != filename]
|
||||||
|
return True
|
||||||
|
except OSError as e:
|
||||||
|
logger.error(f"Failed to delete image {filename}: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _emit_progress(self, progress: CaptureProgress) -> None:
|
||||||
|
"""Emit progress update to callback."""
|
||||||
|
if self._callback:
|
||||||
|
try:
|
||||||
|
self._callback(progress)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error in progress callback: {e}")
|
||||||
|
|
||||||
|
def get_status(self) -> dict:
|
||||||
|
"""Get current decoder status."""
|
||||||
|
elapsed = 0
|
||||||
|
if self._running and self._capture_start_time:
|
||||||
|
elapsed = int(time.time() - self._capture_start_time)
|
||||||
|
|
||||||
|
return {
|
||||||
|
'available': self._decoder is not None,
|
||||||
|
'decoder': self._decoder,
|
||||||
|
'running': self._running,
|
||||||
|
'satellite': self._current_satellite,
|
||||||
|
'frequency': self._current_frequency,
|
||||||
|
'mode': self._current_mode,
|
||||||
|
'elapsed_seconds': elapsed,
|
||||||
|
'image_count': len(self._images),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# Global decoder instance
|
||||||
|
_decoder: WeatherSatDecoder | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_weather_sat_decoder() -> WeatherSatDecoder:
|
||||||
|
"""Get or create the global weather satellite decoder instance."""
|
||||||
|
global _decoder
|
||||||
|
if _decoder is None:
|
||||||
|
_decoder = WeatherSatDecoder()
|
||||||
|
return _decoder
|
||||||
|
|
||||||
|
|
||||||
|
def is_weather_sat_available() -> bool:
|
||||||
|
"""Check if weather satellite decoding is available."""
|
||||||
|
decoder = get_weather_sat_decoder()
|
||||||
|
return decoder.decoder_available is not None
|
||||||
Reference in New Issue
Block a user