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 f409222f8a
commit b860a4309b
10 changed files with 1653 additions and 144 deletions

View File

@@ -145,6 +145,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
&& ldconfig \
&& cd /tmp \
&& rm -rf /tmp/SatDump \
# Build rtlamr (utility meter decoder - requires Go)
&& cd /tmp \
&& curl -fsSL "https://go.dev/dl/go1.22.5.linux-$(dpkg --print-architecture).tar.gz" | tar -C /usr/local -xz \
&& export PATH="$PATH:/usr/local/go/bin" \
&& export GOPATH=/tmp/gopath \
&& go install github.com/bemasher/rtlamr@latest \
&& cp /tmp/gopath/bin/rtlamr /usr/bin/rtlamr \
&& rm -rf /usr/local/go /tmp/gopath \
# Cleanup build tools to reduce image size
&& apt-get remove -y \
build-essential \

View File

@@ -196,6 +196,8 @@ WEATHER_SAT_DEFAULT_GAIN = _get_env_float('WEATHER_SAT_GAIN', 40.0)
WEATHER_SAT_SAMPLE_RATE = _get_env_int('WEATHER_SAT_SAMPLE_RATE', 1000000)
WEATHER_SAT_MIN_ELEVATION = _get_env_float('WEATHER_SAT_MIN_ELEVATION', 15.0)
WEATHER_SAT_PREDICTION_HOURS = _get_env_int('WEATHER_SAT_PREDICTION_HOURS', 24)
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = _get_env_int('WEATHER_SAT_SCHEDULE_REFRESH_MINUTES', 30)
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = _get_env_int('WEATHER_SAT_CAPTURE_BUFFER_SECONDS', 30)
# Update checking
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')

View File

@@ -162,8 +162,18 @@ def start_capture():
except queue.Empty:
break
# Set callback and start
# Set callback and on-complete handler for SDR release
decoder.set_callback(_progress_callback)
def _release_device():
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
decoder.set_on_complete(_release_device)
success = decoder.start(
satellite=satellite,
device_index=device_index,
@@ -182,11 +192,7 @@ def start_capture():
})
else:
# Release device on failure
try:
import app as app_module
app_module.release_sdr_device(device_index)
except ImportError:
pass
_release_device()
return jsonify({
'status': 'error',
'message': 'Failed to start capture'
@@ -333,6 +339,8 @@ def get_passes():
longitude: Observer longitude (required)
hours: Hours to predict ahead (default: 24, max: 72)
min_elevation: Minimum elevation in degrees (default: 15)
trajectory: Include az/el trajectory points (default: false)
ground_track: Include lat/lon ground track points (default: false)
Returns:
JSON with upcoming passes for all weather satellites.
@@ -341,6 +349,8 @@ def get_passes():
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_ground_track = request.args.get('ground_track', 'false').lower() in ('true', '1')
if lat is None or lon is None:
return jsonify({
@@ -357,119 +367,16 @@ def get_passes():
min_elevation = max(0, min(min_elevation, 90))
try:
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from data.satellites import TLE_SATELLITES
from utils.weather_sat_predict import predict_passes
ts = load.timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + __import__('datetime').timedelta(hours=hours))
all_passes = []
for sat_key, sat_info in WEATHER_SATELLITES.items():
if not sat_info['active']:
continue
tle_data = TLE_SATELLITES.get(sat_info['tle_key'])
if not tle_data:
continue
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
def above_horizon(t, _sat=satellite):
diff = _sat - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1 / 720
try:
times, events = find_discrete(t0, t1, above_horizon)
except Exception:
continue
i = 0
while i < len(times):
if i < len(events) and events[i]: # Rising
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)):
if not events[j]: # Setting
set_time = times[j]
i = j
break
else:
i += 1
continue
if set_time is None:
i += 1
continue
# Calculate max elevation
max_el = 0
max_el_az = 0
duration_seconds = (
set_time.utc_datetime() - rise_time.utc_datetime()
).total_seconds()
duration_minutes = round(duration_seconds / 60, 1)
for k in range(30):
frac = k / 29
t_point = ts.utc(
rise_time.utc_datetime()
+ __import__('datetime').timedelta(
seconds=duration_seconds * frac
all_passes = predict_passes(
lat=lat,
lon=lon,
hours=hours,
min_elevation=min_elevation,
include_trajectory=include_trajectory,
include_ground_track=include_ground_track,
)
)
diff = satellite - observer
topocentric = diff.at(t_point)
alt, az, _ = topocentric.altaz()
if alt.degrees > max_el:
max_el = alt.degrees
max_el_az = az.degrees
if max_el >= min_elevation:
# Calculate rise/set azimuth
rise_diff = satellite - observer
rise_topo = rise_diff.at(rise_time)
_, rise_az, _ = rise_topo.altaz()
set_diff = satellite - observer
set_topo = set_diff.at(set_time)
_, set_az, _ = set_topo.altaz()
pass_data = {
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime(
'%Y-%m-%d %H:%M UTC'
),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'endTimeISO': set_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1),
'setAz': round(set_az.degrees, 1),
'duration': duration_minutes,
'quality': (
'excellent' if max_el >= 60
else 'good' if max_el >= 30
else 'fair'
),
}
all_passes.append(pass_data)
i += 1
# Sort by start time
all_passes.sort(key=lambda p: p['startTimeISO'])
return jsonify({
'status': 'ok',
@@ -492,3 +399,124 @@ def get_passes():
'status': 'error',
'message': str(e)
}), 500
# ========================
# Auto-Scheduler Endpoints
# ========================
def _scheduler_event_callback(event: dict) -> None:
"""Forward scheduler events to the SSE queue."""
try:
_weather_sat_queue.put_nowait(event)
except queue.Full:
try:
_weather_sat_queue.get_nowait()
_weather_sat_queue.put_nowait(event)
except queue.Empty:
pass
@weather_sat_bp.route('/schedule/enable', methods=['POST'])
def enable_schedule():
"""Enable auto-scheduling of weather satellite captures.
JSON body:
{
"latitude": 51.5, // Required
"longitude": -0.1, // Required
"min_elevation": 15, // Minimum pass elevation (default: 15)
"device": 0, // RTL-SDR device index (default: 0)
"gain": 40.0, // SDR gain (default: 40)
"bias_t": false // Enable bias-T (default: false)
}
Returns:
JSON with scheduler status.
"""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
data = request.get_json(silent=True) or {}
lat = data.get('latitude')
lon = data.get('longitude')
if lat is None or lon is None:
return jsonify({
'status': 'error',
'message': 'latitude and longitude required'
}), 400
try:
lat = float(lat)
lon = float(lon)
if not (-90 <= lat <= 90) or not (-180 <= lon <= 180):
raise ValueError
except (TypeError, ValueError):
return jsonify({
'status': 'error',
'message': 'Invalid coordinates'
}), 400
scheduler = get_weather_sat_scheduler()
scheduler.set_callbacks(_progress_callback, _scheduler_event_callback)
result = scheduler.enable(
lat=lat,
lon=lon,
min_elevation=float(data.get('min_elevation', 15)),
device=int(data.get('device', 0)),
gain=float(data.get('gain', 40.0)),
bias_t=bool(data.get('bias_t', False)),
)
return jsonify({'status': 'ok', **result})
@weather_sat_bp.route('/schedule/disable', methods=['POST'])
def disable_schedule():
"""Disable auto-scheduling."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
scheduler = get_weather_sat_scheduler()
result = scheduler.disable()
return jsonify(result)
@weather_sat_bp.route('/schedule/status')
def schedule_status():
"""Get current scheduler state."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
scheduler = get_weather_sat_scheduler()
return jsonify(scheduler.get_status())
@weather_sat_bp.route('/schedule/passes')
def schedule_passes():
"""List scheduled passes."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
scheduler = get_weather_sat_scheduler()
passes = scheduler.get_passes()
return jsonify({
'status': 'ok',
'passes': passes,
'count': len(passes),
})
@weather_sat_bp.route('/schedule/skip/<pass_id>', methods=['POST'])
def skip_pass(pass_id: str):
"""Skip a scheduled pass."""
from utils.weather_sat_scheduler import get_weather_sat_scheduler
if not pass_id.replace('_', '').replace('-', '').isalnum():
return jsonify({'status': 'error', 'message': 'Invalid pass ID'}), 400
scheduler = get_weather_sat_scheduler()
if scheduler.skip_pass(pass_id):
return jsonify({'status': 'skipped', 'pass_id': pass_id})
else:
return jsonify({'status': 'error', 'message': 'Pass not found or already processed'}), 404

View File

@@ -107,6 +107,30 @@
color: var(--accent-cyan, #00d4ff);
}
/* ===== Auto-Schedule Toggle ===== */
.wxsat-schedule-toggle {
display: flex;
align-items: center;
gap: 6px;
cursor: pointer;
font-size: 10px;
font-family: 'JetBrains Mono', monospace;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wxsat-schedule-toggle input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: #00ff88;
}
.wxsat-schedule-toggle input:checked + .wxsat-toggle-label {
color: #00ff88;
}
/* ===== Location inputs in strip ===== */
.wxsat-strip-location {
display: flex;
@@ -142,16 +166,160 @@
.wxsat-content {
display: flex;
gap: 16px;
padding: 16px;
gap: 12px;
padding: 12px;
flex: 1;
min-height: 0;
overflow: auto;
}
/* ===== Countdown Bar ===== */
.wxsat-countdown-bar {
display: flex;
align-items: center;
gap: 16px;
padding: 10px 16px;
background: var(--bg-secondary, #141820);
border-bottom: 1px solid var(--border-color, #2a3040);
}
.wxsat-countdown-next {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
}
.wxsat-countdown-boxes {
display: flex;
gap: 4px;
}
.wxsat-countdown-box {
display: flex;
flex-direction: column;
align-items: center;
padding: 4px 8px;
background: var(--bg-primary, #0d1117);
border: 1px solid var(--border-color, #2a3040);
border-radius: 4px;
min-width: 40px;
}
.wxsat-countdown-box.imminent {
border-color: #ffbb00;
box-shadow: 0 0 8px rgba(255, 187, 0, 0.2);
}
.wxsat-countdown-box.active {
border-color: #00ff88;
box-shadow: 0 0 8px rgba(0, 255, 136, 0.3);
animation: wxsat-glow 1.5s ease-in-out infinite;
}
@keyframes wxsat-glow {
0%, 100% { box-shadow: 0 0 8px rgba(0, 255, 136, 0.3); }
50% { box-shadow: 0 0 16px rgba(0, 255, 136, 0.5); }
}
.wxsat-cd-value {
font-size: 16px;
font-weight: 700;
font-family: 'JetBrains Mono', monospace;
color: var(--text-primary, #e0e0e0);
line-height: 1;
}
.wxsat-cd-unit {
font-size: 8px;
color: var(--text-dim, #666);
text-transform: uppercase;
letter-spacing: 0.5px;
margin-top: 2px;
}
.wxsat-countdown-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.wxsat-countdown-sat {
font-size: 12px;
font-weight: 600;
color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace;
}
.wxsat-countdown-detail {
font-size: 10px;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
}
/* ===== Timeline ===== */
.wxsat-timeline {
flex: 1;
position: relative;
height: 36px;
min-width: 200px;
}
.wxsat-timeline-track {
position: absolute;
top: 4px;
left: 0;
right: 0;
height: 16px;
background: var(--bg-primary, #0d1117);
border: 1px solid var(--border-color, #2a3040);
border-radius: 3px;
overflow: hidden;
}
.wxsat-timeline-pass {
position: absolute;
top: 0;
height: 100%;
border-radius: 2px;
cursor: pointer;
opacity: 0.8;
transition: opacity 0.2s;
}
.wxsat-timeline-pass:hover {
opacity: 1;
}
.wxsat-timeline-pass.apt { background: rgba(0, 212, 255, 0.6); }
.wxsat-timeline-pass.lrpt { background: rgba(0, 255, 136, 0.6); }
.wxsat-timeline-pass.scheduled { border: 1px solid #ffbb00; }
.wxsat-timeline-cursor {
position: absolute;
top: 2px;
width: 2px;
height: 20px;
background: #ff4444;
border-radius: 1px;
z-index: 2;
}
.wxsat-timeline-labels {
position: absolute;
bottom: 0;
left: 0;
right: 0;
display: flex;
justify-content: space-between;
font-size: 8px;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
}
/* ===== Pass Predictions Panel ===== */
.wxsat-passes-panel {
flex: 0 0 320px;
flex: 0 0 280px;
display: flex;
flex-direction: column;
gap: 0;
@@ -205,11 +373,25 @@
background: var(--bg-hover, #252a3a);
}
.wxsat-pass-card.active {
.wxsat-pass-card.active,
.wxsat-pass-card.selected {
border-color: #00ff88;
background: rgba(0, 255, 136, 0.05);
}
.wxsat-pass-card .wxsat-scheduled-badge {
display: inline-block;
font-size: 8px;
padding: 1px 4px;
border-radius: 2px;
background: rgba(255, 187, 0, 0.15);
color: #ffbb00;
margin-left: 6px;
font-family: 'JetBrains Mono', monospace;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wxsat-pass-sat {
display: flex;
align-items: center;
@@ -281,6 +463,57 @@
color: #ffbb00;
}
/* ===== Center Panel (Polar + Map) ===== */
.wxsat-center-panel {
flex: 0 0 320px;
display: flex;
flex-direction: column;
gap: 12px;
}
.wxsat-polar-container,
.wxsat-map-container {
background: var(--bg-secondary, #141820);
border: 1px solid var(--border-color, #2a3040);
border-radius: 6px;
overflow: hidden;
}
.wxsat-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 14px;
background: var(--bg-tertiary, #1a1f2e);
border-bottom: 1px solid var(--border-color, #2a3040);
}
.wxsat-panel-title {
font-size: 11px;
font-weight: 600;
color: var(--text-primary, #e0e0e0);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.wxsat-panel-subtitle {
font-size: 10px;
color: var(--accent-cyan, #00d4ff);
font-family: 'JetBrains Mono', monospace;
}
#wxsatPolarCanvas {
display: block;
width: 100%;
height: auto;
max-height: 300px;
}
.wxsat-ground-map {
height: 200px;
background: var(--bg-primary, #0d1117);
}
/* ===== Image Gallery Panel ===== */
.wxsat-gallery-panel {
flex: 1;
@@ -322,7 +555,7 @@
overflow-y: auto;
padding: 12px;
display: grid;
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
gap: 12px;
align-content: start;
}
@@ -421,12 +654,18 @@
font-size: 11px;
color: var(--text-secondary, #999);
font-family: 'JetBrains Mono', monospace;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
margin-right: 12px;
}
.wxsat-capture-elapsed {
font-size: 11px;
color: var(--text-dim, #666);
font-family: 'JetBrains Mono', monospace;
flex-shrink: 0;
}
.wxsat-progress-bar {
@@ -496,14 +735,53 @@
}
/* ===== Responsive ===== */
@media (max-width: 900px) {
@media (max-width: 1100px) {
.wxsat-content {
flex-direction: column;
}
.wxsat-passes-panel {
flex: none;
max-height: 300px;
max-height: 250px;
}
.wxsat-center-panel {
flex: none;
flex-direction: row;
gap: 12px;
}
.wxsat-polar-container,
.wxsat-map-container {
flex: 1;
}
.wxsat-countdown-bar {
flex-wrap: wrap;
}
.wxsat-timeline {
min-width: 0;
flex: 1 1 200px;
}
}
@media (max-width: 768px) {
.wxsat-center-panel {
flex-direction: column;
}
.wxsat-countdown-boxes {
gap: 2px;
}
.wxsat-countdown-box {
min-width: 32px;
padding: 3px 5px;
}
.wxsat-cd-value {
font-size: 13px;
}
.wxsat-gallery-grid {

View File

@@ -1,6 +1,7 @@
/**
* Weather Satellite Mode
* NOAA APT and Meteor LRPT decoder interface
* NOAA APT and Meteor LRPT decoder interface with auto-scheduler,
* polar plot, ground track map, countdown, and timeline.
*/
const WeatherSat = (function() {
@@ -9,7 +10,13 @@ const WeatherSat = (function() {
let eventSource = null;
let images = [];
let passes = [];
let selectedPassIndex = -1;
let currentSatellite = null;
let countdownInterval = null;
let schedulerEnabled = false;
let groundMap = null;
let groundTrackLayer = null;
let observerMarker = null;
/**
* Initialize the Weather Satellite mode
@@ -19,6 +26,9 @@ const WeatherSat = (function() {
loadImages();
loadLocationInputs();
loadPasses();
startCountdownTimer();
checkSchedulerStatus();
initGroundMap();
}
/**
@@ -261,6 +271,8 @@ const WeatherSat = (function() {
const data = JSON.parse(e.data);
if (data.type === 'weather_sat_progress') {
handleProgress(data);
} else if (data.type && data.type.startsWith('schedule_')) {
handleSchedulerSSE(data);
}
} catch (err) {
console.error('Failed to parse SSE:', err);
@@ -269,7 +281,7 @@ const WeatherSat = (function() {
eventSource.onerror = () => {
setTimeout(() => {
if (isRunning) startStream();
if (isRunning || schedulerEnabled) startStream();
}, 3000);
};
}
@@ -312,7 +324,7 @@ const WeatherSat = (function() {
if (!data.image) {
// Capture ended
isRunning = false;
stopStream();
if (!schedulerEnabled) stopStream();
updateStatusUI('idle', 'Capture complete');
if (captureStatus) captureStatus.classList.remove('active');
}
@@ -324,6 +336,26 @@ const WeatherSat = (function() {
}
}
/**
* Handle scheduler SSE events
*/
function handleSchedulerSSE(data) {
if (data.type === 'schedule_capture_start') {
isRunning = true;
const p = data.pass || {};
currentSatellite = p.satellite;
updateStatusUI('capturing', `Auto: ${p.name || p.satellite} ${p.frequency} MHz`);
showNotification('Weather Sat', `Auto-capture started: ${p.name || p.satellite}`);
} else if (data.type === 'schedule_capture_complete') {
showNotification('Weather Sat', `Auto-capture complete: ${(data.pass || {}).name || ''}`);
loadImages();
} else if (data.type === 'schedule_capture_skipped') {
const reason = data.reason || 'unknown';
const p = data.pass || {};
showNotification('Weather Sat', `Pass skipped (${reason}): ${p.name || p.satellite}`);
}
}
/**
* Format elapsed seconds
*/
@@ -334,7 +366,7 @@ const WeatherSat = (function() {
}
/**
* Load pass predictions
* Load pass predictions (with trajectory + ground track)
*/
async function loadPasses() {
const storedLat = localStorage.getItem('observerLat');
@@ -346,19 +378,49 @@ const WeatherSat = (function() {
}
try {
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15`;
const url = `/weather-sat/passes?latitude=${storedLat}&longitude=${storedLon}&hours=24&min_elevation=15&trajectory=true&ground_track=true`;
const response = await fetch(url);
const data = await response.json();
if (data.status === 'ok') {
passes = data.passes || [];
renderPasses(passes);
renderTimeline(passes);
updateCountdownFromPasses();
// Auto-select first pass
if (passes.length > 0 && selectedPassIndex < 0) {
selectPass(0);
}
}
} catch (err) {
console.error('Failed to load passes:', err);
}
}
/**
* Select a pass to display in polar plot and map
*/
function selectPass(index) {
if (index < 0 || index >= passes.length) return;
selectedPassIndex = index;
const pass = passes[index];
// Highlight active card
document.querySelectorAll('.wxsat-pass-card').forEach((card, i) => {
card.classList.toggle('selected', i === index);
});
// Update polar plot
drawPolarPlot(pass);
// Update ground track
updateGroundTrack(pass);
// Update polar panel subtitle
const polarSat = document.getElementById('wxsatPolarSat');
if (polarSat) polarSat.textContent = `${pass.name} ${pass.maxEl}\u00b0`;
}
/**
* Render pass predictions list
*/
@@ -387,6 +449,7 @@ const WeatherSat = (function() {
const passStart = new Date(pass.startTimeISO);
const diffMs = passStart - now;
const diffMins = Math.floor(diffMs / 60000);
const isSelected = idx === selectedPassIndex;
let countdown = '';
if (diffMs < 0) {
@@ -400,7 +463,7 @@ const WeatherSat = (function() {
}
return `
<div class="wxsat-pass-card" onclick="WeatherSat.startPass('${escapeHtml(pass.satellite)}')">
<div class="wxsat-pass-card${isSelected ? ' selected' : ''}" onclick="WeatherSat.selectPass(${idx})">
<div class="wxsat-pass-sat">
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</span>
@@ -419,11 +482,479 @@ const WeatherSat = (function() {
<span class="wxsat-pass-quality ${pass.quality}">${pass.quality}</span>
<span style="font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace;">${countdown}</span>
</div>
<div style="margin-top: 6px; text-align: right;">
<button class="wxsat-strip-btn" onclick="event.stopPropagation(); WeatherSat.startPass('${escapeHtml(pass.satellite)}')" style="font-size: 10px; padding: 2px 8px;">Capture</button>
</div>
</div>
`;
}).join('');
}
// ========================
// Polar Plot
// ========================
/**
* Draw polar plot for a pass trajectory
*/
function drawPolarPlot(pass) {
const canvas = document.getElementById('wxsatPolarCanvas');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width;
const h = canvas.height;
const cx = w / 2;
const cy = h / 2;
const r = Math.min(cx, cy) - 20;
ctx.clearRect(0, 0, w, h);
// Background
ctx.fillStyle = '#0d1117';
ctx.fillRect(0, 0, w, h);
// Grid circles (30, 60, 90 deg elevation)
ctx.strokeStyle = '#2a3040';
ctx.lineWidth = 0.5;
[90, 60, 30].forEach((el, i) => {
const gr = r * (1 - el / 90);
ctx.beginPath();
ctx.arc(cx, cy, gr, 0, Math.PI * 2);
ctx.stroke();
// Label
ctx.fillStyle = '#555';
ctx.font = '9px JetBrains Mono, monospace';
ctx.textAlign = 'left';
ctx.fillText(el + '\u00b0', cx + gr + 3, cy - 2);
});
// Horizon circle
ctx.strokeStyle = '#3a4050';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.stroke();
// Cardinal directions
ctx.fillStyle = '#666';
ctx.font = '10px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('N', cx, cy - r - 10);
ctx.fillText('S', cx, cy + r + 10);
ctx.fillText('E', cx + r + 10, cy);
ctx.fillText('W', cx - r - 10, cy);
// Cross hairs
ctx.strokeStyle = '#2a3040';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(cx, cy - r);
ctx.lineTo(cx, cy + r);
ctx.moveTo(cx - r, cy);
ctx.lineTo(cx + r, cy);
ctx.stroke();
// Trajectory
const trajectory = pass.trajectory;
if (!trajectory || trajectory.length === 0) return;
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
ctx.beginPath();
ctx.strokeStyle = color;
ctx.lineWidth = 2;
trajectory.forEach((pt, i) => {
const elRad = (90 - pt.el) / 90;
const azRad = (pt.az - 90) * Math.PI / 180; // offset: N is up
const px = cx + r * elRad * Math.cos(azRad);
const py = cy + r * elRad * Math.sin(azRad);
if (i === 0) ctx.moveTo(px, py);
else ctx.lineTo(px, py);
});
ctx.stroke();
// Start point (green dot)
const start = trajectory[0];
const startR = (90 - start.el) / 90;
const startAz = (start.az - 90) * Math.PI / 180;
ctx.fillStyle = '#00ff88';
ctx.beginPath();
ctx.arc(cx + r * startR * Math.cos(startAz), cy + r * startR * Math.sin(startAz), 4, 0, Math.PI * 2);
ctx.fill();
// End point (red dot)
const end = trajectory[trajectory.length - 1];
const endR = (90 - end.el) / 90;
const endAz = (end.az - 90) * Math.PI / 180;
ctx.fillStyle = '#ff4444';
ctx.beginPath();
ctx.arc(cx + r * endR * Math.cos(endAz), cy + r * endR * Math.sin(endAz), 4, 0, Math.PI * 2);
ctx.fill();
// Max elevation marker
let maxEl = 0;
let maxPt = trajectory[0];
trajectory.forEach(pt => { if (pt.el > maxEl) { maxEl = pt.el; maxPt = pt; } });
const maxR = (90 - maxPt.el) / 90;
const maxAz = (maxPt.az - 90) * Math.PI / 180;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz), 3, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = color;
ctx.font = '9px JetBrains Mono, monospace';
ctx.textAlign = 'center';
ctx.fillText(Math.round(maxEl) + '\u00b0', cx + r * maxR * Math.cos(maxAz), cy + r * maxR * Math.sin(maxAz) - 8);
}
// ========================
// Ground Track Map
// ========================
/**
* Initialize Leaflet ground track map
*/
function initGroundMap() {
const container = document.getElementById('wxsatGroundMap');
if (!container || groundMap) return;
if (typeof L === 'undefined') return;
groundMap = L.map(container, {
center: [20, 0],
zoom: 2,
zoomControl: false,
attributionControl: false,
});
// Check tile provider from settings
let tileUrl = 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png';
try {
const provider = localStorage.getItem('tileProvider');
if (provider === 'osm') {
tileUrl = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
}
} catch (e) {}
L.tileLayer(tileUrl, { maxZoom: 10 }).addTo(groundMap);
groundTrackLayer = L.layerGroup().addTo(groundMap);
// Delayed invalidation to fix sizing
setTimeout(() => { if (groundMap) groundMap.invalidateSize(); }, 200);
}
/**
* Update ground track on the map
*/
function updateGroundTrack(pass) {
if (!groundMap || !groundTrackLayer) return;
groundTrackLayer.clearLayers();
const track = pass.groundTrack;
if (!track || track.length === 0) return;
const color = pass.mode === 'LRPT' ? '#00ff88' : '#00d4ff';
// Draw polyline
const latlngs = track.map(p => [p.lat, p.lon]);
L.polyline(latlngs, { color, weight: 2, opacity: 0.8 }).addTo(groundTrackLayer);
// Start marker
L.circleMarker(latlngs[0], {
radius: 5, color: '#00ff88', fillColor: '#00ff88', fillOpacity: 1, weight: 0,
}).addTo(groundTrackLayer);
// End marker
L.circleMarker(latlngs[latlngs.length - 1], {
radius: 5, color: '#ff4444', fillColor: '#ff4444', fillOpacity: 1, weight: 0,
}).addTo(groundTrackLayer);
// Observer marker
const lat = parseFloat(localStorage.getItem('observerLat'));
const lon = parseFloat(localStorage.getItem('observerLon'));
if (!isNaN(lat) && !isNaN(lon)) {
L.circleMarker([lat, lon], {
radius: 6, color: '#ffbb00', fillColor: '#ffbb00', fillOpacity: 0.8, weight: 1,
}).addTo(groundTrackLayer);
}
// Fit bounds
try {
const bounds = L.latLngBounds(latlngs);
if (!isNaN(lat) && !isNaN(lon)) bounds.extend([lat, lon]);
groundMap.fitBounds(bounds, { padding: [20, 20] });
} catch (e) {}
}
// ========================
// Countdown
// ========================
/**
* Start the countdown interval timer
*/
function startCountdownTimer() {
if (countdownInterval) clearInterval(countdownInterval);
countdownInterval = setInterval(updateCountdownFromPasses, 1000);
}
/**
* Update countdown display from passes array
*/
function updateCountdownFromPasses() {
const now = new Date();
let nextPass = null;
let isActive = false;
for (const pass of passes) {
const start = new Date(pass.startTimeISO);
const end = new Date(pass.endTimeISO);
if (end > now) {
nextPass = pass;
isActive = start <= now;
break;
}
}
const daysEl = document.getElementById('wxsatCdDays');
const hoursEl = document.getElementById('wxsatCdHours');
const minsEl = document.getElementById('wxsatCdMins');
const secsEl = document.getElementById('wxsatCdSecs');
const satEl = document.getElementById('wxsatCountdownSat');
const detailEl = document.getElementById('wxsatCountdownDetail');
const boxes = document.getElementById('wxsatCountdownBoxes');
if (!nextPass) {
if (daysEl) daysEl.textContent = '--';
if (hoursEl) hoursEl.textContent = '--';
if (minsEl) minsEl.textContent = '--';
if (secsEl) secsEl.textContent = '--';
if (satEl) satEl.textContent = '--';
if (detailEl) detailEl.textContent = 'No passes predicted';
if (boxes) boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => {
b.classList.remove('imminent', 'active');
});
return;
}
const target = new Date(nextPass.startTimeISO);
let diffMs = target - now;
if (isActive) {
diffMs = 0;
}
const totalSec = Math.max(0, Math.floor(diffMs / 1000));
const d = Math.floor(totalSec / 86400);
const h = Math.floor((totalSec % 86400) / 3600);
const m = Math.floor((totalSec % 3600) / 60);
const s = totalSec % 60;
if (daysEl) daysEl.textContent = d.toString().padStart(2, '0');
if (hoursEl) hoursEl.textContent = h.toString().padStart(2, '0');
if (minsEl) minsEl.textContent = m.toString().padStart(2, '0');
if (secsEl) secsEl.textContent = s.toString().padStart(2, '0');
if (satEl) satEl.textContent = `${nextPass.name} ${nextPass.frequency} MHz`;
if (detailEl) {
if (isActive) {
detailEl.textContent = `ACTIVE - ${nextPass.maxEl}\u00b0 max el`;
} else {
detailEl.textContent = `${nextPass.maxEl}\u00b0 max el / ${nextPass.duration} min`;
}
}
// Countdown box states
if (boxes) {
const isImminent = totalSec < 600 && totalSec > 0; // < 10 min
boxes.querySelectorAll('.wxsat-countdown-box').forEach(b => {
b.classList.toggle('imminent', isImminent);
b.classList.toggle('active', isActive);
});
}
}
// ========================
// Timeline
// ========================
/**
* Render 24h timeline with pass markers
*/
function renderTimeline(passList) {
const track = document.getElementById('wxsatTimelineTrack');
const cursor = document.getElementById('wxsatTimelineCursor');
if (!track) return;
// Clear existing pass markers
track.querySelectorAll('.wxsat-timeline-pass').forEach(el => el.remove());
const now = new Date();
const dayStart = new Date(now);
dayStart.setHours(0, 0, 0, 0);
const dayMs = 24 * 60 * 60 * 1000;
passList.forEach((pass, idx) => {
const start = new Date(pass.startTimeISO);
const end = new Date(pass.endTimeISO);
const startPct = Math.max(0, Math.min(100, ((start - dayStart) / dayMs) * 100));
const endPct = Math.max(0, Math.min(100, ((end - dayStart) / dayMs) * 100));
const widthPct = Math.max(0.5, endPct - startPct);
const marker = document.createElement('div');
marker.className = `wxsat-timeline-pass ${pass.mode === 'LRPT' ? 'lrpt' : 'apt'}`;
marker.style.left = startPct + '%';
marker.style.width = widthPct + '%';
marker.title = `${pass.name} ${pass.startTime} (${pass.maxEl}\u00b0)`;
marker.onclick = () => selectPass(idx);
track.appendChild(marker);
});
// Update cursor position
updateTimelineCursor();
}
/**
* Update timeline cursor to current time
*/
function updateTimelineCursor() {
const cursor = document.getElementById('wxsatTimelineCursor');
if (!cursor) return;
const now = new Date();
const dayStart = new Date(now);
dayStart.setHours(0, 0, 0, 0);
const pct = ((now - dayStart) / (24 * 60 * 60 * 1000)) * 100;
cursor.style.left = pct + '%';
}
// ========================
// Auto-Scheduler
// ========================
/**
* Toggle auto-scheduler
*/
async function toggleScheduler() {
const stripCheckbox = document.getElementById('wxsatAutoSchedule');
const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
const checked = stripCheckbox?.checked || sidebarCheckbox?.checked;
// Sync both checkboxes
if (stripCheckbox) stripCheckbox.checked = checked;
if (sidebarCheckbox) sidebarCheckbox.checked = checked;
if (checked) {
await enableScheduler();
} else {
await disableScheduler();
}
}
/**
* Enable auto-scheduler
*/
async function enableScheduler() {
const lat = parseFloat(localStorage.getItem('observerLat'));
const lon = parseFloat(localStorage.getItem('observerLon'));
if (isNaN(lat) || isNaN(lon)) {
showNotification('Weather Sat', 'Set observer location first');
const stripCheckbox = document.getElementById('wxsatAutoSchedule');
const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
if (stripCheckbox) stripCheckbox.checked = false;
if (sidebarCheckbox) sidebarCheckbox.checked = false;
return;
}
const deviceSelect = document.getElementById('deviceSelect');
const gainInput = document.getElementById('weatherSatGain');
const biasTInput = document.getElementById('weatherSatBiasT');
try {
const response = await fetch('/weather-sat/schedule/enable', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
latitude: lat,
longitude: lon,
device: parseInt(deviceSelect?.value || '0', 10),
gain: parseFloat(gainInput?.value || '40'),
bias_t: biasTInput?.checked || false,
}),
});
const data = await response.json();
schedulerEnabled = true;
updateSchedulerUI(data);
startStream();
showNotification('Weather Sat', `Auto-scheduler enabled (${data.scheduled_count || 0} passes)`);
} catch (err) {
console.error('Failed to enable scheduler:', err);
showNotification('Weather Sat', 'Failed to enable auto-scheduler');
}
}
/**
* Disable auto-scheduler
*/
async function disableScheduler() {
try {
await fetch('/weather-sat/schedule/disable', { method: 'POST' });
schedulerEnabled = false;
updateSchedulerUI({ enabled: false });
if (!isRunning) stopStream();
showNotification('Weather Sat', 'Auto-scheduler disabled');
} catch (err) {
console.error('Failed to disable scheduler:', err);
}
}
/**
* Check current scheduler status
*/
async function checkSchedulerStatus() {
try {
const response = await fetch('/weather-sat/schedule/status');
const data = await response.json();
schedulerEnabled = data.enabled;
updateSchedulerUI(data);
if (schedulerEnabled) startStream();
} catch (err) {
// Scheduler endpoint may not exist yet
}
}
/**
* Update scheduler UI elements
*/
function updateSchedulerUI(data) {
const stripCheckbox = document.getElementById('wxsatAutoSchedule');
const sidebarCheckbox = document.getElementById('wxsatSidebarAutoSchedule');
const statusEl = document.getElementById('wxsatSchedulerStatus');
if (stripCheckbox) stripCheckbox.checked = data.enabled;
if (sidebarCheckbox) sidebarCheckbox.checked = data.enabled;
if (statusEl) {
if (data.enabled) {
statusEl.textContent = `Active: ${data.scheduled_count || 0} passes queued`;
statusEl.style.color = '#00ff88';
} else {
statusEl.textContent = 'Disabled';
statusEl.style.color = '';
}
}
}
// ========================
// Images
// ========================
/**
* Load decoded images
*/
@@ -544,17 +1075,29 @@ const WeatherSat = (function() {
return div.innerHTML;
}
/**
* Invalidate ground map size (call after container becomes visible)
*/
function invalidateMap() {
if (groundMap) {
setTimeout(() => groundMap.invalidateSize(), 100);
}
}
// Public API
return {
init,
start,
stop,
startPass,
selectPass,
loadImages,
loadPasses,
showImage,
closeImage,
useGPS,
toggleScheduler,
invalidateMap,
};
})();

View File

@@ -1925,6 +1925,36 @@
</button>
</div>
</div>
<div class="wxsat-strip-divider"></div>
<div class="wxsat-strip-group">
<label class="wxsat-schedule-toggle" title="Auto-capture passes">
<input type="checkbox" id="wxsatAutoSchedule" onchange="WeatherSat.toggleScheduler()">
<span class="wxsat-toggle-label">AUTO</span>
</label>
</div>
</div>
<!-- Countdown + Timeline -->
<div class="wxsat-countdown-bar">
<div class="wxsat-countdown-next">
<div class="wxsat-countdown-boxes" id="wxsatCountdownBoxes">
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdDays">--</span><span class="wxsat-cd-unit">DAYS</span></div>
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdHours">--</span><span class="wxsat-cd-unit">HRS</span></div>
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdMins">--</span><span class="wxsat-cd-unit">MIN</span></div>
<div class="wxsat-countdown-box"><span class="wxsat-cd-value" id="wxsatCdSecs">--</span><span class="wxsat-cd-unit">SEC</span></div>
</div>
<div class="wxsat-countdown-info" id="wxsatCountdownInfo">
<span class="wxsat-countdown-sat" id="wxsatCountdownSat">--</span>
<span class="wxsat-countdown-detail" id="wxsatCountdownDetail">No passes predicted</span>
</div>
</div>
<div class="wxsat-timeline" id="wxsatTimeline">
<div class="wxsat-timeline-track" id="wxsatTimelineTrack"></div>
<div class="wxsat-timeline-cursor" id="wxsatTimelineCursor"></div>
<div class="wxsat-timeline-labels">
<span>00:00</span><span>06:00</span><span>12:00</span><span>18:00</span><span>24:00</span>
</div>
</div>
</div>
<!-- Capture progress -->
@@ -1938,9 +1968,9 @@
</div>
</div>
<!-- Main content: passes + gallery -->
<!-- Main content: 3-column layout -->
<div class="wxsat-content">
<!-- Pass predictions -->
<!-- Left: Pass predictions -->
<div class="wxsat-passes-panel">
<div class="wxsat-passes-header">
<span class="wxsat-passes-title">Upcoming Passes</span>
@@ -1953,7 +1983,24 @@
</div>
</div>
<!-- Image gallery -->
<!-- Center: Polar plot + Ground track map -->
<div class="wxsat-center-panel">
<div class="wxsat-polar-container">
<div class="wxsat-panel-header">
<span class="wxsat-panel-title">Polar Plot</span>
<span class="wxsat-panel-subtitle" id="wxsatPolarSat">--</span>
</div>
<canvas id="wxsatPolarCanvas" width="300" height="300"></canvas>
</div>
<div class="wxsat-map-container">
<div class="wxsat-panel-header">
<span class="wxsat-panel-title">Ground Track</span>
</div>
<div id="wxsatGroundMap" class="wxsat-ground-map"></div>
</div>
</div>
<!-- Right: Image gallery -->
<div class="wxsat-gallery-panel">
<div class="wxsat-gallery-header">
<span class="wxsat-gallery-title">Decoded Images</span>
@@ -2899,6 +2946,9 @@
SSTV.init();
} else if (mode === 'weathersat') {
WeatherSat.init();
setTimeout(() => {
WeatherSat.invalidateMap();
}, 100);
}
}

View File

@@ -174,6 +174,23 @@
</div>
</div>
<div class="section">
<h3>Auto-Scheduler</h3>
<p class="info-text" style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
Automatically capture satellite passes based on predictions.
Set your location above and toggle AUTO in the strip bar.
</p>
<div class="form-group">
<label style="display: flex; align-items: center; gap: 6px;">
<input type="checkbox" id="wxsatSidebarAutoSchedule" onchange="WeatherSat.toggleScheduler()" style="width: auto;">
Enable Auto-Capture
</label>
</div>
<div id="wxsatSchedulerStatus" style="font-size: 11px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace; margin-top: 4px;">
Disabled
</div>
</div>
<div class="section">
<h3>Resources</h3>
<div style="display: flex; flex-direction: column; gap: 6px;">

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:
# Process ended — release resources
was_running = self._running
self._running = False
elapsed = int(time.time() - self._capture_start_time)
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:

View File

@@ -0,0 +1,179 @@
"""Weather satellite pass prediction utility.
Shared prediction logic used by both the API endpoint and the auto-scheduler.
"""
from __future__ import annotations
import datetime
from typing import Any
from utils.logging import get_logger
from utils.weather_sat import WEATHER_SATELLITES
logger = get_logger('intercept.weather_sat_predict')
def predict_passes(
lat: float,
lon: float,
hours: int = 24,
min_elevation: float = 15.0,
include_trajectory: bool = False,
include_ground_track: bool = False,
) -> list[dict[str, Any]]:
"""Predict upcoming weather satellite passes for an observer location.
Args:
lat: Observer latitude (-90 to 90)
lon: Observer longitude (-180 to 180)
hours: Hours ahead to predict (1-72)
min_elevation: Minimum max elevation in degrees (0-90)
include_trajectory: Include az/el trajectory points (30 points)
include_ground_track: Include lat/lon ground track points (60 points)
Returns:
List of pass dicts sorted by start time.
Raises:
ImportError: If skyfield is not installed.
"""
from skyfield.api import load, wgs84, EarthSatellite
from skyfield.almanac import find_discrete
from data.satellites import TLE_SATELLITES
ts = load.timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + datetime.timedelta(hours=hours))
all_passes: list[dict[str, Any]] = []
for sat_key, sat_info in WEATHER_SATELLITES.items():
if not sat_info['active']:
continue
tle_data = TLE_SATELLITES.get(sat_info['tle_key'])
if not tle_data:
continue
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
def above_horizon(t, _sat=satellite):
diff = _sat - observer
topocentric = diff.at(t)
alt, _, _ = topocentric.altaz()
return alt.degrees > 0
above_horizon.step_days = 1 / 720
try:
times, events = find_discrete(t0, t1, above_horizon)
except Exception:
continue
i = 0
while i < len(times):
if i < len(events) and events[i]: # Rising
rise_time = times[i]
set_time = None
for j in range(i + 1, len(times)):
if not events[j]: # Setting
set_time = times[j]
i = j
break
else:
i += 1
continue
if set_time is None:
i += 1
continue
duration_seconds = (
set_time.utc_datetime() - rise_time.utc_datetime()
).total_seconds()
duration_minutes = round(duration_seconds / 60, 1)
# Calculate max elevation and trajectory
max_el = 0.0
max_el_az = 0.0
trajectory: list[dict[str, float]] = []
num_traj_points = 30
for k in range(num_traj_points):
frac = k / (num_traj_points - 1)
t_point = ts.utc(
rise_time.utc_datetime()
+ datetime.timedelta(seconds=duration_seconds * frac)
)
diff = satellite - observer
topocentric = diff.at(t_point)
alt, az, _ = topocentric.altaz()
if alt.degrees > max_el:
max_el = alt.degrees
max_el_az = az.degrees
if include_trajectory:
trajectory.append({
'el': float(max(0, alt.degrees)),
'az': float(az.degrees),
})
if max_el < min_elevation:
i += 1
continue
# Rise/set azimuths
rise_topo = (satellite - observer).at(rise_time)
_, rise_az, _ = rise_topo.altaz()
set_topo = (satellite - observer).at(set_time)
_, set_az, _ = set_topo.altaz()
pass_data: dict[str, Any] = {
'id': f"{sat_key}_{rise_time.utc_datetime().strftime('%Y%m%d%H%M')}",
'satellite': sat_key,
'name': sat_info['name'],
'frequency': sat_info['frequency'],
'mode': sat_info['mode'],
'startTime': rise_time.utc_datetime().strftime('%Y-%m-%d %H:%M UTC'),
'startTimeISO': rise_time.utc_datetime().isoformat(),
'endTimeISO': set_time.utc_datetime().isoformat(),
'maxEl': round(max_el, 1),
'maxElAz': round(max_el_az, 1),
'riseAz': round(rise_az.degrees, 1),
'setAz': round(set_az.degrees, 1),
'duration': duration_minutes,
'quality': (
'excellent' if max_el >= 60
else 'good' if max_el >= 30
else 'fair'
),
}
if include_trajectory:
pass_data['trajectory'] = trajectory
if include_ground_track:
ground_track: list[dict[str, float]] = []
for k in range(60):
frac = k / 59
t_point = ts.utc(
rise_time.utc_datetime()
+ datetime.timedelta(seconds=duration_seconds * frac)
)
geocentric = satellite.at(t_point)
subpoint = wgs84.subpoint(geocentric)
ground_track.append({
'lat': float(subpoint.latitude.degrees),
'lon': float(subpoint.longitude.degrees),
})
pass_data['groundTrack'] = ground_track
all_passes.append(pass_data)
i += 1
all_passes.sort(key=lambda p: p['startTimeISO'])
return all_passes

View File

@@ -0,0 +1,385 @@
"""Weather satellite auto-scheduler.
Automatically captures satellite passes based on predicted pass times.
Uses threading.Timer for scheduling — no external dependencies required.
"""
from __future__ import annotations
import threading
import time
import uuid
from datetime import datetime, timezone, timedelta
from typing import Any, Callable
from utils.logging import get_logger
from utils.weather_sat import get_weather_sat_decoder, WEATHER_SATELLITES, CaptureProgress
logger = get_logger('intercept.weather_sat_scheduler')
# Import config defaults
try:
from config import (
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES,
WEATHER_SAT_CAPTURE_BUFFER_SECONDS,
)
except ImportError:
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES = 30
WEATHER_SAT_CAPTURE_BUFFER_SECONDS = 30
class ScheduledPass:
"""A pass scheduled for automatic capture."""
def __init__(self, pass_data: dict[str, Any]):
self.id: str = pass_data.get('id', str(uuid.uuid4())[:8])
self.satellite: str = pass_data['satellite']
self.name: str = pass_data['name']
self.frequency: float = pass_data['frequency']
self.mode: str = pass_data['mode']
self.start_time: str = pass_data['startTimeISO']
self.end_time: str = pass_data['endTimeISO']
self.max_el: float = pass_data['maxEl']
self.duration: float = pass_data['duration']
self.quality: str = pass_data['quality']
self.status: str = 'scheduled' # scheduled, capturing, complete, skipped
self.skipped: bool = False
self._timer: threading.Timer | None = None
self._stop_timer: threading.Timer | None = None
@property
def start_dt(self) -> datetime:
return datetime.fromisoformat(self.start_time).replace(tzinfo=timezone.utc)
@property
def end_dt(self) -> datetime:
return datetime.fromisoformat(self.end_time).replace(tzinfo=timezone.utc)
def to_dict(self) -> dict[str, Any]:
return {
'id': self.id,
'satellite': self.satellite,
'name': self.name,
'frequency': self.frequency,
'mode': self.mode,
'startTimeISO': self.start_time,
'endTimeISO': self.end_time,
'maxEl': self.max_el,
'duration': self.duration,
'quality': self.quality,
'status': self.status,
'skipped': self.skipped,
}
class WeatherSatScheduler:
"""Auto-scheduler for weather satellite captures."""
def __init__(self):
self._enabled = False
self._lock = threading.Lock()
self._passes: list[ScheduledPass] = []
self._refresh_timer: threading.Timer | None = None
self._lat: float = 0.0
self._lon: float = 0.0
self._min_elevation: float = 15.0
self._device: int = 0
self._gain: float = 40.0
self._bias_t: bool = False
self._progress_callback: Callable[[CaptureProgress], None] | None = None
self._event_callback: Callable[[dict[str, Any]], None] | None = None
@property
def enabled(self) -> bool:
return self._enabled
def set_callbacks(
self,
progress_callback: Callable[[CaptureProgress], None],
event_callback: Callable[[dict[str, Any]], None],
) -> None:
"""Set callbacks for progress and scheduler events."""
self._progress_callback = progress_callback
self._event_callback = event_callback
def enable(
self,
lat: float,
lon: float,
min_elevation: float = 15.0,
device: int = 0,
gain: float = 40.0,
bias_t: bool = False,
) -> dict[str, Any]:
"""Enable auto-scheduling.
Args:
lat: Observer latitude
lon: Observer longitude
min_elevation: Minimum pass elevation to capture
device: RTL-SDR device index
gain: SDR gain in dB
bias_t: Enable bias-T
Returns:
Status dict with scheduled passes.
"""
with self._lock:
self._lat = lat
self._lon = lon
self._min_elevation = min_elevation
self._device = device
self._gain = gain
self._bias_t = bias_t
self._enabled = True
self._refresh_passes()
return self.get_status()
def disable(self) -> dict[str, Any]:
"""Disable auto-scheduling and cancel all timers."""
with self._lock:
self._enabled = False
# Cancel refresh timer
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = None
# Cancel all pass timers
for p in self._passes:
if p._timer:
p._timer.cancel()
p._timer = None
if p._stop_timer:
p._stop_timer.cancel()
p._stop_timer = None
self._passes.clear()
logger.info("Weather satellite auto-scheduler disabled")
return {'status': 'disabled'}
def skip_pass(self, pass_id: str) -> bool:
"""Manually skip a scheduled pass."""
with self._lock:
for p in self._passes:
if p.id == pass_id and p.status == 'scheduled':
p.skipped = True
p.status = 'skipped'
if p._timer:
p._timer.cancel()
p._timer = None
logger.info(f"Skipped pass: {p.satellite} at {p.start_time}")
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': p.to_dict(),
'reason': 'manual',
})
return True
return False
def get_status(self) -> dict[str, Any]:
"""Get current scheduler status."""
with self._lock:
return {
'enabled': self._enabled,
'observer': {'latitude': self._lat, 'longitude': self._lon},
'device': self._device,
'gain': self._gain,
'bias_t': self._bias_t,
'min_elevation': self._min_elevation,
'scheduled_count': sum(
1 for p in self._passes if p.status == 'scheduled'
),
'total_passes': len(self._passes),
}
def get_passes(self) -> list[dict[str, Any]]:
"""Get list of scheduled passes."""
with self._lock:
return [p.to_dict() for p in self._passes]
def _refresh_passes(self) -> None:
"""Recompute passes and schedule timers."""
if not self._enabled:
return
try:
from utils.weather_sat_predict import predict_passes
passes = predict_passes(
lat=self._lat,
lon=self._lon,
hours=24,
min_elevation=self._min_elevation,
)
except Exception as e:
logger.error(f"Failed to predict passes for scheduler: {e}")
passes = []
with self._lock:
# Cancel existing timers
for p in self._passes:
if p._timer:
p._timer.cancel()
if p._stop_timer:
p._stop_timer.cancel()
# Keep completed/skipped for history, replace scheduled
history = [p for p in self._passes if p.status in ('complete', 'skipped', 'capturing')]
self._passes = history
now = datetime.now(timezone.utc)
buffer = WEATHER_SAT_CAPTURE_BUFFER_SECONDS
for pass_data in passes:
sp = ScheduledPass(pass_data)
# Skip passes that already started
if sp.start_dt - timedelta(seconds=buffer) <= now:
continue
# Check if already in history
if any(h.id == sp.id for h in history):
continue
# Schedule capture timer
delay = (sp.start_dt - timedelta(seconds=buffer) - now).total_seconds()
if delay > 0:
sp._timer = threading.Timer(delay, self._execute_capture, args=[sp])
sp._timer.daemon = True
sp._timer.start()
self._passes.append(sp)
logger.info(
f"Scheduler refreshed: {sum(1 for p in self._passes if p.status == 'scheduled')} "
f"passes scheduled"
)
# Schedule next refresh
if self._refresh_timer:
self._refresh_timer.cancel()
self._refresh_timer = threading.Timer(
WEATHER_SAT_SCHEDULE_REFRESH_MINUTES * 60,
self._refresh_passes,
)
self._refresh_timer.daemon = True
self._refresh_timer.start()
def _execute_capture(self, sp: ScheduledPass) -> None:
"""Execute capture for a scheduled pass."""
if not self._enabled or sp.skipped:
return
decoder = get_weather_sat_decoder()
if decoder.is_running:
logger.info(f"SDR busy, skipping scheduled pass: {sp.satellite}")
sp.status = 'skipped'
sp.skipped = True
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'sdr_busy',
})
return
# Claim SDR device
try:
import app as app_module
error = app_module.claim_sdr_device(self._device, 'weather_sat')
if error:
logger.info(f"SDR device busy, skipping: {sp.satellite} - {error}")
sp.status = 'skipped'
sp.skipped = True
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'device_busy',
})
return
except ImportError:
pass
sp.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
decoder.set_on_complete(lambda: self._on_capture_complete(sp, _release_device))
success = decoder.start(
satellite=sp.satellite,
device_index=self._device,
gain=self._gain,
bias_t=self._bias_t,
)
if success:
logger.info(f"Auto-scheduler started capture: {sp.satellite}")
self._emit_event({
'type': 'schedule_capture_start',
'pass': sp.to_dict(),
})
# Schedule stop timer at pass end + buffer
now = datetime.now(timezone.utc)
stop_delay = (sp.end_dt + timedelta(seconds=WEATHER_SAT_CAPTURE_BUFFER_SECONDS) - now).total_seconds()
if stop_delay > 0:
sp._stop_timer = threading.Timer(stop_delay, self._stop_capture, args=[sp])
sp._stop_timer.daemon = True
sp._stop_timer.start()
else:
sp.status = 'skipped'
_release_device()
self._emit_event({
'type': 'schedule_capture_skipped',
'pass': sp.to_dict(),
'reason': 'start_failed',
})
def _stop_capture(self, sp: ScheduledPass) -> None:
"""Stop capture at pass end."""
decoder = get_weather_sat_decoder()
if decoder.is_running:
decoder.stop()
logger.info(f"Auto-scheduler stopped capture: {sp.satellite}")
def _on_capture_complete(self, sp: ScheduledPass, release_fn: Callable) -> None:
"""Handle capture completion."""
sp.status = 'complete'
release_fn()
self._emit_event({
'type': 'schedule_capture_complete',
'pass': sp.to_dict(),
})
def _emit_event(self, event: dict[str, Any]) -> None:
"""Emit scheduler event to callback."""
if self._event_callback:
try:
self._event_callback(event)
except Exception as e:
logger.error(f"Error in scheduler event callback: {e}")
# Singleton
_scheduler: WeatherSatScheduler | None = None
def get_weather_sat_scheduler() -> WeatherSatScheduler:
"""Get or create the global weather satellite scheduler instance."""
global _scheduler
if _scheduler is None:
_scheduler = WeatherSatScheduler()
return _scheduler