Fix satellite dashboard refresh flows

This commit is contained in:
James Smith
2026-03-18 22:53:36 +00:00
parent 6fd5098b89
commit 62ee2252a3
4 changed files with 257 additions and 127 deletions

View File

@@ -48,11 +48,18 @@ _tle_cache = dict(TLE_SATELLITES)
# Ground track cache: key=(sat_name, tle_line1[:20]) -> (track_data, computed_at_timestamp)
# TTL is 1800 seconds (30 minutes)
_track_cache: dict = {}
_TRACK_CACHE_TTL = 1800
_track_cache: dict = {}
_TRACK_CACHE_TTL = 1800
_BUILTIN_NORAD_TO_KEY = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3',
59051: 'METEOR-M2-4',
}
def _load_db_satellites_into_cache():
def _load_db_satellites_into_cache():
"""Load user-tracked satellites from DB into the TLE cache."""
global _tle_cache
try:
@@ -67,8 +74,102 @@ def _load_db_satellites_into_cache():
loaded += 1
if loaded:
logger.info(f"Loaded {loaded} user-tracked satellites into TLE cache")
except Exception as e:
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
except Exception as e:
logger.warning(f"Failed to load DB satellites into TLE cache: {e}")
def _normalize_satellite_name(value: object) -> str:
"""Normalize satellite identifiers for loose name matching."""
return str(value or '').strip().replace(' ', '-').upper()
def _get_tracked_satellite_maps() -> tuple[dict[int, dict], dict[str, dict]]:
"""Return tracked satellites indexed by NORAD ID and normalized name."""
by_norad: dict[int, dict] = {}
by_name: dict[str, dict] = {}
try:
for sat in get_tracked_satellites():
try:
norad_id = int(sat['norad_id'])
except (TypeError, ValueError):
continue
by_norad[norad_id] = sat
by_name[_normalize_satellite_name(sat.get('name'))] = sat
except Exception as e:
logger.warning(f"Failed to read tracked satellites for lookup: {e}")
return by_norad, by_name
def _resolve_satellite_request(sat: object, tracked_by_norad: dict[int, dict], tracked_by_name: dict[str, dict]) -> tuple[str, int | None, tuple[str, str, str] | None]:
"""Resolve a satellite request to display name, NORAD ID, and TLE data."""
norad_id: int | None = None
sat_key: str | None = None
tracked: dict | None = None
if isinstance(sat, int):
norad_id = sat
elif isinstance(sat, str):
stripped = sat.strip()
if stripped.isdigit():
norad_id = int(stripped)
else:
sat_key = stripped
if norad_id is not None:
tracked = tracked_by_norad.get(norad_id)
sat_key = _BUILTIN_NORAD_TO_KEY.get(norad_id) or (tracked.get('name') if tracked else str(norad_id))
else:
normalized = _normalize_satellite_name(sat_key)
tracked = tracked_by_name.get(normalized)
if tracked:
try:
norad_id = int(tracked['norad_id'])
except (TypeError, ValueError):
norad_id = None
sat_key = tracked.get('name') or sat_key
tle_data = None
candidate_keys: list[str] = []
if sat_key:
candidate_keys.extend([
sat_key,
_normalize_satellite_name(sat_key),
])
if tracked and tracked.get('name'):
candidate_keys.extend([
tracked['name'],
_normalize_satellite_name(tracked['name']),
])
seen: set[str] = set()
for key in candidate_keys:
norm = _normalize_satellite_name(key)
if norm in seen:
continue
seen.add(norm)
if key in _tle_cache:
tle_data = _tle_cache[key]
break
if norm in _tle_cache:
tle_data = _tle_cache[norm]
break
if tle_data is None and tracked and tracked.get('tle_line1') and tracked.get('tle_line2'):
display_name = tracked.get('name') or sat_key or str(norad_id or 'UNKNOWN')
tle_data = (display_name, tracked['tle_line1'], tracked['tle_line2'])
_tle_cache[_normalize_satellite_name(display_name)] = tle_data
if tle_data is None and sat_key:
normalized = _normalize_satellite_name(sat_key)
for key, value in _tle_cache.items():
if key == normalized or _normalize_satellite_name(value[0]) == normalized:
tle_data = value
break
display_name = _BUILTIN_NORAD_TO_KEY.get(norad_id or -1)
if not display_name:
display_name = (tracked.get('name') if tracked else None) or (tle_data[0] if tle_data else None) or (sat_key if sat_key else str(norad_id or 'UNKNOWN'))
return display_name, norad_id, tle_data
def _start_satellite_tracker():
@@ -328,45 +429,30 @@ def predict_passes():
except ValueError as e:
return api_error(str(e), 400)
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3',
59051: 'METEOR-M2-4',
}
sat_input = data.get('satellites', ['ISS', 'METEOR-M2-3', 'METEOR-M2-4'])
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
satellites.append(norad_to_name[sat])
else:
satellites.append(sat)
passes = []
passes = []
colors = {
'ISS': '#00ffff',
'METEOR-M2': '#9370DB',
'METEOR-M2-3': '#ff00ff',
'METEOR-M2-4': '#00ff88',
}
name_to_norad = {v: k for k, v in norad_to_name.items()}
ts = _get_timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
for sat_name in satellites:
if sat_name not in _tle_cache:
continue
tle_data = _tle_cache[sat_name]
# Current position for map marker (computed once per satellite)
current_pos = None
try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
ts = _get_timescale()
observer = wgs84.latlon(lat, lon)
t0 = ts.now()
t1 = ts.utc(t0.utc_datetime() + timedelta(hours=hours))
for sat in sat_input:
sat_name, norad_id, tle_data = _resolve_satellite_request(sat, tracked_by_norad, tracked_by_name)
if not tle_data:
continue
# Current position for map marker (computed once per satellite)
current_pos = None
try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
geo = satellite.at(ts.now())
sp = wgs84.subpoint(geo)
current_pos = {
@@ -376,14 +462,14 @@ def predict_passes():
except Exception:
pass
sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el)
for p in sat_passes:
p['satellite'] = sat_name
p['norad'] = name_to_norad.get(sat_name, 0)
p['color'] = colors.get(sat_name, '#00ff00')
if current_pos:
p['currentPos'] = current_pos
passes.extend(sat_passes)
sat_passes = _predict_passes(tle_data, observer, ts, t0, t1, min_el=min_el)
for p in sat_passes:
p['satellite'] = sat_name
p['norad'] = norad_id or 0
p['color'] = colors.get(sat_name, '#00ff00')
if current_pos:
p['currentPos'] = current_pos
passes.extend(sat_passes)
passes.sort(key=lambda p: p['startTimeISO'])
@@ -413,35 +499,23 @@ def get_satellite_position():
sat_input = data.get('satellites', [])
include_track = bool(data.get('includeTrack', True))
norad_to_name = {
25544: 'ISS',
40069: 'METEOR-M2',
57166: 'METEOR-M2-3',
59051: 'METEOR-M2-4',
}
satellites = []
for sat in sat_input:
if isinstance(sat, int) and sat in norad_to_name:
satellites.append(norad_to_name[sat])
else:
satellites.append(sat)
ts = _get_timescale()
observer = wgs84.latlon(lat, lon)
now = ts.now()
now_dt = now.utc_datetime()
positions = []
for sat_name in satellites:
# Special handling for ISS - use real-time API for accurate position
if sat_name == 'ISS':
iss_data = _fetch_iss_realtime(lat, lon)
if iss_data:
# Add orbit track if requested (using TLE for track prediction)
if include_track and 'ISS' in _tle_cache:
try:
ts = _get_timescale()
observer = wgs84.latlon(lat, lon)
now = ts.now()
now_dt = now.utc_datetime()
tracked_by_norad, tracked_by_name = _get_tracked_satellite_maps()
positions = []
for sat in sat_input:
sat_name, norad_id, tle_data = _resolve_satellite_request(sat, tracked_by_norad, tracked_by_name)
# Special handling for ISS - use real-time API for accurate position
if norad_id == 25544 or sat_name == 'ISS':
iss_data = _fetch_iss_realtime(lat, lon)
if iss_data:
# Add orbit track if requested (using TLE for track prediction)
if include_track and 'ISS' in _tle_cache:
try:
tle_data = _tle_cache['ISS']
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
orbit_track = []
@@ -460,16 +534,15 @@ def get_satellite_position():
iss_data['track'] = orbit_track
except Exception:
pass
positions.append(iss_data)
continue
# Other satellites - use TLE data
if sat_name not in _tle_cache:
continue
tle_data = _tle_cache[sat_name]
try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
positions.append(iss_data)
continue
# Other satellites - use TLE data
if not tle_data:
continue
try:
satellite = EarthSatellite(tle_data[1], tle_data[2], tle_data[0], ts)
geocentric = satellite.at(now)
subpoint = wgs84.subpoint(geocentric)
@@ -480,7 +553,7 @@ def get_satellite_position():
pos_data = {
'satellite': sat_name,
'norad_id': next((nid for nid, name in norad_to_name.items() if name == sat_name), None),
'norad_id': norad_id,
'lat': float(subpoint.latitude.degrees),
'lon': float(subpoint.longitude.degrees),
'altitude': float(geocentric.distance().km - 6371),

View File

@@ -11554,10 +11554,13 @@
function fetchCelestrakCategory(category) {
const status = document.getElementById('celestrakStatus');
status.innerHTML = '<span style="color: var(--accent-cyan);">Fetching ' + category + '...</span>';
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
fetch('/satellite/celestrak/' + category)
fetch('/satellite/celestrak/' + category, { signal: controller.signal })
.then(r => r.json())
.then(async data => {
clearTimeout(timeout);
if (data.status === 'success' && data.satellites) {
const toAdd = data.satellites
.filter(sat => !trackedSatellites.find(s => s.norad === String(sat.norad)))
@@ -11602,8 +11605,10 @@
}
})
.catch((err) => {
clearTimeout(timeout);
const msg = err && err.message ? err.message : 'Network error';
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${msg}</span>`;
const label = err && err.name === 'AbortError' ? 'Request timed out' : msg;
status.innerHTML = `<span style="color: var(--accent-red);">Import failed: ${label}</span>`;
});
}
@@ -11613,7 +11618,7 @@
.then(data => {
if (data.status === 'success' && data.satellites) {
trackedSatellites = data.satellites.map(sat => ({
id: sat.name.replace(/[^a-zA-Z0-9]/g, '-').toUpperCase(),
id: String(sat.norad_id),
name: sat.name,
norad: sat.norad_id,
builtin: sat.builtin,
@@ -11627,9 +11632,9 @@
// Fallback to hardcoded defaults if API fails
if (trackedSatellites.length === 0) {
trackedSatellites = [
{ id: 'ISS', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: 'METEOR-M2-3', name: 'Meteor-M2-3', norad: '57166', builtin: true, checked: true },
{ id: 'METEOR-M2-4', name: 'Meteor-M2-4', norad: '59051', builtin: true, checked: true }
{ id: '25544', name: 'ISS (ZARYA)', norad: '25544', builtin: true, checked: true },
{ id: '57166', name: 'Meteor-M2-3', norad: '57166', builtin: true, checked: true },
{ id: '59051', name: 'Meteor-M2-4', norad: '59051', builtin: true, checked: true }
];
renderSatelliteList();
}

View File

@@ -200,19 +200,6 @@
</div>
</div>
<!-- Decoded Packets -->
<div class="panel packets-panel">
<div class="panel-header">
<span>DECODED PACKETS <span id="packetCount" style="color:var(--accent-cyan);"></span></span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content" id="packetList">
<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">
No packets received yet.<br>Run a ground-station observation with telemetry tasks enabled to populate this panel.
</div>
</div>
</div>
<!-- Ground Station -->
<div class="panel gs-panel" id="gsPanel">
<div class="panel-header">
@@ -352,6 +339,19 @@
</div>
</div>
</div>
<!-- Decoded Packets -->
<div class="panel packets-panel">
<div class="panel-header">
<span>DECODED PACKETS <span id="packetCount" style="color:var(--accent-cyan);"></span></span>
<div class="panel-indicator"></div>
</div>
<div class="panel-content" id="packetList">
<div style="text-align:center;color:var(--text-secondary);padding:15px;font-size:11px;">
No packets received yet.<br>Run a ground-station observation with telemetry tasks enabled to populate this panel.
</div>
</div>
</div>
</div>
<!-- Controls Bar -->
@@ -601,6 +601,7 @@
let agents = [];
let _txRequestId = 0;
let _telemetryPollTimer = null;
let _passRequestId = 0;
let satellites = {
25544: { name: 'ISS (ZARYA)', color: '#00ffff' },
@@ -620,33 +621,49 @@
fetch('/satellite/tracked?enabled=true')
.then(r => r.json())
.then(data => {
if (data.status === 'success' && data.satellites && data.satellites.length > 0) {
const prevSelected = selectedSatellite;
const newSats = {};
const select = document.getElementById('satSelect');
select.innerHTML = '';
const prevSelected = selectedSatellite;
const newSats = {
25544: { name: 'ISS (ZARYA)', color: satellites[25544]?.color || satColors[0] },
57166: { name: 'METEOR-M2-3', color: satellites[57166]?.color || satColors[2] },
59051: { name: 'METEOR-M2-4', color: satellites[59051]?.color || satColors[4] },
};
const select = document.getElementById('satSelect');
if (!select) return;
if (data.status === 'success' && Array.isArray(data.satellites)) {
data.satellites.forEach((sat, i) => {
const norad = parseInt(sat.norad_id);
if (!Number.isFinite(norad)) return;
newSats[norad] = {
name: sat.name,
color: satellites[norad]?.color || satColors[i % satColors.length]
};
const opt = document.createElement('option');
opt.value = norad;
opt.textContent = sat.name;
select.appendChild(opt);
});
satellites = newSats;
// Restore previous selection if still available, else default to ISS
if (newSats[prevSelected]) {
select.value = prevSelected;
} else if (newSats[25544]) {
select.value = '25544';
}
selectedSatellite = parseInt(select.value);
}
satellites = newSats;
select.innerHTML = '';
Object.entries(newSats).forEach(([norad, sat]) => {
const opt = document.createElement('option');
opt.value = norad;
opt.textContent = sat.name;
select.appendChild(opt);
});
if (newSats[prevSelected]) {
select.value = String(prevSelected);
} else if (newSats[25544]) {
select.value = '25544';
}
selectedSatellite = parseInt(select.value);
clearTelemetry();
loadTransmitters(selectedSatellite);
calculatePasses();
fetchCurrentTelemetry();
if (window.gsLoadOutputs) window.gsLoadOutputs();
if (window.gsOnSatelliteChange) window.gsOnSatelliteChange();
})
.catch(() => {});
.catch(() => {})
.finally(() => {
if (btn) btn.classList.remove('spinning');
});
}
function onSatelliteChange() {
@@ -670,7 +687,7 @@
loadTransmitters(selectedSatellite);
calculatePasses();
fetchCurrentTelemetry();
gsLoadOutputs();
if (window.gsLoadOutputs) window.gsLoadOutputs();
if (window.gsOnSatelliteChange) gsOnSatelliteChange();
}
@@ -1073,9 +1090,18 @@
}
async function calculatePasses() {
const requestId = ++_passRequestId;
const lat = parseFloat(document.getElementById('obsLat').value);
const lon = parseFloat(document.getElementById('obsLon').value);
const satName = satellites[selectedSatellite]?.name || 'Unknown';
const container = document.getElementById('passList');
const button = document.querySelector('.controls-bar .btn.primary');
if (container) {
container.innerHTML = '<div style="text-align:center;color:var(--text-secondary);padding:20px;">Calculating passes...</div>';
}
if (button) {
button.disabled = true;
button.textContent = 'WORKING...';
}
try {
const response = await fetch('/satellite/predict', {
@@ -1091,25 +1117,38 @@
});
const data = await response.json();
if (requestId !== _passRequestId) return;
if (data.status === 'success') {
passes = data.passes;
renderPassList();
updateStats();
if (passes.length > 0) {
selectPass(0);
} else {
clearTelemetry();
}
updateObserverMarker(lat, lon);
document.getElementById('trackingStatus').textContent = 'TRACKING';
document.getElementById('trackingDot').style.background = 'var(--accent-green)';
} else {
passes = [];
renderPassList();
document.getElementById('trackingStatus').textContent = 'ERROR';
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
}
} catch (err) {
if (requestId !== _passRequestId) return;
console.error('Pass calculation error:', err);
passes = [];
renderPassList();
document.getElementById('trackingStatus').textContent = 'OFFLINE';
document.getElementById('trackingDot').style.background = 'var(--accent-red)';
} finally {
if (requestId === _passRequestId && button) {
button.disabled = false;
button.textContent = 'CALCULATE';
}
}
}
@@ -2046,6 +2085,7 @@
})
.catch(() => {});
}
window.gsLoadOutputs = gsLoadOutputs;
function gsLoadDecodeJobs(norad) {
const panel = document.getElementById('gsOutputsPanel');

View File

@@ -179,15 +179,27 @@ def get_transmitters(norad_id: int) -> list[dict]:
"""
global _transmitters, _fetched_at # noqa: PLW0603
with _fetch_lock:
sat_id = int(norad_id)
age = time.time() - _fetched_at
# Fast path: serve warm cache immediately.
if _transmitters and age <= _CACHE_TTL:
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
# Avoid blocking the UI behind a long-running background refresh.
if not _fetch_lock.acquire(blocking=False):
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
try:
age = time.time() - _fetched_at
if not _transmitters or age > _CACHE_TTL:
fetched = fetch_transmitters()
if fetched:
_transmitters = fetched
_fetched_at = time.time()
return _transmitters.get(int(norad_id), _BUILTIN_TRANSMITTERS.get(int(norad_id), []))
return _transmitters.get(sat_id, _BUILTIN_TRANSMITTERS.get(sat_id, []))
finally:
_fetch_lock.release()
def refresh_transmitters() -> int: