mirror of
https://github.com/smittix/intercept.git
synced 2026-04-24 06:40:00 -07:00
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:
@@ -145,6 +145,14 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
&& ldconfig \
|
&& ldconfig \
|
||||||
&& cd /tmp \
|
&& cd /tmp \
|
||||||
&& rm -rf /tmp/SatDump \
|
&& 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
|
# Cleanup build tools to reduce image size
|
||||||
&& apt-get remove -y \
|
&& apt-get remove -y \
|
||||||
build-essential \
|
build-essential \
|
||||||
|
|||||||
@@ -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_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_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_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
|
# Update checking
|
||||||
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
GITHUB_REPO = _get_env('GITHUB_REPO', 'smittix/intercept')
|
||||||
|
|||||||
@@ -162,8 +162,18 @@ def start_capture():
|
|||||||
except queue.Empty:
|
except queue.Empty:
|
||||||
break
|
break
|
||||||
|
|
||||||
# Set callback and start
|
# Set callback and on-complete handler for SDR release
|
||||||
decoder.set_callback(_progress_callback)
|
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(
|
success = decoder.start(
|
||||||
satellite=satellite,
|
satellite=satellite,
|
||||||
device_index=device_index,
|
device_index=device_index,
|
||||||
@@ -182,11 +192,7 @@ def start_capture():
|
|||||||
})
|
})
|
||||||
else:
|
else:
|
||||||
# Release device on failure
|
# Release device on failure
|
||||||
try:
|
_release_device()
|
||||||
import app as app_module
|
|
||||||
app_module.release_sdr_device(device_index)
|
|
||||||
except ImportError:
|
|
||||||
pass
|
|
||||||
return jsonify({
|
return jsonify({
|
||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': 'Failed to start capture'
|
'message': 'Failed to start capture'
|
||||||
@@ -333,6 +339,8 @@ def get_passes():
|
|||||||
longitude: Observer longitude (required)
|
longitude: Observer longitude (required)
|
||||||
hours: Hours to predict ahead (default: 24, max: 72)
|
hours: Hours to predict ahead (default: 24, max: 72)
|
||||||
min_elevation: Minimum elevation in degrees (default: 15)
|
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:
|
Returns:
|
||||||
JSON with upcoming passes for all weather satellites.
|
JSON with upcoming passes for all weather satellites.
|
||||||
@@ -341,6 +349,8 @@ def get_passes():
|
|||||||
lon = request.args.get('longitude', type=float)
|
lon = request.args.get('longitude', type=float)
|
||||||
hours = request.args.get('hours', 24, type=int)
|
hours = request.args.get('hours', 24, type=int)
|
||||||
min_elevation = request.args.get('min_elevation', 15, type=float)
|
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:
|
if lat is None or lon is None:
|
||||||
return jsonify({
|
return jsonify({
|
||||||
@@ -357,119 +367,16 @@ def get_passes():
|
|||||||
min_elevation = max(0, min(min_elevation, 90))
|
min_elevation = max(0, min(min_elevation, 90))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from skyfield.api import load, wgs84, EarthSatellite
|
from utils.weather_sat_predict import predict_passes
|
||||||
from skyfield.almanac import find_discrete
|
|
||||||
from data.satellites import TLE_SATELLITES
|
|
||||||
|
|
||||||
ts = load.timescale()
|
all_passes = predict_passes(
|
||||||
observer = wgs84.latlon(lat, lon)
|
lat=lat,
|
||||||
t0 = ts.now()
|
lon=lon,
|
||||||
t1 = ts.utc(t0.utc_datetime() + __import__('datetime').timedelta(hours=hours))
|
hours=hours,
|
||||||
|
min_elevation=min_elevation,
|
||||||
all_passes = []
|
include_trajectory=include_trajectory,
|
||||||
|
include_ground_track=include_ground_track,
|
||||||
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
|
|
||||||
)
|
|
||||||
)
|
|
||||||
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({
|
return jsonify({
|
||||||
'status': 'ok',
|
'status': 'ok',
|
||||||
@@ -492,3 +399,124 @@ def get_passes():
|
|||||||
'status': 'error',
|
'status': 'error',
|
||||||
'message': str(e)
|
'message': str(e)
|
||||||
}), 500
|
}), 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
|
||||||
|
|||||||
@@ -107,6 +107,30 @@
|
|||||||
color: var(--accent-cyan, #00d4ff);
|
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 ===== */
|
/* ===== Location inputs in strip ===== */
|
||||||
.wxsat-strip-location {
|
.wxsat-strip-location {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -142,16 +166,160 @@
|
|||||||
|
|
||||||
.wxsat-content {
|
.wxsat-content {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
padding: 16px;
|
padding: 12px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
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 ===== */
|
/* ===== Pass Predictions Panel ===== */
|
||||||
.wxsat-passes-panel {
|
.wxsat-passes-panel {
|
||||||
flex: 0 0 320px;
|
flex: 0 0 280px;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0;
|
gap: 0;
|
||||||
@@ -205,11 +373,25 @@
|
|||||||
background: var(--bg-hover, #252a3a);
|
background: var(--bg-hover, #252a3a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-pass-card.active {
|
.wxsat-pass-card.active,
|
||||||
|
.wxsat-pass-card.selected {
|
||||||
border-color: #00ff88;
|
border-color: #00ff88;
|
||||||
background: rgba(0, 255, 136, 0.05);
|
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 {
|
.wxsat-pass-sat {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -281,6 +463,57 @@
|
|||||||
color: #ffbb00;
|
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 ===== */
|
/* ===== Image Gallery Panel ===== */
|
||||||
.wxsat-gallery-panel {
|
.wxsat-gallery-panel {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
@@ -322,7 +555,7 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
align-content: start;
|
align-content: start;
|
||||||
}
|
}
|
||||||
@@ -421,12 +654,18 @@
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-secondary, #999);
|
color: var(--text-secondary, #999);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1;
|
||||||
|
margin-right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-capture-elapsed {
|
.wxsat-capture-elapsed {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--text-dim, #666);
|
color: var(--text-dim, #666);
|
||||||
font-family: 'JetBrains Mono', monospace;
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-progress-bar {
|
.wxsat-progress-bar {
|
||||||
@@ -496,14 +735,53 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Responsive ===== */
|
/* ===== Responsive ===== */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 1100px) {
|
||||||
.wxsat-content {
|
.wxsat-content {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.wxsat-passes-panel {
|
.wxsat-passes-panel {
|
||||||
flex: none;
|
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 {
|
.wxsat-gallery-grid {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
/**
|
/**
|
||||||
* Weather Satellite Mode
|
* 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() {
|
const WeatherSat = (function() {
|
||||||
@@ -9,7 +10,13 @@ const WeatherSat = (function() {
|
|||||||
let eventSource = null;
|
let eventSource = null;
|
||||||
let images = [];
|
let images = [];
|
||||||
let passes = [];
|
let passes = [];
|
||||||
|
let selectedPassIndex = -1;
|
||||||
let currentSatellite = null;
|
let currentSatellite = null;
|
||||||
|
let countdownInterval = null;
|
||||||
|
let schedulerEnabled = false;
|
||||||
|
let groundMap = null;
|
||||||
|
let groundTrackLayer = null;
|
||||||
|
let observerMarker = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize the Weather Satellite mode
|
* Initialize the Weather Satellite mode
|
||||||
@@ -19,6 +26,9 @@ const WeatherSat = (function() {
|
|||||||
loadImages();
|
loadImages();
|
||||||
loadLocationInputs();
|
loadLocationInputs();
|
||||||
loadPasses();
|
loadPasses();
|
||||||
|
startCountdownTimer();
|
||||||
|
checkSchedulerStatus();
|
||||||
|
initGroundMap();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -261,6 +271,8 @@ const WeatherSat = (function() {
|
|||||||
const data = JSON.parse(e.data);
|
const data = JSON.parse(e.data);
|
||||||
if (data.type === 'weather_sat_progress') {
|
if (data.type === 'weather_sat_progress') {
|
||||||
handleProgress(data);
|
handleProgress(data);
|
||||||
|
} else if (data.type && data.type.startsWith('schedule_')) {
|
||||||
|
handleSchedulerSSE(data);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to parse SSE:', err);
|
console.error('Failed to parse SSE:', err);
|
||||||
@@ -269,7 +281,7 @@ const WeatherSat = (function() {
|
|||||||
|
|
||||||
eventSource.onerror = () => {
|
eventSource.onerror = () => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
if (isRunning) startStream();
|
if (isRunning || schedulerEnabled) startStream();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -312,7 +324,7 @@ const WeatherSat = (function() {
|
|||||||
if (!data.image) {
|
if (!data.image) {
|
||||||
// Capture ended
|
// Capture ended
|
||||||
isRunning = false;
|
isRunning = false;
|
||||||
stopStream();
|
if (!schedulerEnabled) stopStream();
|
||||||
updateStatusUI('idle', 'Capture complete');
|
updateStatusUI('idle', 'Capture complete');
|
||||||
if (captureStatus) captureStatus.classList.remove('active');
|
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
|
* Format elapsed seconds
|
||||||
*/
|
*/
|
||||||
@@ -334,7 +366,7 @@ const WeatherSat = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Load pass predictions
|
* Load pass predictions (with trajectory + ground track)
|
||||||
*/
|
*/
|
||||||
async function loadPasses() {
|
async function loadPasses() {
|
||||||
const storedLat = localStorage.getItem('observerLat');
|
const storedLat = localStorage.getItem('observerLat');
|
||||||
@@ -346,19 +378,49 @@ const WeatherSat = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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 response = await fetch(url);
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
|
|
||||||
if (data.status === 'ok') {
|
if (data.status === 'ok') {
|
||||||
passes = data.passes || [];
|
passes = data.passes || [];
|
||||||
renderPasses(passes);
|
renderPasses(passes);
|
||||||
|
renderTimeline(passes);
|
||||||
|
updateCountdownFromPasses();
|
||||||
|
// Auto-select first pass
|
||||||
|
if (passes.length > 0 && selectedPassIndex < 0) {
|
||||||
|
selectPass(0);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load passes:', 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
|
* Render pass predictions list
|
||||||
*/
|
*/
|
||||||
@@ -387,6 +449,7 @@ const WeatherSat = (function() {
|
|||||||
const passStart = new Date(pass.startTimeISO);
|
const passStart = new Date(pass.startTimeISO);
|
||||||
const diffMs = passStart - now;
|
const diffMs = passStart - now;
|
||||||
const diffMins = Math.floor(diffMs / 60000);
|
const diffMins = Math.floor(diffMs / 60000);
|
||||||
|
const isSelected = idx === selectedPassIndex;
|
||||||
|
|
||||||
let countdown = '';
|
let countdown = '';
|
||||||
if (diffMs < 0) {
|
if (diffMs < 0) {
|
||||||
@@ -400,7 +463,7 @@ const WeatherSat = (function() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return `
|
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">
|
<div class="wxsat-pass-sat">
|
||||||
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
|
<span class="wxsat-pass-sat-name">${escapeHtml(pass.name)}</span>
|
||||||
<span class="wxsat-pass-mode ${modeClass}">${escapeHtml(pass.mode)}</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 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>
|
<span style="font-size: 10px; color: var(--text-dim); font-family: 'JetBrains Mono', monospace;">${countdown}</span>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).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
|
* Load decoded images
|
||||||
*/
|
*/
|
||||||
@@ -544,17 +1075,29 @@ const WeatherSat = (function() {
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate ground map size (call after container becomes visible)
|
||||||
|
*/
|
||||||
|
function invalidateMap() {
|
||||||
|
if (groundMap) {
|
||||||
|
setTimeout(() => groundMap.invalidateSize(), 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Public API
|
// Public API
|
||||||
return {
|
return {
|
||||||
init,
|
init,
|
||||||
start,
|
start,
|
||||||
stop,
|
stop,
|
||||||
startPass,
|
startPass,
|
||||||
|
selectPass,
|
||||||
loadImages,
|
loadImages,
|
||||||
loadPasses,
|
loadPasses,
|
||||||
showImage,
|
showImage,
|
||||||
closeImage,
|
closeImage,
|
||||||
useGPS,
|
useGPS,
|
||||||
|
toggleScheduler,
|
||||||
|
invalidateMap,
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
|
|
||||||
|
|||||||
@@ -1925,6 +1925,36 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Capture progress -->
|
<!-- Capture progress -->
|
||||||
@@ -1938,9 +1968,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Main content: passes + gallery -->
|
<!-- Main content: 3-column layout -->
|
||||||
<div class="wxsat-content">
|
<div class="wxsat-content">
|
||||||
<!-- Pass predictions -->
|
<!-- Left: Pass predictions -->
|
||||||
<div class="wxsat-passes-panel">
|
<div class="wxsat-passes-panel">
|
||||||
<div class="wxsat-passes-header">
|
<div class="wxsat-passes-header">
|
||||||
<span class="wxsat-passes-title">Upcoming Passes</span>
|
<span class="wxsat-passes-title">Upcoming Passes</span>
|
||||||
@@ -1953,7 +1983,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</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-panel">
|
||||||
<div class="wxsat-gallery-header">
|
<div class="wxsat-gallery-header">
|
||||||
<span class="wxsat-gallery-title">Decoded Images</span>
|
<span class="wxsat-gallery-title">Decoded Images</span>
|
||||||
@@ -2899,6 +2946,9 @@
|
|||||||
SSTV.init();
|
SSTV.init();
|
||||||
} else if (mode === 'weathersat') {
|
} else if (mode === 'weathersat') {
|
||||||
WeatherSat.init();
|
WeatherSat.init();
|
||||||
|
setTimeout(() => {
|
||||||
|
WeatherSat.invalidateMap();
|
||||||
|
}, 100);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -174,6 +174,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="section">
|
||||||
<h3>Resources</h3>
|
<h3>Resources</h3>
|
||||||
<div style="display: flex; flex-direction: column; gap: 6px;">
|
<div style="display: flex; flex-direction: column; gap: 6px;">
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ class WeatherSatDecoder:
|
|||||||
self._capture_start_time: float = 0
|
self._capture_start_time: float = 0
|
||||||
self._device_index: int = 0
|
self._device_index: int = 0
|
||||||
self._capture_output_dir: Path | None = None
|
self._capture_output_dir: Path | None = None
|
||||||
|
self._on_complete_callback: Callable[[], None] | None = None
|
||||||
|
|
||||||
# Ensure output directory exists
|
# Ensure output directory exists
|
||||||
self._output_dir.mkdir(parents=True, exist_ok=True)
|
self._output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -189,6 +190,10 @@ class WeatherSatDecoder:
|
|||||||
"""Set callback for capture progress updates."""
|
"""Set callback for capture progress updates."""
|
||||||
self._callback = callback
|
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(
|
def start(
|
||||||
self,
|
self,
|
||||||
satellite: str,
|
satellite: str,
|
||||||
@@ -320,6 +325,8 @@ class WeatherSatDecoder:
|
|||||||
if not self._process or not self._process.stdout:
|
if not self._process or not self._process.stdout:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
last_emit_time = 0.0
|
||||||
|
|
||||||
try:
|
try:
|
||||||
for line in iter(self._process.stdout.readline, ''):
|
for line in iter(self._process.stdout.readline, ''):
|
||||||
if not self._running:
|
if not self._running:
|
||||||
@@ -331,12 +338,11 @@ class WeatherSatDecoder:
|
|||||||
|
|
||||||
logger.debug(f"satdump: {line}")
|
logger.debug(f"satdump: {line}")
|
||||||
|
|
||||||
# Parse progress from SatDump output
|
|
||||||
elapsed = int(time.time() - self._capture_start_time)
|
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:
|
if 'Progress' in line or 'progress' in line:
|
||||||
# Try to extract percentage
|
|
||||||
match = re.search(r'(\d+(?:\.\d+)?)\s*%', line)
|
match = re.search(r'(\d+(?:\.\d+)?)\s*%', line)
|
||||||
pct = int(float(match.group(1))) if match else 0
|
pct = int(float(match.group(1))) if match else 0
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
@@ -348,6 +354,7 @@ class WeatherSatDecoder:
|
|||||||
progress_percent=pct,
|
progress_percent=pct,
|
||||||
elapsed_seconds=elapsed,
|
elapsed_seconds=elapsed,
|
||||||
))
|
))
|
||||||
|
last_emit_time = now
|
||||||
elif 'Saved' in line or 'saved' in line or 'Writing' in line:
|
elif 'Saved' in line or 'saved' in line or 'Writing' in line:
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
status='decoding',
|
status='decoding',
|
||||||
@@ -357,6 +364,7 @@ class WeatherSatDecoder:
|
|||||||
message=line,
|
message=line,
|
||||||
elapsed_seconds=elapsed,
|
elapsed_seconds=elapsed,
|
||||||
))
|
))
|
||||||
|
last_emit_time = now
|
||||||
elif 'error' in line.lower() or 'fail' in line.lower():
|
elif 'error' in line.lower() or 'fail' in line.lower():
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
status='capturing',
|
status='capturing',
|
||||||
@@ -366,25 +374,29 @@ class WeatherSatDecoder:
|
|||||||
message=line,
|
message=line,
|
||||||
elapsed_seconds=elapsed,
|
elapsed_seconds=elapsed,
|
||||||
))
|
))
|
||||||
|
last_emit_time = now
|
||||||
else:
|
else:
|
||||||
# Generic progress update every ~10 seconds
|
# Emit all output lines, throttled to every 2 seconds
|
||||||
if elapsed % 10 == 0:
|
if now - last_emit_time >= 2.0:
|
||||||
self._emit_progress(CaptureProgress(
|
self._emit_progress(CaptureProgress(
|
||||||
status='capturing',
|
status='capturing',
|
||||||
satellite=self._current_satellite,
|
satellite=self._current_satellite,
|
||||||
frequency=self._current_frequency,
|
frequency=self._current_frequency,
|
||||||
mode=self._current_mode,
|
mode=self._current_mode,
|
||||||
message=f"Capturing... ({elapsed}s elapsed)",
|
message=line,
|
||||||
elapsed_seconds=elapsed,
|
elapsed_seconds=elapsed,
|
||||||
))
|
))
|
||||||
|
last_emit_time = now
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error reading SatDump output: {e}")
|
logger.error(f"Error reading SatDump output: {e}")
|
||||||
finally:
|
finally:
|
||||||
# Process ended
|
# Process ended — release resources
|
||||||
if self._running:
|
was_running = self._running
|
||||||
self._running = False
|
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(
|
self._emit_progress(CaptureProgress(
|
||||||
status='complete',
|
status='complete',
|
||||||
satellite=self._current_satellite,
|
satellite=self._current_satellite,
|
||||||
@@ -394,6 +406,13 @@ class WeatherSatDecoder:
|
|||||||
elapsed_seconds=elapsed,
|
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:
|
def _watch_images(self) -> None:
|
||||||
"""Watch output directory for new decoded images."""
|
"""Watch output directory for new decoded images."""
|
||||||
if not self._capture_output_dir:
|
if not self._capture_output_dir:
|
||||||
|
|||||||
179
utils/weather_sat_predict.py
Normal file
179
utils/weather_sat_predict.py
Normal 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
|
||||||
385
utils/weather_sat_scheduler.py
Normal file
385
utils/weather_sat_scheduler.py
Normal 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
|
||||||
Reference in New Issue
Block a user