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().
855 lines
32 KiB
Python
855 lines
32 KiB
Python
"""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 io
|
|
import os
|
|
import pty
|
|
import re
|
|
import select
|
|
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
|
|
log_type: str = '' # 'info', 'debug', 'progress', 'error', 'signal', 'save', 'warning'
|
|
capture_phase: str = '' # 'tuning', 'listening', 'signal_detected', 'decoding', 'complete', 'error'
|
|
|
|
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,
|
|
'log_type': self.log_type,
|
|
'capture_phase': self.capture_phase,
|
|
}
|
|
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._pty_master_fd: int | 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
|
|
self._on_complete_callback: Callable[[], None] | None = None
|
|
self._capture_phase: str = 'idle'
|
|
|
|
# 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 set_on_complete(self, callback: Callable[[], None]) -> None:
|
|
"""Set callback invoked when capture process ends (for SDR release)."""
|
|
self._on_complete_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()
|
|
self._capture_phase = 'tuning'
|
|
|
|
try:
|
|
self._running = True
|
|
self._start_satdump(sat_info, device_index, gain, sample_rate, bias_t)
|
|
|
|
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']})...",
|
|
log_type='info',
|
|
capture_phase=self._capture_phase,
|
|
))
|
|
|
|
return True
|
|
|
|
except Exception as e:
|
|
self._running = False
|
|
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)
|
|
|
|
# SatDump v1.2+ uses string source_id (device serial) not numeric index.
|
|
# Auto-detect serial by querying rtl_eeprom, fall back to string index.
|
|
source_id = self._resolve_device_id(device_index)
|
|
|
|
cmd = [
|
|
'satdump', 'live',
|
|
sat_info['pipeline'],
|
|
str(self._capture_output_dir),
|
|
'--source', 'rtlsdr',
|
|
'--samplerate', str(sample_rate),
|
|
'--frequency', str(freq_hz),
|
|
'--gain', str(int(gain)),
|
|
'--source_id', source_id,
|
|
]
|
|
|
|
if bias_t:
|
|
cmd.append('--bias')
|
|
|
|
logger.info(f"Starting SatDump: {' '.join(cmd)}")
|
|
|
|
# Use a pseudo-terminal so SatDump thinks it's writing to a real
|
|
# terminal. C/C++ runtimes disable buffering on TTYs, which lets
|
|
# us see output (including \r progress lines) in real time.
|
|
master_fd, slave_fd = pty.openpty()
|
|
self._pty_master_fd = master_fd
|
|
|
|
self._process = subprocess.Popen(
|
|
cmd,
|
|
stdout=slave_fd,
|
|
stderr=slave_fd,
|
|
stdin=subprocess.DEVNULL,
|
|
close_fds=True,
|
|
)
|
|
os.close(slave_fd) # parent doesn't need the slave side
|
|
|
|
# Check for early exit (SatDump errors out immediately)
|
|
try:
|
|
retcode = self._process.wait(timeout=3)
|
|
# Process already died — read whatever output it produced
|
|
output = b''
|
|
try:
|
|
while True:
|
|
r, _, _ = select.select([master_fd], [], [], 0.1)
|
|
if not r:
|
|
break
|
|
chunk = os.read(master_fd, 4096)
|
|
if not chunk:
|
|
break
|
|
output += chunk
|
|
except OSError:
|
|
pass
|
|
os.close(master_fd)
|
|
self._pty_master_fd = None
|
|
output_str = output.decode('utf-8', errors='replace')
|
|
error_msg = f"SatDump exited immediately (code {retcode})"
|
|
if output_str:
|
|
for line in output_str.strip().splitlines():
|
|
if 'error' in line.lower() or 'could not' in line.lower() or 'cannot' in line.lower():
|
|
error_msg = line.strip()
|
|
break
|
|
logger.error(f"SatDump output:\n{output_str}")
|
|
self._process = None
|
|
raise RuntimeError(error_msg)
|
|
except subprocess.TimeoutExpired:
|
|
# Good — process is still running after 3 seconds
|
|
pass
|
|
|
|
# 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()
|
|
|
|
@staticmethod
|
|
def _classify_log_type(line: str) -> str:
|
|
"""Classify a SatDump output line into a log type."""
|
|
lower = line.lower()
|
|
if '(e)' in lower or 'error' in lower or 'fail' in lower:
|
|
return 'error'
|
|
if 'progress' in lower and '%' in line:
|
|
return 'progress'
|
|
if 'saved' in lower or 'writing' in lower:
|
|
return 'save'
|
|
if 'detected' in lower or 'lock' in lower or 'sync' in lower:
|
|
return 'signal'
|
|
if '(w)' in lower:
|
|
return 'warning'
|
|
if '(d)' in lower:
|
|
return 'debug'
|
|
return 'info'
|
|
|
|
@staticmethod
|
|
def _resolve_device_id(device_index: int) -> str:
|
|
"""Resolve RTL-SDR device index to serial number string for SatDump v1.2+.
|
|
|
|
SatDump v1.2+ expects --source_id as a device serial string, not a
|
|
numeric index. Try to look up the serial via rtl_test, fall back to
|
|
the string representation of the index.
|
|
"""
|
|
try:
|
|
result = subprocess.run(
|
|
['rtl_test', '-d', str(device_index), '-t'],
|
|
capture_output=True, text=True, timeout=5,
|
|
)
|
|
# rtl_test outputs: "Found 2 device(s):" then
|
|
# " 0: RTLSDRBlog, Blog V4, SN: 00004000"
|
|
output = result.stdout + result.stderr
|
|
for line in output.splitlines():
|
|
# Match SN: <serial> pattern
|
|
match = re.search(r'SN:\s*(\S+)', line)
|
|
if match:
|
|
serial = match.group(1)
|
|
logger.info(f"RTL-SDR device {device_index} serial: {serial}")
|
|
return serial
|
|
# Also match "Using device #N: ..." then "Serial number is <serial>"
|
|
match = re.search(r'Serial number is\s+(\S+)', line)
|
|
if match:
|
|
serial = match.group(1)
|
|
logger.info(f"RTL-SDR device {device_index} serial: {serial}")
|
|
return serial
|
|
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
|
|
logger.debug(f"Could not detect device serial: {e}")
|
|
|
|
# Fall back to string index
|
|
return str(device_index)
|
|
|
|
def _read_pty_lines(self):
|
|
"""Read lines from the PTY master fd, splitting on \\n and \\r.
|
|
|
|
SatDump uses \\r carriage returns for progress updates. A PTY gives
|
|
us unbuffered output. We use select() to detect data availability
|
|
and os.read() for raw bytes, then split on line boundaries.
|
|
"""
|
|
master_fd = self._pty_master_fd
|
|
if master_fd is None:
|
|
return
|
|
|
|
buf = b''
|
|
while self._running:
|
|
try:
|
|
r, _, _ = select.select([master_fd], [], [], 1.0)
|
|
if not r:
|
|
# Timeout — check if process is still alive
|
|
if self._process and self._process.poll() is not None:
|
|
break
|
|
continue
|
|
chunk = os.read(master_fd, 4096)
|
|
if not chunk:
|
|
break
|
|
buf += chunk
|
|
# Split on \r and \n
|
|
while b'\n' in buf or b'\r' in buf:
|
|
# Find earliest delimiter
|
|
idx_n = buf.find(b'\n')
|
|
idx_r = buf.find(b'\r')
|
|
if idx_n == -1:
|
|
idx = idx_r
|
|
elif idx_r == -1:
|
|
idx = idx_n
|
|
else:
|
|
idx = min(idx_n, idx_r)
|
|
line = buf[:idx]
|
|
buf = buf[idx + 1:]
|
|
# Skip empty lines
|
|
text = line.decode('utf-8', errors='replace').strip()
|
|
# Strip ANSI escape codes that terminals produce
|
|
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
|
if text:
|
|
yield text
|
|
except OSError:
|
|
break
|
|
# Drain remaining buffer
|
|
text = buf.decode('utf-8', errors='replace').strip()
|
|
if text:
|
|
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
|
|
if text:
|
|
yield text
|
|
|
|
def _read_satdump_output(self) -> None:
|
|
"""Read SatDump stdout/stderr for progress updates."""
|
|
if not self._process or self._pty_master_fd is None:
|
|
return
|
|
|
|
last_emit_time = 0.0
|
|
|
|
try:
|
|
for line in self._read_pty_lines():
|
|
if not self._running:
|
|
break
|
|
|
|
line = line.strip()
|
|
if not line:
|
|
continue
|
|
|
|
logger.debug(f"satdump: {line}")
|
|
|
|
elapsed = int(time.time() - self._capture_start_time)
|
|
now = time.time()
|
|
log_type = self._classify_log_type(line)
|
|
|
|
# Track phase transitions
|
|
lower = line.lower()
|
|
if log_type == 'signal':
|
|
self._capture_phase = 'signal_detected'
|
|
elif log_type == 'progress':
|
|
self._capture_phase = 'decoding'
|
|
elif self._capture_phase == 'tuning' and (
|
|
'freq' in lower or 'processing' in lower
|
|
or 'starting' in lower or 'source' in lower
|
|
):
|
|
self._capture_phase = 'listening'
|
|
|
|
# Parse progress from SatDump output
|
|
if log_type == 'progress':
|
|
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,
|
|
log_type=log_type,
|
|
capture_phase=self._capture_phase,
|
|
))
|
|
last_emit_time = now
|
|
elif log_type == 'save':
|
|
self._emit_progress(CaptureProgress(
|
|
status='decoding',
|
|
satellite=self._current_satellite,
|
|
frequency=self._current_frequency,
|
|
mode=self._current_mode,
|
|
message=line,
|
|
elapsed_seconds=elapsed,
|
|
log_type=log_type,
|
|
capture_phase=self._capture_phase,
|
|
))
|
|
last_emit_time = now
|
|
elif log_type == 'error':
|
|
self._emit_progress(CaptureProgress(
|
|
status='capturing',
|
|
satellite=self._current_satellite,
|
|
frequency=self._current_frequency,
|
|
mode=self._current_mode,
|
|
message=line,
|
|
elapsed_seconds=elapsed,
|
|
log_type=log_type,
|
|
capture_phase=self._capture_phase,
|
|
))
|
|
last_emit_time = now
|
|
elif log_type == 'signal':
|
|
self._emit_progress(CaptureProgress(
|
|
status='capturing',
|
|
satellite=self._current_satellite,
|
|
frequency=self._current_frequency,
|
|
mode=self._current_mode,
|
|
message=line,
|
|
elapsed_seconds=elapsed,
|
|
log_type=log_type,
|
|
capture_phase=self._capture_phase,
|
|
))
|
|
last_emit_time = now
|
|
else:
|
|
# Emit other lines, throttled to every 0.5 seconds
|
|
if now - last_emit_time >= 0.5:
|
|
self._emit_progress(CaptureProgress(
|
|
status='capturing',
|
|
satellite=self._current_satellite,
|
|
frequency=self._current_frequency,
|
|
mode=self._current_mode,
|
|
message=line,
|
|
elapsed_seconds=elapsed,
|
|
log_type=log_type,
|
|
capture_phase=self._capture_phase,
|
|
))
|
|
last_emit_time = now
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error reading SatDump output: {e}")
|
|
finally:
|
|
# Close PTY master fd
|
|
if self._pty_master_fd is not None:
|
|
try:
|
|
os.close(self._pty_master_fd)
|
|
except OSError:
|
|
pass
|
|
self._pty_master_fd = None
|
|
|
|
# Process ended — release resources
|
|
was_running = self._running
|
|
self._running = False
|
|
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
|
|
|
if was_running:
|
|
self._capture_phase = 'complete'
|
|
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,
|
|
log_type='info',
|
|
capture_phase='complete',
|
|
))
|
|
|
|
# Notify route layer to release SDR device
|
|
if self._on_complete_callback:
|
|
try:
|
|
self._on_complete_callback()
|
|
except Exception as e:
|
|
logger.error(f"Error in on_complete callback: {e}")
|
|
|
|
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._pty_master_fd is not None:
|
|
try:
|
|
os.close(self._pty_master_fd)
|
|
except OSError:
|
|
pass
|
|
self._pty_master_fd = None
|
|
|
|
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 delete_all_images(self) -> int:
|
|
"""Delete all decoded images."""
|
|
count = 0
|
|
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
|
for filepath in self._output_dir.glob(ext):
|
|
try:
|
|
filepath.unlink()
|
|
count += 1
|
|
except OSError:
|
|
pass
|
|
self._images.clear()
|
|
return count
|
|
|
|
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
|