mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
Fix weather satellite decoder security, architecture, and race conditions
Security: replace path traversal-vulnerable str().startswith() with is_relative_to(), anchor path checks to app root, strip filesystem paths from error responses, add decoder-level path validation. Architecture: use safe_terminate/register_process for subprocess lifecycle, replace custom SSE generator with sse_stream(), use centralized validate_* functions, remove unused app.py declarations. Bugs: add thread-safe singleton locks, protect _images list across threads, move blocking process.wait() to async daemon thread, fix timezone handling for tz-aware datetimes, use full path for image deduplication, guard TLE auto-refresh during tests, validate scheduler parameters to avoid 500 errors. Docker: pin SatDump to v1.2.2 and slowrx to ca6d7012, document INTERCEPT_IMAGE fallback pattern. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -136,16 +136,17 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& make \
|
&& make \
|
||||||
&& cp acarsdec /usr/bin/acarsdec \
|
&& cp acarsdec /usr/bin/acarsdec \
|
||||||
&& rm -rf /tmp/acarsdec \
|
&& rm -rf /tmp/acarsdec \
|
||||||
# Build slowrx (SSTV decoder)
|
# Build slowrx (SSTV decoder) — pinned to known-good commit
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone --depth 1 https://github.com/windytan/slowrx.git \
|
&& git clone https://github.com/windytan/slowrx.git \
|
||||||
&& cd slowrx \
|
&& cd slowrx \
|
||||||
|
&& git checkout ca6d7012 \
|
||||||
&& 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)
|
# Build SatDump (weather satellite decoder - NOAA APT & Meteor LRPT) — pinned to v1.2.2
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& git clone --depth 1 https://github.com/SatDump/SatDump.git \
|
&& git clone --depth 1 --branch 1.2.2 https://github.com/SatDump/SatDump.git \
|
||||||
&& cd SatDump \
|
&& cd SatDump \
|
||||||
&& mkdir build && cd build \
|
&& mkdir build && cd build \
|
||||||
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
&& cmake -DCMAKE_BUILD_TYPE=Release -DBUILD_GUI=OFF -DCMAKE_INSTALL_LIBDIR=lib .. \
|
||||||
|
|||||||
4
app.py
4
app.py
@@ -182,10 +182,6 @@ dmr_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)
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
services:
|
services:
|
||||||
intercept:
|
intercept:
|
||||||
|
# When INTERCEPT_IMAGE is set, use that pre-built image; when empty/unset,
|
||||||
|
# the empty string causes Docker Compose to fall through to the build: directive.
|
||||||
image: ${INTERCEPT_IMAGE:-}
|
image: ${INTERCEPT_IMAGE:-}
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept
|
container_name: intercept
|
||||||
@@ -61,6 +63,7 @@ services:
|
|||||||
# ADS-B history with Postgres persistence
|
# ADS-B history with Postgres persistence
|
||||||
# Enable with: docker compose --profile history up -d
|
# Enable with: docker compose --profile history up -d
|
||||||
intercept-history:
|
intercept-history:
|
||||||
|
# Same image/build fallback pattern as above
|
||||||
image: ${INTERCEPT_IMAGE:-}
|
image: ${INTERCEPT_IMAGE:-}
|
||||||
build: .
|
build: .
|
||||||
container_name: intercept-history
|
container_name: intercept-history
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ ALLOWED_TLE_HOSTS = ['celestrak.org', 'celestrak.com', 'www.celestrak.org', 'www
|
|||||||
_tle_cache = dict(TLE_SATELLITES)
|
_tle_cache = dict(TLE_SATELLITES)
|
||||||
|
|
||||||
# Auto-refresh TLEs from CelesTrak on startup (non-blocking)
|
# Auto-refresh TLEs from CelesTrak on startup (non-blocking)
|
||||||
|
import os
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
def _auto_refresh_tle():
|
def _auto_refresh_tle():
|
||||||
@@ -42,7 +43,9 @@ def _auto_refresh_tle():
|
|||||||
logger.warning(f"Auto TLE refresh failed: {e}")
|
logger.warning(f"Auto TLE refresh failed: {e}")
|
||||||
|
|
||||||
# Delay import — refresh_tle_data is defined later in this module
|
# Delay import — refresh_tle_data is defined later in this module
|
||||||
threading.Timer(2.0, _auto_refresh_tle).start()
|
# Guard to avoid firing during tests
|
||||||
|
if not os.environ.get('TESTING'):
|
||||||
|
threading.Timer(2.0, _auto_refresh_tle).start()
|
||||||
|
|
||||||
|
|
||||||
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
def _fetch_iss_realtime(observer_lat: Optional[float] = None, observer_lon: Optional[float] = None) -> Optional[dict]:
|
||||||
|
|||||||
@@ -7,13 +7,12 @@ from NOAA (APT) and Meteor (LRPT) satellites using SatDump.
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import queue
|
import queue
|
||||||
import time
|
|
||||||
from typing import Generator
|
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, request, Response, send_file
|
from flask import Blueprint, jsonify, request, Response, send_file
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
from utils.sse import format_sse
|
from utils.sse import sse_stream
|
||||||
|
from utils.validation import validate_device_index, validate_gain, validate_latitude, validate_longitude, validate_elevation
|
||||||
from utils.weather_sat import (
|
from utils.weather_sat import (
|
||||||
get_weather_sat_decoder,
|
get_weather_sat_decoder,
|
||||||
is_weather_sat_available,
|
is_weather_sat_available,
|
||||||
@@ -116,28 +115,14 @@ def start_capture():
|
|||||||
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
'message': f'Invalid satellite. Must be one of: {", ".join(WEATHER_SATELLITES.keys())}'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
# Validate device index
|
# Validate device index and gain
|
||||||
device_index = data.get('device', 0)
|
|
||||||
try:
|
try:
|
||||||
device_index = int(device_index)
|
device_index = validate_device_index(data.get('device', 0))
|
||||||
if not (0 <= device_index <= 255):
|
gain = validate_gain(data.get('gain', 40.0))
|
||||||
raise ValueError
|
except ValueError as e:
|
||||||
except (TypeError, ValueError):
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Invalid device index (0-255)'
|
'message': str(e)
|
||||||
}), 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
|
}), 400
|
||||||
|
|
||||||
bias_t = bool(data.get('bias_t', False))
|
bias_t = bool(data.get('bias_t', False))
|
||||||
@@ -252,11 +237,11 @@ def test_decode():
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
input_path = Path(input_file)
|
input_path = Path(input_file)
|
||||||
|
|
||||||
# Security: restrict to data directory
|
# Security: restrict to data directory (anchored to app root, not CWD)
|
||||||
allowed_base = Path('data').resolve()
|
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||||
try:
|
try:
|
||||||
resolved = input_path.resolve()
|
resolved = input_path.resolve()
|
||||||
if not str(resolved).startswith(str(allowed_base)):
|
if not resolved.is_relative_to(allowed_base):
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'input_file must be under the data/ directory'
|
'message': 'input_file must be under the data/ directory'
|
||||||
@@ -268,9 +253,10 @@ def test_decode():
|
|||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
if not input_path.is_file():
|
if not input_path.is_file():
|
||||||
|
logger.warning(f"Test-decode file not found: {input_file}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': f'File not found: {input_file}'
|
'message': 'File not found'
|
||||||
}), 404
|
}), 404
|
||||||
|
|
||||||
# Validate sample rate
|
# Validate sample rate
|
||||||
@@ -440,22 +426,7 @@ def stream_progress():
|
|||||||
Returns:
|
Returns:
|
||||||
SSE stream (text/event-stream)
|
SSE stream (text/event-stream)
|
||||||
"""
|
"""
|
||||||
def generate() -> Generator[str, None, None]:
|
response = Response(sse_stream(_weather_sat_queue), mimetype='text/event-stream')
|
||||||
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['Cache-Control'] = 'no-cache'
|
||||||
response.headers['X-Accel-Buffering'] = 'no'
|
response.headers['X-Accel-Buffering'] = 'no'
|
||||||
response.headers['Connection'] = 'keep-alive'
|
response.headers['Connection'] = 'keep-alive'
|
||||||
@@ -477,26 +448,26 @@ def get_passes():
|
|||||||
Returns:
|
Returns:
|
||||||
JSON with upcoming passes for all weather satellites.
|
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_trajectory = request.args.get('trajectory', 'false').lower() in ('true', '1')
|
||||||
include_ground_track = request.args.get('ground_track', '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:
|
raw_lat = request.args.get('latitude')
|
||||||
|
raw_lon = request.args.get('longitude')
|
||||||
|
|
||||||
|
if raw_lat is None or raw_lon is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'latitude and longitude parameters required'
|
'message': 'latitude and longitude parameters required'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
if not (-90 <= lat <= 90):
|
try:
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid latitude'}), 400
|
lat = validate_latitude(raw_lat)
|
||||||
if not (-180 <= lon <= 180):
|
lon = validate_longitude(raw_lon)
|
||||||
return jsonify({'status': 'error', 'message': 'Invalid longitude'}), 400
|
except ValueError as e:
|
||||||
|
return jsonify({'status': 'error', 'message': str(e)}), 400
|
||||||
|
|
||||||
hours = max(1, min(hours, 72))
|
hours = max(1, min(request.args.get('hours', 24, type=int), 72))
|
||||||
min_elevation = max(0, min(min_elevation, 90))
|
min_elevation = max(0, min(request.args.get('min_elevation', 15, type=float), 90))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from utils.weather_sat_predict import predict_passes
|
from utils.weather_sat_predict import predict_passes
|
||||||
@@ -529,7 +500,7 @@ def get_passes():
|
|||||||
logger.error(f"Error predicting passes: {e}")
|
logger.error(f"Error predicting passes: {e}")
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': str(e)
|
'message': 'Pass prediction failed'
|
||||||
}), 500
|
}), 500
|
||||||
|
|
||||||
|
|
||||||
@@ -571,24 +542,22 @@ def enable_schedule():
|
|||||||
|
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
|
|
||||||
lat = data.get('latitude')
|
if data.get('latitude') is None or data.get('longitude') is None:
|
||||||
lon = data.get('longitude')
|
|
||||||
|
|
||||||
if lat is None or lon is None:
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'latitude and longitude required'
|
'message': 'latitude and longitude required'
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
try:
|
try:
|
||||||
lat = float(lat)
|
lat = validate_latitude(data.get('latitude'))
|
||||||
lon = float(lon)
|
lon = validate_longitude(data.get('longitude'))
|
||||||
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
|
min_elev = validate_elevation(data.get('min_elevation', 15))
|
||||||
raise ValueError
|
device = validate_device_index(data.get('device', 0))
|
||||||
except (TypeError, ValueError):
|
gain_val = validate_gain(data.get('gain', 40.0))
|
||||||
|
except ValueError as e:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Invalid coordinates'
|
'message': str(e)
|
||||||
}), 400
|
}), 400
|
||||||
|
|
||||||
scheduler = get_weather_sat_scheduler()
|
scheduler = get_weather_sat_scheduler()
|
||||||
@@ -597,9 +566,9 @@ def enable_schedule():
|
|||||||
result = scheduler.enable(
|
result = scheduler.enable(
|
||||||
lat=lat,
|
lat=lat,
|
||||||
lon=lon,
|
lon=lon,
|
||||||
min_elevation=float(data.get('min_elevation', 15)),
|
min_elevation=min_elev,
|
||||||
device=int(data.get('device', 0)),
|
device=device,
|
||||||
gain=float(data.get('gain', 40.0)),
|
gain=gain_val,
|
||||||
bias_t=bool(data.get('bias_t', False)),
|
bias_t=bool(data.get('bias_t', False)),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ from pathlib import Path
|
|||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from utils.logging import get_logger
|
from utils.logging import get_logger
|
||||||
|
from utils.process import register_process, safe_terminate
|
||||||
|
|
||||||
logger = get_logger('intercept.weather_sat')
|
logger = get_logger('intercept.weather_sat')
|
||||||
|
|
||||||
@@ -145,6 +146,7 @@ class WeatherSatDecoder:
|
|||||||
self._process: subprocess.Popen | None = None
|
self._process: subprocess.Popen | None = None
|
||||||
self._running = False
|
self._running = False
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.Lock()
|
||||||
|
self._images_lock = threading.Lock()
|
||||||
self._callback: Callable[[CaptureProgress], None] | None = None
|
self._callback: Callable[[CaptureProgress], None] | None = None
|
||||||
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
|
self._output_dir = Path(output_dir) if output_dir else Path('data/weather_sat')
|
||||||
self._images: list[WeatherSatImage] = []
|
self._images: list[WeatherSatImage] = []
|
||||||
@@ -243,11 +245,30 @@ class WeatherSatDecoder:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
input_path = Path(input_file)
|
input_path = Path(input_file)
|
||||||
|
|
||||||
|
# Security: restrict to data directory
|
||||||
|
allowed_base = Path(__file__).resolve().parent.parent / 'data'
|
||||||
|
try:
|
||||||
|
resolved = input_path.resolve()
|
||||||
|
if not resolved.is_relative_to(allowed_base):
|
||||||
|
logger.warning(f"Path traversal blocked in start_from_file: {input_file}")
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='error',
|
||||||
|
message='Input file must be under the data/ directory'
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
except (OSError, ValueError):
|
||||||
|
self._emit_progress(CaptureProgress(
|
||||||
|
status='error',
|
||||||
|
message='Invalid file path'
|
||||||
|
))
|
||||||
|
return False
|
||||||
|
|
||||||
if not input_path.is_file():
|
if not input_path.is_file():
|
||||||
logger.error(f"Input file not found: {input_file}")
|
logger.error(f"Input file not found: {input_file}")
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
status='error',
|
status='error',
|
||||||
message=f'Input file not found: {input_file}'
|
message='Input file not found'
|
||||||
))
|
))
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -417,12 +438,17 @@ class WeatherSatDecoder:
|
|||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
)
|
)
|
||||||
|
register_process(self._process)
|
||||||
os.close(slave_fd) # parent doesn't need the slave side
|
os.close(slave_fd) # parent doesn't need the slave side
|
||||||
|
|
||||||
# Check for early exit (SatDump errors out immediately)
|
# Check for early exit asynchronously (avoid blocking /start for 3s)
|
||||||
try:
|
def _check_early_exit():
|
||||||
retcode = self._process.wait(timeout=3)
|
"""Poll once after 3s; if SatDump died, emit an error event."""
|
||||||
# Process already died — read whatever output it produced
|
time.sleep(3)
|
||||||
|
process = self._process
|
||||||
|
if process is None or process.poll() is None:
|
||||||
|
return # still running or already cleaned up
|
||||||
|
retcode = process.returncode
|
||||||
output = b''
|
output = b''
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
@@ -435,8 +461,6 @@ class WeatherSatDecoder:
|
|||||||
output += chunk
|
output += chunk
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
os.close(master_fd)
|
|
||||||
self._pty_master_fd = None
|
|
||||||
output_str = output.decode('utf-8', errors='replace')
|
output_str = output.decode('utf-8', errors='replace')
|
||||||
error_msg = f"SatDump exited immediately (code {retcode})"
|
error_msg = f"SatDump exited immediately (code {retcode})"
|
||||||
if output_str:
|
if output_str:
|
||||||
@@ -445,11 +469,17 @@ class WeatherSatDecoder:
|
|||||||
error_msg = line.strip()
|
error_msg = line.strip()
|
||||||
break
|
break
|
||||||
logger.error(f"SatDump output:\n{output_str}")
|
logger.error(f"SatDump output:\n{output_str}")
|
||||||
self._process = None
|
self._emit_progress(CaptureProgress(
|
||||||
raise RuntimeError(error_msg)
|
status='error',
|
||||||
except subprocess.TimeoutExpired:
|
satellite=self._current_satellite,
|
||||||
# Good — process is still running after 3 seconds
|
frequency=self._current_frequency,
|
||||||
pass
|
mode=self._current_mode,
|
||||||
|
message=error_msg,
|
||||||
|
log_type='error',
|
||||||
|
capture_phase='error',
|
||||||
|
))
|
||||||
|
|
||||||
|
threading.Thread(target=_check_early_exit, daemon=True).start()
|
||||||
|
|
||||||
# Start reader thread to monitor output
|
# Start reader thread to monitor output
|
||||||
self._reader_thread = threading.Thread(
|
self._reader_thread = threading.Thread(
|
||||||
@@ -508,6 +538,7 @@ class WeatherSatDecoder:
|
|||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
close_fds=True,
|
close_fds=True,
|
||||||
)
|
)
|
||||||
|
register_process(self._process)
|
||||||
os.close(slave_fd) # parent doesn't need the slave side
|
os.close(slave_fd) # parent doesn't need the slave side
|
||||||
|
|
||||||
# For offline mode, don't check for early exit — file decoding
|
# For offline mode, don't check for early exit — file decoding
|
||||||
@@ -782,7 +813,8 @@ class WeatherSatDecoder:
|
|||||||
# Recursively scan for image files
|
# Recursively scan for image files
|
||||||
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||||
for filepath in self._capture_output_dir.rglob(ext):
|
for filepath in self._capture_output_dir.rglob(ext):
|
||||||
if filepath.name in known_files:
|
file_key = str(filepath)
|
||||||
|
if file_key in known_files:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Skip tiny files (likely incomplete)
|
# Skip tiny files (likely incomplete)
|
||||||
@@ -793,7 +825,7 @@ class WeatherSatDecoder:
|
|||||||
except OSError:
|
except OSError:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
known_files.add(filepath.name)
|
known_files.add(file_key)
|
||||||
|
|
||||||
# Determine product type from filename/path
|
# Determine product type from filename/path
|
||||||
product = self._parse_product_name(filepath)
|
product = self._parse_product_name(filepath)
|
||||||
@@ -817,7 +849,8 @@ class WeatherSatDecoder:
|
|||||||
size_bytes=stat.st_size,
|
size_bytes=stat.st_size,
|
||||||
product=product,
|
product=product,
|
||||||
)
|
)
|
||||||
self._images.append(image)
|
with self._images_lock:
|
||||||
|
self._images.append(image)
|
||||||
|
|
||||||
logger.info(f"New weather satellite image: {serve_name} ({product})")
|
logger.info(f"New weather satellite image: {serve_name} ({product})")
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
@@ -877,16 +910,7 @@ class WeatherSatDecoder:
|
|||||||
self._pty_master_fd = None
|
self._pty_master_fd = None
|
||||||
|
|
||||||
if self._process:
|
if self._process:
|
||||||
try:
|
safe_terminate(self._process)
|
||||||
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
|
self._process = None
|
||||||
|
|
||||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||||
@@ -894,11 +918,15 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
def get_images(self) -> list[WeatherSatImage]:
|
def get_images(self) -> list[WeatherSatImage]:
|
||||||
"""Get list of decoded images."""
|
"""Get list of decoded images."""
|
||||||
self._scan_images()
|
with self._images_lock:
|
||||||
return list(self._images)
|
self._scan_images()
|
||||||
|
return list(self._images)
|
||||||
|
|
||||||
def _scan_images(self) -> None:
|
def _scan_images(self) -> None:
|
||||||
"""Scan output directory for images not yet tracked."""
|
"""Scan output directory for images not yet tracked.
|
||||||
|
|
||||||
|
Must be called with self._images_lock held.
|
||||||
|
"""
|
||||||
known_filenames = {img.filename for img in self._images}
|
known_filenames = {img.filename for img in self._images}
|
||||||
|
|
||||||
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||||
@@ -940,7 +968,8 @@ class WeatherSatDecoder:
|
|||||||
if filepath.exists():
|
if filepath.exists():
|
||||||
try:
|
try:
|
||||||
filepath.unlink()
|
filepath.unlink()
|
||||||
self._images = [img for img in self._images if img.filename != filename]
|
with self._images_lock:
|
||||||
|
self._images = [img for img in self._images if img.filename != filename]
|
||||||
return True
|
return True
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.error(f"Failed to delete image {filename}: {e}")
|
logger.error(f"Failed to delete image {filename}: {e}")
|
||||||
@@ -956,7 +985,8 @@ class WeatherSatDecoder:
|
|||||||
count += 1
|
count += 1
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
pass
|
||||||
self._images.clear()
|
with self._images_lock:
|
||||||
|
self._images.clear()
|
||||||
return count
|
return count
|
||||||
|
|
||||||
def _emit_progress(self, progress: CaptureProgress) -> None:
|
def _emit_progress(self, progress: CaptureProgress) -> None:
|
||||||
@@ -987,13 +1017,16 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
# Global decoder instance
|
# Global decoder instance
|
||||||
_decoder: WeatherSatDecoder | None = None
|
_decoder: WeatherSatDecoder | None = None
|
||||||
|
_decoder_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_weather_sat_decoder() -> WeatherSatDecoder:
|
def get_weather_sat_decoder() -> WeatherSatDecoder:
|
||||||
"""Get or create the global weather satellite decoder instance."""
|
"""Get or create the global weather satellite decoder instance."""
|
||||||
global _decoder
|
global _decoder
|
||||||
if _decoder is None:
|
if _decoder is None:
|
||||||
_decoder = WeatherSatDecoder()
|
with _decoder_lock:
|
||||||
|
if _decoder is None:
|
||||||
|
_decoder = WeatherSatDecoder()
|
||||||
return _decoder
|
return _decoder
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -49,11 +49,17 @@ class ScheduledPass:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def start_dt(self) -> datetime:
|
def start_dt(self) -> datetime:
|
||||||
return datetime.fromisoformat(self.start_time).replace(tzinfo=timezone.utc)
|
dt = datetime.fromisoformat(self.start_time)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def end_dt(self) -> datetime:
|
def end_dt(self) -> datetime:
|
||||||
return datetime.fromisoformat(self.end_time).replace(tzinfo=timezone.utc)
|
dt = datetime.fromisoformat(self.end_time)
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
return dt.replace(tzinfo=timezone.utc)
|
||||||
|
return dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
def to_dict(self) -> dict[str, Any]:
|
def to_dict(self) -> dict[str, Any]:
|
||||||
return {
|
return {
|
||||||
@@ -375,11 +381,14 @@ class WeatherSatScheduler:
|
|||||||
|
|
||||||
# Singleton
|
# Singleton
|
||||||
_scheduler: WeatherSatScheduler | None = None
|
_scheduler: WeatherSatScheduler | None = None
|
||||||
|
_scheduler_lock = threading.Lock()
|
||||||
|
|
||||||
|
|
||||||
def get_weather_sat_scheduler() -> WeatherSatScheduler:
|
def get_weather_sat_scheduler() -> WeatherSatScheduler:
|
||||||
"""Get or create the global weather satellite scheduler instance."""
|
"""Get or create the global weather satellite scheduler instance."""
|
||||||
global _scheduler
|
global _scheduler
|
||||||
if _scheduler is None:
|
if _scheduler is None:
|
||||||
_scheduler = WeatherSatScheduler()
|
with _scheduler_lock:
|
||||||
|
if _scheduler is None:
|
||||||
|
_scheduler = WeatherSatScheduler()
|
||||||
return _scheduler
|
return _scheduler
|
||||||
|
|||||||
Reference in New Issue
Block a user