mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
fix: Resolve multiple weather satellite decoder bugs
- Fix SatDump crash reported as "Capture complete" by collecting exit status via process.wait() before checking returncode - Fix PTY file descriptor double-close race between stop() and reader thread by adding thread-safe _close_pty() helper with dedicated lock - Fix image watcher missing final images by doing post-exit scans after SatDump process ends, using threading.Event for fast wakeup - Fix failed image copy permanently skipping file by only marking as known after successful copy - Fix frontend error handler not resetting isRunning, preventing new captures after a crash - Fix console auto-hide timer leak on rapid complete/error events - Fix ground track and auto-scheduler ignoring shared ObserverLocation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -399,16 +399,20 @@ const WeatherSat = (function() {
|
||||
|
||||
addConsoleEntry('Capture complete', 'signal');
|
||||
updatePhaseIndicator('complete');
|
||||
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 30000);
|
||||
}
|
||||
|
||||
} else if (data.status === 'error') {
|
||||
isRunning = false;
|
||||
if (!schedulerEnabled) stopStream();
|
||||
updateStatusUI('idle', 'Error');
|
||||
showNotification('Weather Sat', data.message || 'Capture error');
|
||||
if (captureStatus) captureStatus.classList.remove('active');
|
||||
|
||||
if (data.message) addConsoleEntry(data.message, 'error');
|
||||
updatePhaseIndicator('error');
|
||||
if (consoleAutoHideTimer) clearTimeout(consoleAutoHideTimer);
|
||||
consoleAutoHideTimer = setTimeout(() => showConsole(false), 15000);
|
||||
}
|
||||
}
|
||||
@@ -761,8 +765,17 @@ const WeatherSat = (function() {
|
||||
}).addTo(groundTrackLayer);
|
||||
|
||||
// Observer marker
|
||||
const lat = parseFloat(localStorage.getItem('observerLat'));
|
||||
const lon = parseFloat(localStorage.getItem('observerLon'));
|
||||
let obsLat, obsLon;
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
obsLat = shared?.lat;
|
||||
obsLon = shared?.lon;
|
||||
} else {
|
||||
obsLat = parseFloat(localStorage.getItem('observerLat'));
|
||||
obsLon = parseFloat(localStorage.getItem('observerLon'));
|
||||
}
|
||||
const lat = obsLat;
|
||||
const lon = obsLon;
|
||||
if (!isNaN(lat) && !isNaN(lon)) {
|
||||
L.circleMarker([lat, lon], {
|
||||
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
|
||||
@@ -946,8 +959,15 @@ const WeatherSat = (function() {
|
||||
* Enable auto-scheduler
|
||||
*/
|
||||
async function enableScheduler() {
|
||||
const lat = parseFloat(localStorage.getItem('observerLat'));
|
||||
const lon = parseFloat(localStorage.getItem('observerLon'));
|
||||
let lat, lon;
|
||||
if (window.ObserverLocation && ObserverLocation.isSharedEnabled()) {
|
||||
const shared = ObserverLocation.getShared();
|
||||
lat = shared?.lat;
|
||||
lon = shared?.lon;
|
||||
} else {
|
||||
lat = parseFloat(localStorage.getItem('observerLat'));
|
||||
lon = parseFloat(localStorage.getItem('observerLon'));
|
||||
}
|
||||
|
||||
if (isNaN(lat) || isNaN(lon)) {
|
||||
showNotification('Weather Sat', 'Set observer location first');
|
||||
|
||||
@@ -156,7 +156,9 @@ class WeatherSatDecoder:
|
||||
self._process: subprocess.Popen | None = None
|
||||
self._running = False
|
||||
self._lock = threading.Lock()
|
||||
self._pty_lock = threading.Lock()
|
||||
self._images_lock = threading.Lock()
|
||||
self._stop_event = threading.Event()
|
||||
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] = []
|
||||
@@ -212,6 +214,16 @@ class WeatherSatDecoder:
|
||||
)
|
||||
return None
|
||||
|
||||
def _close_pty(self) -> None:
|
||||
"""Close the PTY master fd in a thread-safe manner."""
|
||||
with self._pty_lock:
|
||||
if self._pty_master_fd is not None:
|
||||
try:
|
||||
os.close(self._pty_master_fd)
|
||||
except OSError:
|
||||
pass
|
||||
self._pty_master_fd = None
|
||||
|
||||
def set_callback(self, callback: Callable[[CaptureProgress], None]) -> None:
|
||||
"""Set callback for capture progress updates."""
|
||||
self._callback = callback
|
||||
@@ -292,6 +304,7 @@ class WeatherSatDecoder:
|
||||
self._current_mode = sat_info['mode']
|
||||
self._capture_start_time = time.time()
|
||||
self._capture_phase = 'decoding'
|
||||
self._stop_event.clear()
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
@@ -372,6 +385,7 @@ class WeatherSatDecoder:
|
||||
self._device_index = device_index
|
||||
self._capture_start_time = time.time()
|
||||
self._capture_phase = 'tuning'
|
||||
self._stop_event.clear()
|
||||
|
||||
try:
|
||||
self._running = True
|
||||
@@ -781,13 +795,11 @@ class WeatherSatDecoder:
|
||||
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
|
||||
# Close PTY master fd (thread-safe)
|
||||
self._close_pty()
|
||||
|
||||
# Signal watcher thread to do final scan and exit
|
||||
self._stop_event.set()
|
||||
|
||||
# Process ended — release resources
|
||||
was_running = self._running
|
||||
@@ -795,7 +807,13 @@ class WeatherSatDecoder:
|
||||
elapsed = int(time.time() - self._capture_start_time) if self._capture_start_time else 0
|
||||
|
||||
if was_running:
|
||||
# Check if SatDump exited with an error
|
||||
# Collect exit status (returncode is only set after poll/wait)
|
||||
if self._process and self._process.returncode is None:
|
||||
try:
|
||||
self._process.wait(timeout=5)
|
||||
except subprocess.TimeoutExpired:
|
||||
self._process.kill()
|
||||
self._process.wait()
|
||||
retcode = self._process.returncode if self._process else None
|
||||
if retcode and retcode != 0:
|
||||
self._capture_phase = 'error'
|
||||
@@ -837,63 +855,79 @@ class WeatherSatDecoder:
|
||||
known_files: set[str] = set()
|
||||
|
||||
while self._running:
|
||||
time.sleep(2)
|
||||
self._scan_output_dir(known_files)
|
||||
# Use stop_event for faster wakeup on process exit
|
||||
if self._stop_event.wait(timeout=2):
|
||||
break
|
||||
|
||||
try:
|
||||
# Recursively scan for image files
|
||||
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||
for filepath in self._capture_output_dir.rglob(ext):
|
||||
file_key = str(filepath)
|
||||
if file_key in known_files:
|
||||
# Final scan — SatDump writes images at the end of processing,
|
||||
# often after the process has already exited. Do multiple scans
|
||||
# with a short delay to catch late-written files.
|
||||
for _ in range(3):
|
||||
time.sleep(0.5)
|
||||
self._scan_output_dir(known_files)
|
||||
|
||||
def _scan_output_dir(self, known_files: set[str]) -> None:
|
||||
"""Scan capture output directory for new image files."""
|
||||
if not self._capture_output_dir:
|
||||
return
|
||||
|
||||
try:
|
||||
# Recursively scan for image files
|
||||
for ext in ('*.png', '*.jpg', '*.jpeg'):
|
||||
for filepath in self._capture_output_dir.rglob(ext):
|
||||
file_key = str(filepath)
|
||||
if file_key in known_files:
|
||||
continue
|
||||
|
||||
# Skip tiny files (likely incomplete)
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
if stat.st_size < 1000:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
# Skip tiny files (likely incomplete)
|
||||
try:
|
||||
stat = filepath.stat()
|
||||
if stat.st_size < 1000:
|
||||
continue
|
||||
except OSError:
|
||||
continue
|
||||
# Determine product type from filename/path
|
||||
product = self._parse_product_name(filepath)
|
||||
|
||||
known_files.add(file_key)
|
||||
# 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:
|
||||
# Copy failed — don't mark as known so it can be retried
|
||||
continue
|
||||
|
||||
# Determine product type from filename/path
|
||||
product = self._parse_product_name(filepath)
|
||||
# Only mark as known after successful copy
|
||||
known_files.add(file_key)
|
||||
|
||||
# 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,
|
||||
)
|
||||
with self._images_lock:
|
||||
self._images.append(image)
|
||||
|
||||
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,
|
||||
)
|
||||
with self._images_lock:
|
||||
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,
|
||||
))
|
||||
|
||||
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}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error scanning for images: {e}")
|
||||
|
||||
def _parse_product_name(self, filepath: Path) -> str:
|
||||
"""Parse a human-readable product name from the image filepath."""
|
||||
@@ -931,13 +965,8 @@ class WeatherSatDecoder:
|
||||
"""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
|
||||
self._stop_event.set()
|
||||
self._close_pty()
|
||||
|
||||
if self._process:
|
||||
safe_terminate(self._process)
|
||||
|
||||
Reference in New Issue
Block a user