mirror of
https://github.com/smittix/intercept.git
synced 2026-06-15 09:03:38 -07:00
824 lines
25 KiB
Python
824 lines
25 KiB
Python
"""ISS SSTV (Slow-Scan Television) decoder routes.
|
|
|
|
Provides endpoints for decoding SSTV images from the International Space Station.
|
|
ISS SSTV events occur during special commemorations and typically transmit on 145.800 MHz FM.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import queue
|
|
import threading
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from flask import Blueprint, Response, jsonify, request, send_file
|
|
|
|
import app as app_module
|
|
from utils.event_pipeline import process_event
|
|
from utils.logging import get_logger
|
|
from utils.responses import api_error
|
|
from utils.sse import sse_stream_fanout
|
|
from utils.sstv import (
|
|
ISS_SSTV_FREQ,
|
|
get_sstv_decoder,
|
|
is_sstv_available,
|
|
)
|
|
|
|
logger = get_logger('intercept.sstv')
|
|
|
|
sstv_bp = Blueprint('sstv', __name__, url_prefix='/sstv')
|
|
|
|
# ISS SSTV runs on a fixed downlink; allow a small entry tolerance so users
|
|
# can type nearby values and still land on the canonical center frequency.
|
|
ISS_SSTV_MODULATION = 'fm'
|
|
ISS_SSTV_FREQUENCIES = (ISS_SSTV_FREQ,)
|
|
ISS_SSTV_FREQ_TOLERANCE_MHZ = 0.05
|
|
|
|
# Queue for SSE progress streaming
|
|
_sstv_queue: queue.Queue = queue.Queue(maxsize=100)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Caching — ISS position (external API) and schedule (skyfield computation)
|
|
# ---------------------------------------------------------------------------
|
|
_iss_position_cache: dict | None = None
|
|
_iss_position_cache_time: float = 0
|
|
_iss_position_lock = threading.Lock()
|
|
ISS_POSITION_CACHE_TTL = 10 # seconds
|
|
|
|
_iss_schedule_cache: dict | None = None
|
|
_iss_schedule_cache_time: float = 0
|
|
_iss_schedule_cache_key: str | None = None
|
|
_iss_schedule_lock = threading.Lock()
|
|
ISS_SCHEDULE_CACHE_TTL = 900 # 15 minutes
|
|
|
|
# Reusable skyfield timescale (expensive to create)
|
|
_timescale = None
|
|
_timescale_lock = threading.Lock()
|
|
|
|
# Track which device is being used
|
|
sstv_active_device: int | None = None
|
|
sstv_active_sdr_type: str = 'rtlsdr'
|
|
|
|
|
|
def _progress_callback(data: dict) -> None:
|
|
"""Callback to queue progress/scope updates for SSE stream."""
|
|
try:
|
|
_sstv_queue.put_nowait(data)
|
|
except queue.Full:
|
|
try:
|
|
_sstv_queue.get_nowait()
|
|
_sstv_queue.put_nowait(data)
|
|
except queue.Empty:
|
|
pass
|
|
|
|
|
|
def _normalize_iss_frequency(frequency_mhz: float) -> float | None:
|
|
"""Snap near-match user input to a supported ISS SSTV center frequency."""
|
|
for supported in ISS_SSTV_FREQUENCIES:
|
|
if abs(frequency_mhz - supported) <= ISS_SSTV_FREQ_TOLERANCE_MHZ:
|
|
return supported
|
|
return None
|
|
|
|
|
|
@sstv_bp.route('/status')
|
|
def get_status():
|
|
"""
|
|
Get SSTV decoder status.
|
|
|
|
Returns:
|
|
JSON with decoder availability and current status.
|
|
"""
|
|
available = is_sstv_available()
|
|
decoder = get_sstv_decoder()
|
|
|
|
result = {
|
|
'available': available,
|
|
'decoder': decoder.decoder_available,
|
|
'running': decoder.is_running,
|
|
'iss_frequency': ISS_SSTV_FREQ,
|
|
'modulation': ISS_SSTV_MODULATION,
|
|
'image_count': len(decoder.get_images()),
|
|
'doppler_enabled': decoder.doppler_enabled,
|
|
}
|
|
|
|
# Include Doppler info if available
|
|
doppler_info = decoder.last_doppler_info
|
|
if doppler_info:
|
|
result['doppler'] = doppler_info.to_dict()
|
|
|
|
return jsonify(result)
|
|
|
|
|
|
@sstv_bp.route('/start', methods=['POST'])
|
|
def start_decoder():
|
|
"""
|
|
Start SSTV decoder.
|
|
|
|
JSON body (optional):
|
|
{
|
|
"frequency": 145.800, // Frequency in MHz (default: ISS 145.800)
|
|
"modulation": "fm", // ISS mode is FM-only
|
|
"device": 0, // RTL-SDR device index
|
|
"latitude": 40.7128, // Observer latitude for Doppler correction
|
|
"longitude": -74.0060 // Observer longitude for Doppler correction
|
|
}
|
|
|
|
If latitude and longitude are provided, real-time Doppler shift compensation
|
|
will be enabled, which improves reception by tracking the ISS frequency shift
|
|
as it passes overhead (up to ±3.5 kHz at 145.800 MHz).
|
|
|
|
Returns:
|
|
JSON with start status.
|
|
"""
|
|
if not is_sstv_available():
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'SSTV decoder not available. Install numpy and Pillow: pip install numpy Pillow'
|
|
}), 400
|
|
|
|
decoder = get_sstv_decoder()
|
|
|
|
if decoder.is_running:
|
|
return jsonify({
|
|
'status': 'already_running',
|
|
'frequency': ISS_SSTV_FREQ,
|
|
'modulation': ISS_SSTV_MODULATION,
|
|
'doppler_enabled': decoder.doppler_enabled
|
|
})
|
|
|
|
# Clear queue
|
|
while not _sstv_queue.empty():
|
|
try:
|
|
_sstv_queue.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
|
|
# Get parameters
|
|
data = request.get_json(silent=True) or {}
|
|
sdr_type_str = data.get('sdr_type', 'rtlsdr')
|
|
|
|
if sdr_type_str != 'rtlsdr':
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'{sdr_type_str.replace("_", " ").title()} is not yet supported for this mode. Please use an RTL-SDR device.'
|
|
}), 400
|
|
|
|
frequency = data.get('frequency', ISS_SSTV_FREQ)
|
|
modulation = str(data.get('modulation', ISS_SSTV_MODULATION)).strip().lower()
|
|
device_index = data.get('device', 0)
|
|
latitude = data.get('latitude')
|
|
longitude = data.get('longitude')
|
|
|
|
# Validate modulation (ISS mode is FM-only)
|
|
if modulation != ISS_SSTV_MODULATION:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Modulation must be {ISS_SSTV_MODULATION} for ISS SSTV mode'
|
|
}), 400
|
|
|
|
# Validate frequency
|
|
try:
|
|
frequency = float(frequency)
|
|
normalized_frequency = _normalize_iss_frequency(frequency)
|
|
if normalized_frequency is None:
|
|
supported = ', '.join(f'{freq:.3f}' for freq in ISS_SSTV_FREQUENCIES)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': f'Supported ISS SSTV frequency: {supported} MHz FM'
|
|
}), 400
|
|
frequency = normalized_frequency
|
|
except (TypeError, ValueError):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Invalid frequency'
|
|
}), 400
|
|
|
|
# Validate location if provided
|
|
if latitude is not None and longitude is not None:
|
|
try:
|
|
latitude = float(latitude)
|
|
longitude = float(longitude)
|
|
if not (-90 <= latitude <= 90):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Latitude must be between -90 and 90'
|
|
}), 400
|
|
if not (-180 <= longitude <= 180):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Longitude must be between -180 and 180'
|
|
}), 400
|
|
except (TypeError, ValueError):
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Invalid latitude or longitude'
|
|
}), 400
|
|
else:
|
|
latitude = None
|
|
longitude = None
|
|
|
|
# Claim SDR device
|
|
global sstv_active_device, sstv_active_sdr_type
|
|
device_int = int(device_index)
|
|
error = app_module.claim_sdr_device(device_int, 'sstv', sdr_type_str)
|
|
if error:
|
|
return jsonify({
|
|
'status': 'error',
|
|
'error_type': 'DEVICE_BUSY',
|
|
'message': error
|
|
}), 409
|
|
|
|
# Set callback and start
|
|
decoder.set_callback(_progress_callback)
|
|
success = decoder.start(
|
|
frequency=frequency,
|
|
device_index=device_index,
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
modulation=ISS_SSTV_MODULATION,
|
|
)
|
|
|
|
if success:
|
|
sstv_active_device = device_int
|
|
sstv_active_sdr_type = sdr_type_str
|
|
|
|
result = {
|
|
'status': 'started',
|
|
'frequency': frequency,
|
|
'modulation': ISS_SSTV_MODULATION,
|
|
'device': device_index,
|
|
'doppler_enabled': decoder.doppler_enabled
|
|
}
|
|
|
|
# Include initial Doppler info if available
|
|
if decoder.doppler_enabled and decoder.last_doppler_info:
|
|
result['doppler'] = decoder.last_doppler_info.to_dict()
|
|
|
|
return jsonify(result)
|
|
else:
|
|
# Release device on failure
|
|
app_module.release_sdr_device(device_int, sdr_type_str)
|
|
return jsonify({
|
|
'status': 'error',
|
|
'message': 'Failed to start decoder'
|
|
}), 500
|
|
|
|
|
|
@sstv_bp.route('/stop', methods=['POST'])
|
|
def stop_decoder():
|
|
"""
|
|
Stop SSTV decoder.
|
|
|
|
Returns:
|
|
JSON confirmation.
|
|
"""
|
|
global sstv_active_device, sstv_active_sdr_type
|
|
decoder = get_sstv_decoder()
|
|
decoder.stop()
|
|
|
|
# Release device from registry
|
|
if sstv_active_device is not None:
|
|
app_module.release_sdr_device(sstv_active_device, sstv_active_sdr_type)
|
|
sstv_active_device = None
|
|
|
|
return jsonify({'status': 'stopped'})
|
|
|
|
|
|
@sstv_bp.route('/doppler')
|
|
def get_doppler():
|
|
"""
|
|
Get current Doppler shift information.
|
|
|
|
Returns real-time Doppler shift data if tracking is enabled.
|
|
|
|
Returns:
|
|
JSON with Doppler shift information.
|
|
"""
|
|
decoder = get_sstv_decoder()
|
|
|
|
if not decoder.doppler_enabled:
|
|
return jsonify({
|
|
'status': 'disabled',
|
|
'message': 'Doppler tracking not enabled. Provide latitude/longitude when starting decoder.'
|
|
})
|
|
|
|
doppler_info = decoder.last_doppler_info
|
|
if not doppler_info:
|
|
return jsonify({
|
|
'status': 'unavailable',
|
|
'message': 'Doppler data not yet available'
|
|
})
|
|
|
|
return jsonify({
|
|
'status': 'ok',
|
|
'doppler': doppler_info.to_dict(),
|
|
'nominal_frequency_mhz': ISS_SSTV_FREQ,
|
|
'corrected_frequency_mhz': doppler_info.frequency_hz / 1_000_000
|
|
})
|
|
|
|
|
|
@sstv_bp.route('/images')
|
|
def list_images():
|
|
"""
|
|
Get list of decoded SSTV images.
|
|
|
|
Query parameters:
|
|
limit: Maximum number of images to return (default: all)
|
|
|
|
Returns:
|
|
JSON with list of decoded images.
|
|
"""
|
|
decoder = get_sstv_decoder()
|
|
images = decoder.get_images()
|
|
|
|
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)
|
|
})
|
|
|
|
|
|
@sstv_bp.route('/images/<filename>')
|
|
def get_image(filename: str):
|
|
"""
|
|
Get a decoded SSTV image file.
|
|
|
|
Args:
|
|
filename: Image filename
|
|
|
|
Returns:
|
|
Image file or 404.
|
|
"""
|
|
decoder = get_sstv_decoder()
|
|
|
|
# Security: only allow alphanumeric filenames with .png extension
|
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
|
return api_error('Invalid filename', 400)
|
|
|
|
if not filename.endswith('.png'):
|
|
return api_error('Only PNG files supported', 400)
|
|
|
|
# Find image in decoder's output directory
|
|
image_path = decoder._output_dir / filename
|
|
|
|
if not image_path.exists():
|
|
return api_error('Image not found', 404)
|
|
|
|
return send_file(image_path, mimetype='image/png')
|
|
|
|
|
|
@sstv_bp.route('/images/<filename>/download')
|
|
def download_image(filename: str):
|
|
"""
|
|
Download a decoded SSTV image file.
|
|
|
|
Args:
|
|
filename: Image filename
|
|
|
|
Returns:
|
|
Image file as attachment or 404.
|
|
"""
|
|
decoder = get_sstv_decoder()
|
|
|
|
# Security: only allow alphanumeric filenames with .png extension
|
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
|
return api_error('Invalid filename', 400)
|
|
|
|
if not filename.endswith('.png'):
|
|
return api_error('Only PNG files supported', 400)
|
|
|
|
image_path = decoder._output_dir / filename
|
|
|
|
if not image_path.exists():
|
|
return api_error('Image not found', 404)
|
|
|
|
return send_file(image_path, mimetype='image/png', as_attachment=True, download_name=filename)
|
|
|
|
|
|
@sstv_bp.route('/images/<filename>', methods=['DELETE'])
|
|
def delete_image(filename: str):
|
|
"""
|
|
Delete a decoded SSTV image.
|
|
|
|
Args:
|
|
filename: Image filename
|
|
|
|
Returns:
|
|
JSON confirmation.
|
|
"""
|
|
decoder = get_sstv_decoder()
|
|
|
|
# Security: only allow alphanumeric filenames with .png extension
|
|
if not filename.replace('_', '').replace('-', '').replace('.', '').isalnum():
|
|
return api_error('Invalid filename', 400)
|
|
|
|
if not filename.endswith('.png'):
|
|
return api_error('Only PNG files supported', 400)
|
|
|
|
if decoder.delete_image(filename):
|
|
return jsonify({'status': ' |