Add weather satellite auto-scheduler, polar plot, ground track map, and rtlamr Docker support

- Fix SDR device stuck claimed on capture failure via on_complete callback
- Improve SatDump output parsing to emit all lines (throttled 2s) for real-time feedback
- Extract shared pass prediction into utils/weather_sat_predict.py with trajectory/ground track support
- Add auto-scheduler (utils/weather_sat_scheduler.py) using threading.Timer for unattended captures
- Add scheduler API endpoints (enable/disable/status/passes/skip) with SSE event notifications
- Add countdown timer (D/H/M/S) with imminent/active glow states
- Add 24h timeline bar with colored pass markers and current-time cursor
- Add canvas polar plot showing az/el trajectory arc with cardinal directions
- Add Leaflet ground track map with satellite path and observer marker
- Restructure to 3-column layout (passes | polar+map | gallery) with responsive stacking
- Add auto-schedule toggle in strip bar and sidebar
- Add rtlamr (Go utility meter decoder) to Dockerfile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Mitch Ross
2026-02-05 19:32:12 -05:00
parent c910612f9e
commit a37d91900d
10 changed files with 1653 additions and 144 deletions
+29 -10
View File
@@ -149,6 +149,7 @@ class WeatherSatDecoder:
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
# Ensure output directory exists
self._output_dir.mkdir(parents=True, exist_ok=True)
@@ -189,6 +190,10 @@ class WeatherSatDecoder:
"""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,
@@ -320,6 +325,8 @@ class WeatherSatDecoder:
if not self._process or not self._process.stdout:
return
last_emit_time = 0.0
try:
for line in iter(self._process.stdout.readline, ''):
if not self._running:
@@ -331,12 +338,11 @@ class WeatherSatDecoder:
logger.debug(f"satdump: {line}")
# Parse progress from SatDump output
elapsed = int(time.time() - self._capture_start_time)
now = time.time()
# SatDump outputs progress info - parse key indicators
# Parse progress from SatDump output
if 'Progress' in line or 'progress' in line:
# Try to extract percentage
match = re.search(r'(\d+(?:\.\d+)?)\s*%', line)
pct = int(float(match.group(1))) if match else 0
self._emit_progress(CaptureProgress(
@@ -348,6 +354,7 @@ class WeatherSatDecoder:
progress_percent=pct,
elapsed_seconds=elapsed,
))
last_emit_time = now
elif 'Saved' in line or 'saved' in line or 'Writing' in line:
self._emit_progress(CaptureProgress(
status='decoding',
@@ -357,6 +364,7 @@ class WeatherSatDecoder:
message=line,
elapsed_seconds=elapsed,
))
last_emit_time = now
elif 'error' in line.lower() or 'fail' in line.lower():
self._emit_progress(CaptureProgress(
status='capturing',
@@ -366,25 +374,29 @@ class WeatherSatDecoder:
message=line,
elapsed_seconds=elapsed,
))
last_emit_time = now
else:
# Generic progress update every ~10 seconds
if elapsed % 10 == 0:
# Emit all output lines, throttled to every 2 seconds
if now - last_emit_time >= 2.0:
self._emit_progress(CaptureProgress(
status='capturing',
satellite=self._current_satellite,
frequency=self._current_frequency,
mode=self._current_mode,
message=f"Capturing... ({elapsed}s elapsed)",
message=line,
elapsed_seconds=elapsed,
))
last_emit_time = now
except Exception as e:
logger.error(f"Error reading SatDump output: {e}")
finally:
# Process ended
if self._running:
self._running = False
elapsed = int(time.time() - self._capture_start_time)
# 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._emit_progress(CaptureProgress(
status='complete',
satellite=self._current_satellite,
@@ -394,6 +406,13 @@ class WeatherSatDecoder:
elapsed_seconds=elapsed,
))
# 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: