mirror of
https://github.com/smittix/intercept.git
synced 2026-07-07 17:18:11 -07:00
Apply pending weather-sat and wefax updates
This commit is contained in:
+73
-56
@@ -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."""
|
||||
|
||||
@@ -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
@@ -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."""
|
||||
|
||||
Reference in New Issue
Block a user