feat: add GPS status endpoint and dashboard indicator

- add /api/gps/status for thread/fix-age visibility
- surface GPS status in Pi health and live GPS cards
- add tests for app_gps status snapshots

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-10 18:18:14 -04:00
co-authored by Copilot
parent 675bffb4a4
commit be40bbc15f
6 changed files with 163 additions and 3 deletions
+70
View File
@@ -209,6 +209,59 @@ function reverseGeocode(lat, lon) {
.catch(() => { clearTimeout(t); _geocodePending = false; el.textContent = fallback; });
}
function formatGpsAge(seconds) {
if (seconds == null) return 'No fix yet.';
if (seconds < 10) return 'Last fix just now.';
if (seconds < 60) return `Last fix ${Math.round(seconds)}s ago.`;
const mins = Math.floor(seconds / 60);
const secs = Math.round(seconds % 60);
return secs ? `Last fix ${mins}m ${secs}s ago.` : `Last fix ${mins}m ago.`;
}
function renderGpsStatus(status) {
const liveBadge = document.getElementById('gps-live-badge');
const fixDetail = document.getElementById('gps-fix-detail');
const healthStatus = document.getElementById('pi-gps-status');
const healthDetail = document.getElementById('pi-gps-detail');
if (!liveBadge || !fixDetail || !healthStatus || !healthDetail) return;
let label = 'Unavailable';
let badgeClass = 'text-bg-secondary';
let detail = 'GPS thread has not started.';
if (status.thread_alive) {
if (status.has_fix) {
const stale = status.fix_age_seconds != null && status.fix_age_seconds > 30;
label = stale ? 'Stale fix' : 'Live fix';
badgeClass = stale ? 'text-bg-warning' : 'text-bg-success';
detail = formatGpsAge(status.fix_age_seconds);
} else {
label = 'No fix';
badgeClass = 'text-bg-warning';
detail = 'GNSS thread is running, but no fix is available yet.';
}
} else {
label = 'Offline';
badgeClass = 'text-bg-danger';
}
liveBadge.className = `badge ${badgeClass}`;
liveBadge.textContent = label;
fixDetail.textContent = detail;
healthStatus.textContent = label;
healthStatus.className = `h6 fw-bold mb-0 ${label === 'Live fix' ? 'text-success' : label === 'Offline' ? 'text-danger' : 'text-warning'}`;
healthDetail.textContent = detail;
}
async function refreshGpsStatus() {
const response = await fetch('/api/gps/status');
const status = await response.json();
if (!response.ok) {
throw new Error(status.error || 'Could not load GPS status.');
}
renderGpsStatus(status);
}
socket.on('gps_location', function(data) {
const LocationX = document.getElementById('gpsLocationX');
const LocationY = document.getElementById('gpsLocationY');
@@ -218,6 +271,7 @@ socket.on('gps_location', function(data) {
const lat = parseFloat(data[0]);
const lon = parseFloat(data[1]);
if (lat != null && lon != null) {
renderGpsStatus({thread_alive: true, has_fix: true, fix_age_seconds: 0});
if (!map.hasLayer(mapTiles)) {
mapTiles.addTo(map);
}
@@ -334,3 +388,19 @@ window.setInterval(function () {
message.hidden = false;
});
}, 30000);
refreshGpsStatus().catch(function (error) {
renderGpsStatus({thread_alive: false, has_fix: false, fix_age_seconds: null});
const fixDetail = document.getElementById('gps-fix-detail');
const healthDetail = document.getElementById('pi-gps-detail');
if (fixDetail) fixDetail.textContent = error.message;
if (healthDetail) healthDetail.textContent = error.message;
});
window.setInterval(function () {
refreshGpsStatus().catch(function (error) {
const fixDetail = document.getElementById('gps-fix-detail');
const healthDetail = document.getElementById('pi-gps-detail');
if (fixDetail) fixDetail.textContent = error.message;
if (healthDetail) healthDetail.textContent = error.message;
});
}, 15000);