mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
1. utils/weather_sat.py — Added delete_all_images() method that globs for *.png, *.jpg, *.jpeg in the output dir, unlinks each, clears _images list, and returns the
count.
2. routes/weather_sat.py — Added DELETE /weather-sat/images route that calls decoder.delete_all_images() and returns {'status': 'ok', 'deleted': count}.
3. static/js/modes/weather-satellite.js:
- Added currentModalFilename state variable
- renderGallery() now sorts images by timestamp descending, groups by date using toLocaleDateString(), renders date headers spanning the grid, and adds a delete
overlay button on each card
- showImage() accepts a filename param, stores it in currentModalFilename, and creates a modal toolbar with a delete button
- Added deleteImage(filename) — confirm dialog → DELETE /weather-sat/images/{filename} → filter from array → re-render + close modal
- Added deleteAllImages() — confirm dialog → DELETE /weather-sat/images → clear array → re-render
- Exposed deleteImage, deleteAllImages, and _getModalFilename in public API
4. static/css/modes/weather-satellite.css:
- Added position: relative to .wxsat-image-card
- .wxsat-image-actions — absolute top-right overlay, hidden by default, appears on card hover
- .wxsat-image-actions button — dark background, turns red on hover
- .wxsat-date-header — full-grid-width date separator with dimmed uppercase text
- .wxsat-modal-toolbar — absolute top-left in modal for the delete button
- .wxsat-modal-btn.delete — turns red on hover
- .wxsat-gallery-clear-btn — subtle icon button, pushed right via margin-left: auto, turns red on hover
- Updated .wxsat-gallery-header from justify-content: space-between to gap: 8px for proper 3-child layout
5. templates/index.html — Added clear-all trash button with SVG icon in the gallery header, wired to WeatherSat.deleteAllImages().
535 lines
16 KiB
Python
535 lines
16 KiB
Python
"""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 on-complete handler for SDR release
|
|
decoder.set_callback(_progress_callback)
|
|
|
|
def _release_device():
|
|
try:
|
|
import app as app_module
|
|
app_module.release_sdr_device(device_index)
|
|
except ImportError:
|
|
pass
|
|
|
|
decoder.set_on_complete(_release_device)
|
|
|
|
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
|
|
_release_device()
|
|
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('/images', methods=['DELETE'])
|
|
def delete_all_images():
|
|
"""Delete all decoded weather satellite images.
|
|
|
|
Returns:
|
|
JSON with count of deleted images.
|
|
"""
|
|
decoder = get_weather_sat_decoder()
|
|
count = decoder.delete_all_images()
|
|
return jsonify({'status': 'ok', 'deleted': count})
|
|
|
|
|
|
@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)
|
|
trajectory: Include az/el trajectory points (default: false)
|
|
ground_track: Include lat/lon ground track points (default: false)
|
|
|
|
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)
|
|
include_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
|
|
include_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
|
|
|
|
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 utils.weather_sat_predict import predict_passes
|
|
|
|
all_passes = predict_passes(
|
|
lat=lat,
|
|
lon=lon,
|
|
hours=hours,
|
|
min_elevation=min_elevation,
|
|
include_trajectory=include_trajectory,
|
|
include_ground_track=include_ground_track,
|
|
)
|
|
|
|
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
|
|
|
|
|
|
# ========================
|
|
# Auto-Scheduler Endpoints
|
|
# ========================
|
|
|
|
|
|
def _scheduler_event_callback(event: dict) -> None:
|
|
"""Forward scheduler events to the SSE queue."""
|
|
try:
|
|
_weather_sat_queue.put_nowait(event)
|
|
except queue.Full:
|
|
try:
|
|
_weather_sat_queue.get_nowait()
|
|
_weather_sat_queue.put_nowait(event)
|
|
except queue.Empty:
|
|
pass
|
|
|
|
|
|
@weather_sat_bp.route('/schedule/enable', methods=['POST'])
|
|
def enable_schedule():
|
|
"""Enable auto-scheduling of weather satellite captures.
|
|
|
|
JSON body:
|
|
{
|
|
"latitude": 51.5, // Required
|
|
"longitude": -0.1, // Required
|
|
"min_elevation": 15, // Minimum pass elevation (default: 15)
|
|
"device": 0, // RTL-SDR device index (default: 0)
|
|
"gain": 40.0, // SDR gain (default: 40)
|
|
"bias_t": false // Enable bias-T (default: false)
|
|
}
|
|
|
|
Returns:
|
|
JSON with scheduler status.
|
|
"""
|
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
|
|
|
data = request.get_json(silent=True) or {}
|
|
|
|
lat = data.get('latitude')
|
|
lon = data.get('longitude')
|
|
|
|
if lat is None or lon is None:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'latitude and longitude required'
|
|
}), 400
|
|
|
|
try:
|
|
lat = float(lat)
|
|
lon = float(lon)
|
|
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
|
raise ValueError
|
|
except (TypeError, ValueError):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Invalid coordinates'
|
|
}), 400
|
|
|
|
scheduler = get_weather_sat_scheduler()
|
|
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
|
|
|
|
result = scheduler.enable(
|
|
lat=lat,
|
|
lon=lon,
|
|
min_elevation=float(data.get('min_elevation', 15)),
|
|
device=int(data.get('device', 0)),
|
|
gain=float(data.get('gain', 40.0)),
|
|
bias_t=bool(data.get('bias_t', False)),
|
|
)
|
|
|
|
return jsonify({'status': 'ok', **result})
|
|
|
|
|
|
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
|
|
def disable_schedule():
|
|
"""Disable auto-scheduling."""
|
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
|
|
|
scheduler = get_weather_sat_scheduler()
|
|
result = scheduler.disable()
|
|
return jsonify(result)
|
|
|
|
|
|
@weather_sat_bp.route('/schedule/status')
|
|
def schedule_status():
|
|
"""Get current scheduler state."""
|
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
|
|
|
scheduler = get_weather_sat_scheduler()
|
|
return jsonify(scheduler.get_status())
|
|
|
|
|
|
@weather_sat_bp.route('/schedule/passes')
|
|
def schedule_passes():
|
|
"""List scheduled passes."""
|
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
|
|
|
scheduler = get_weather_sat_scheduler()
|
|
passes = scheduler.get_passes()
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'passes': passes,
|
|
'count': len(passes),
|
|
})
|
|
|
|
|
|
@weather_sat_bp.route('/schedule/skip/<pass_id>', methods=['POST'])
|
|
def skip_pass(pass_id: str):
|
|
"""Skip a scheduled pass."""
|
|
from utils.weather_sat_scheduler import get_weather_sat_scheduler
|
|
|
|
if not pass_id.replace('_', '').replace('-', '').isalnum():
|
|
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
|
|
|
|
scheduler = get_weather_sat_scheduler()
|
|
if scheduler.skip_pass(pass_id):
|
|
return jsonify({'status': 'skipped', 'pass_id': pass_id})
|
|
else:
|
|
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404
|