Apply pending weather-sat and wefax updates

This commit is contained in:
Smittix
2026-02-24 21:46:58 +00:00
parent b90f1def60
commit 99c4304308
10 changed files with 712 additions and 206 deletions
+73 -56
View File
@@ -173,7 +173,7 @@ class WeatherSatDecoder:
self._current_frequency: float = 0.0
self._current_mode: str = ''
self._capture_start_time: float = 0
self._device_index: int = 0
self._device_index: int = -1
self._capture_output_dir: Path | None = None
self._on_complete_callback: Callable[[], None] | None = None
self._capture_phase: str = 'idle'
@@ -303,12 +303,13 @@ class WeatherSatDecoder:
))
return False
self._current_satellite = satellite
self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode']
self._capture_start_time = time.time()
self._capture_phase = 'decoding'
self._stop_event.clear()
self._current_satellite = satellite
self._current_frequency = sat_info['frequency']
self._current_mode = sat_info['mode']
self._device_index = -1 # Offline decode does not claim an SDR device
self._capture_start_time = time.time()
self._capture_phase = 'decoding'
self._stop_event.clear()
try:
self._running = True
@@ -464,15 +465,18 @@ class WeatherSatDecoder:
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,
)
register_process(self._process)
os.close(slave_fd) # parent doesn't need the slave side
self._process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=slave_fd,
stdin=subprocess.DEVNULL,
close_fds=True,
)
register_process(self._process)
try:
os.close(slave_fd) # parent doesn't need the slave side
except OSError:
pass
# Check for early exit asynchronously (avoid blocking /start for 3s)
def _check_early_exit():
@@ -564,15 +568,18 @@ class WeatherSatDecoder:
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,
)
register_process(self._process)
os.close(slave_fd) # parent doesn't need the slave side
self._process = subprocess.Popen(
cmd,
stdout=slave_fd,
stderr=slave_fd,
stdin=subprocess.DEVNULL,
close_fds=True,
)
register_process(self._process)
try:
os.close(slave_fd) # parent doesn't need the slave side
except OSError:
pass
# For offline mode, don't check for early exit — file decoding
# may complete very quickly and exit code 0 is normal success.
@@ -892,16 +899,24 @@ class WeatherSatDecoder:
except OSError:
continue
# 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:
# Copy failed — don't mark as known so it can be retried
# Determine product type from filename/path
product = self._parse_product_name(filepath)
# Copy image to main output dir for serving
safe_sat = re.sub(r'[^A-Za-z0-9_-]+', '_', self._current_satellite).strip('_') or 'satellite'
safe_stem = re.sub(r'[^A-Za-z0-9_-]+', '_', filepath.stem).strip('_') or 'image'
suffix = filepath.suffix.lower()
if suffix not in ('.png', '.jpg', '.jpeg'):
suffix = '.png'
serve_name = (
f"{safe_sat}_{safe_stem}_{datetime.now().strftime('%Y%m%d_%H%M%S_%f')}"
f"{suffix}"
)
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
# Only mark as known after successful copy
@@ -945,12 +960,12 @@ class WeatherSatDecoder:
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 '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:
@@ -972,12 +987,13 @@ class WeatherSatDecoder:
self._stop_event.set()
self._close_pty()
if self._process:
safe_terminate(self._process)
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")
if self._process:
safe_terminate(self._process)
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")
self._device_index = -1
def get_images(self) -> list[WeatherSatImage]:
"""Get list of decoded images."""
@@ -1013,17 +1029,18 @@ class WeatherSatDecoder:
sat_info = WEATHER_SATELLITES.get(satellite, {})
image = WeatherSatImage(
filename=filepath.name,
path=filepath,
satellite=satellite,
mode=sat_info.get('mode', 'Unknown'),
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)
size_bytes=stat.st_size,
product=self._parse_product_name(filepath),
)
self._images.append(image)
known_filenames.add(filepath.name)
def delete_image(self, filename: str) -> bool:
"""Delete a decoded image."""
+20 -6
View File
@@ -319,12 +319,26 @@ class WeatherSatScheduler:
if self._progress_callback:
decoder.set_callback(self._progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(self._device)
except ImportError:
pass
def _release_device():
try:
import app as app_module
owner = None
get_status = getattr(app_module, 'get_sdr_device_status', None)
if callable(get_status):
try:
owner = get_status().get(self._device)
except Exception:
owner = None
if owner and owner != 'weather_sat':
logger.debug(
"Skipping SDR release for device %s owned by %s",
self._device,
owner,
)
return
app_module.release_sdr_device(self._device)
except ImportError:
pass
decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device))
+160 -89
View File
@@ -31,28 +31,30 @@ except ImportError:
WEFAX_CAPTURE_BUFFER_SECONDS = 30
class ScheduledBroadcast:
class ScheduledBroadcast:
"""A broadcast scheduled for automatic capture."""
def __init__(
self,
station: str,
callsign: str,
frequency_khz: float,
utc_time: str,
duration_min: int,
content: str,
):
self.id: str = str(uuid.uuid4())[:8]
self.station = station
self.callsign = callsign
self.frequency_khz = frequency_khz
self.utc_time = utc_time
self.duration_min = duration_min
self.content = content
self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
self._timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None
def __init__(
self,
station: str,
callsign: str,
frequency_khz: float,
utc_time: str,
duration_min: int,
content: str,
occurrence_date: str = '',
):
self.id: str = str(uuid.uuid4())[:8]
self.station = station
self.callsign = callsign
self.frequency_khz = frequency_khz
self.utc_time = utc_time
self.duration_min = duration_min
self.content = content
self.occurrence_date = occurrence_date
self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
self._timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None
def to_dict(self) -> dict[str, Any]:
return {
@@ -60,14 +62,15 @@ class ScheduledBroadcast:
'station': self.station,
'callsign': self.callsign,
'frequency_khz': self.frequency_khz,
'utc_time': self.utc_time,
'duration_min': self.duration_min,
'content': self.content,
'status': self.status,
}
'utc_time': self.utc_time,
'duration_min': self.duration_min,
'content': self.content,
'occurrence_date': self.occurrence_date,
'status': self.status,
}
class WeFaxScheduler:
class WeFaxScheduler:
"""Auto-scheduler for WeFax broadcast captures."""
def __init__(self):
@@ -204,10 +207,15 @@ class WeFaxScheduler:
'total_broadcasts': len(self._broadcasts),
}
def get_broadcasts(self) -> list[dict[str, Any]]:
"""Get list of scheduled broadcasts."""
with self._lock:
return [b.to_dict() for b in self._broadcasts]
def get_broadcasts(self) -> list[dict[str, Any]]:
"""Get list of scheduled broadcasts."""
with self._lock:
return [b.to_dict() for b in self._broadcasts]
@staticmethod
def _history_key(callsign: str, utc_time: str, occurrence_date: str) -> str:
"""Build a stable key for one station UTC slot on one calendar day."""
return f'{callsign}_{utc_time}_{occurrence_date}'
def _refresh_schedule(self) -> None:
"""Recompute broadcast schedule and set timers."""
@@ -269,24 +277,34 @@ class WeFaxScheduler:
minutes=duration_min, seconds=buffer
)
capture_start = broadcast_dt - timedelta(seconds=buffer)
# Check if already in history
history_key = f"{self._callsign}_{utc_time}"
if any(
f"{h.callsign}_{h.utc_time}" == history_key
for h in history
):
continue
sb = ScheduledBroadcast(
capture_start = broadcast_dt - timedelta(seconds=buffer)
occurrence_date = broadcast_dt.date().isoformat()
# Check if this specific day/slot was already processed.
history_key = self._history_key(
self._callsign,
utc_time,
occurrence_date,
)
if any(
self._history_key(
h.callsign,
h.utc_time,
getattr(h, 'occurrence_date', ''),
) == history_key
for h in history
):
continue
sb = ScheduledBroadcast(
station=self._station,
callsign=self._callsign,
frequency_khz=self._frequency_khz,
utc_time=utc_time,
duration_min=duration_min,
content=content,
)
frequency_khz=self._frequency_khz,
utc_time=utc_time,
duration_min=duration_min,
content=content,
occurrence_date=occurrence_date,
)
# Schedule capture timer
delay = max(0.0, (capture_start - now).total_seconds())
@@ -371,18 +389,57 @@ class WeFaxScheduler:
except ImportError:
pass
sb.status = 'capturing'
# Set up callbacks
if self._progress_callback:
decoder.set_callback(self._progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(self._device)
except ImportError:
pass
sb.status = 'capturing'
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(self._device)
except ImportError:
pass
released = False
release_lock = threading.Lock()
def _release_device_once() -> None:
nonlocal released
with release_lock:
if released:
return
released = True
_release_device()
def _scheduler_progress_callback(progress: dict) -> None:
"""Forward progress updates and release scheduler resources on terminal states."""
if self._progress_callback:
self._progress_callback(progress)
if not isinstance(progress, dict) or progress.get('type') != 'wefax_progress':
return
status = progress.get('status')
if status not in ('complete', 'error', 'stopped'):
return
if sb.status == 'capturing':
if status == 'complete':
sb.status = 'complete'
self._emit_event({
'type': 'schedule_capture_complete',
'broadcast': sb.to_dict(),
})
else:
sb.status = 'skipped'
self._emit_event({
'type': 'schedule_capture_skipped',
'broadcast': sb.to_dict(),
'reason': 'decoder_error',
'detail': progress.get('message', ''),
})
_release_device_once()
decoder.set_callback(_scheduler_progress_callback)
success = decoder.start(
frequency_khz=self._frequency_khz,
@@ -414,39 +471,53 @@ class WeFaxScheduler:
minutes=sb.duration_min,
seconds=WEFAX_CAPTURE_BUFFER_SECONDS,
)
stop_delay = max(0.0, (stop_dt - now).total_seconds())
if stop_delay > 0:
sb._stop_timer = threading.Timer(
stop_delay, self._stop_capture, args=[sb, _release_device]
)
sb._stop_timer.daemon = True
sb._stop_timer.start()
else:
sb.status = 'skipped'
_release_device()
self._emit_event({
'type': 'schedule_capture_skipped',
'broadcast': sb.to_dict(),
'reason': 'start_failed',
'detail': decoder.last_error or 'unknown error',
stop_delay = max(0.0, (stop_dt - now).total_seconds())
if stop_delay > 0:
sb._stop_timer = threading.Timer(
stop_delay, self._stop_capture, args=[sb, _release_device_once]
)
sb._stop_timer.daemon = True
sb._stop_timer.start()
else:
# If execution was delayed beyond end-of-window, close out
# immediately so SDR allocation is never stranded.
logger.warning(
"Capture window already elapsed for %s at %s UTC; stopping immediately",
sb.content,
sb.utc_time,
)
self._stop_capture(sb, _release_device_once)
else:
sb.status = 'skipped'
_release_device_once()
self._emit_event({
'type': 'schedule_capture_skipped',
'broadcast': sb.to_dict(),
'reason': 'start_failed',
'detail': decoder.last_error or 'unknown error',
})
def _stop_capture(
self, sb: ScheduledBroadcast, release_fn: Callable
) -> None:
"""Stop capture at broadcast end."""
decoder = get_wefax_decoder()
if decoder.is_running:
decoder.stop()
logger.info("Auto-scheduler stopped capture: %s", sb.content)
sb.status = 'complete'
release_fn()
self._emit_event({
'type': 'schedule_capture_complete',
'broadcast': sb.to_dict(),
})
def _stop_capture(
self, sb: ScheduledBroadcast, release_fn: Callable
) -> None:
"""Stop capture at broadcast end."""
if sb.status != 'capturing':
release_fn()
return
sb.status = 'complete'
decoder = get_wefax_decoder()
if decoder.is_running:
decoder.stop()
logger.info("Auto-scheduler stopped capture: %s", sb.content)
release_fn()
self._emit_event({
'type': 'schedule_capture_complete',
'broadcast': sb.to_dict(),
})
def _emit_event(self, event: dict[str, Any]) -> None:
"""Emit scheduler event to callback."""