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
+7
View File
@@ -1744,6 +1744,13 @@ updates its display-facing values without a manual restart once sync lands.
history view still shows one configured car/device at a time until a later history view still shows one configured car/device at a time until a later
portal-wide device selector/filter is implemented. portal-wide device selector/filter is implemented.
### Phase 25.5d: GPS dashboard visibility / live-status debugging — 🚧 IN PROGRESS
- Add an explicit Pi dashboard GPS status indicator (thread alive / live fix / stale fix / no fix)
- Expose a local `/api/gps/status` endpoint so GPS health can be checked without relying on
socket events alone
- Verify on the real Pi whether GPS is actually still producing fixes or whether the issue is
now hardware/module/fix availability rather than a front-end live-update regression
--- ---
### Phase 26: Native Android companion app — 🚧 PLANNED (low priority, always last) ### Phase 26: Native Android companion app — 🚧 PLANNED (low priority, always last)
+4
View File
@@ -131,6 +131,10 @@ def uplink_connect():
def trips(): def trips():
return jsonify({"trips": app_sql.list_trips()}) return jsonify({"trips": app_sql.list_trips()})
@app.route('/api/gps/status')
def gps_status():
return jsonify(app_gps.get_status())
@app.route('/api/trip/<int:trip_id>') @app.route('/api/trip/<int:trip_id>')
def trip_route(trip_id): def trip_route(trip_id):
trip = app_sql.get_trip_route(trip_id) trip = app_sql.get_trip_route(trip_id)
+11
View File
@@ -179,6 +179,17 @@ def get_fix_age():
return None if last_fix_at is None else time.monotonic() - last_fix_at return None if last_fix_at is None else time.monotonic() - last_fix_at
def get_status():
"""Snapshot of the current GPS/GNSS state for the local dashboard."""
return {
"thread_alive": _gps_thread.is_alive() if _gps_thread is not None else False,
"has_fix": LocationX is not None and LocationY is not None and last_fix_at is not None,
"latitude": LocationX,
"longitude": LocationY,
"fix_age_seconds": get_fix_age(),
}
def start_gps(): def start_gps():
"""Start the background thread that continuously polls the SIM868 for a GNSS fix.""" """Start the background thread that continuously polls the SIM868 for a GNSS fix."""
global _gps_thread global _gps_thread
+70
View File
@@ -209,6 +209,59 @@ function reverseGeocode(lat, lon) {
.catch(() => { clearTimeout(t); _geocodePending = false; el.textContent = fallback; }); .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) { socket.on('gps_location', function(data) {
const LocationX = document.getElementById('gpsLocationX'); const LocationX = document.getElementById('gpsLocationX');
const LocationY = document.getElementById('gpsLocationY'); const LocationY = document.getElementById('gpsLocationY');
@@ -218,6 +271,7 @@ socket.on('gps_location', function(data) {
const lat = parseFloat(data[0]); const lat = parseFloat(data[0]);
const lon = parseFloat(data[1]); const lon = parseFloat(data[1]);
if (lat != null && lon != null) { if (lat != null && lon != null) {
renderGpsStatus({thread_alive: true, has_fix: true, fix_age_seconds: 0});
if (!map.hasLayer(mapTiles)) { if (!map.hasLayer(mapTiles)) {
mapTiles.addTo(map); mapTiles.addTo(map);
} }
@@ -334,3 +388,19 @@ window.setInterval(function () {
message.hidden = false; message.hidden = false;
}); });
}, 30000); }, 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);
+12 -3
View File
@@ -87,6 +87,11 @@
<div class="text-uppercase text-muted fw-bold text-xs mb-1">Uptime</div> <div class="text-uppercase text-muted fw-bold text-xs mb-1">Uptime</div>
<div id="pi-uptime" class="h6 text-dark fw-bold mb-0">--</div> <div id="pi-uptime" class="h6 text-dark fw-bold mb-0">--</div>
</div> </div>
<div class="col-6 col-lg-3">
<div class="text-uppercase text-muted fw-bold text-xs mb-1">GPS</div>
<div id="pi-gps-status" class="h6 text-dark fw-bold mb-0">--</div>
<div id="pi-gps-detail" class="small text-muted">Waiting…</div>
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -97,9 +102,12 @@
<div class="card shadow-sm h-100"> <div class="card shadow-sm h-100">
<div class="card-header py-2 d-flex justify-content-between align-items-center"> <div class="card-header py-2 d-flex justify-content-between align-items-center">
<h6 class="text-primary fw-bold m-0">Live GPS</h6> <h6 class="text-primary fw-bold m-0">Live GPS</h6>
<button id="btn-recenter" class="btn btn-outline-secondary btn-sm" type="button" title="Recenter map" hidden> <div class="d-flex align-items-center gap-2">
<i class="fas fa-crosshairs" aria-hidden="true"></i> <span id="gps-live-badge" class="badge text-bg-secondary">Checking…</span>
</button> <button id="btn-recenter" class="btn btn-outline-secondary btn-sm" type="button" title="Recenter map" hidden>
<i class="fas fa-crosshairs" aria-hidden="true"></i>
</button>
</div>
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div id="map"></div> <div id="map"></div>
@@ -116,6 +124,7 @@
</div> </div>
</div> </div>
<div id="gps-location-name" class="text-muted small" style="font-size:0.75rem;min-height:1rem"></div> <div id="gps-location-name" class="text-muted small" style="font-size:0.75rem;min-height:1rem"></div>
<div id="gps-fix-detail" class="text-muted small mt-1" style="font-size:0.75rem;min-height:1rem">Waiting for GPS status…</div>
</div> </div>
</div> </div>
</div> </div>
+59
View File
@@ -0,0 +1,59 @@
import types
import unittest
from unittest.mock import patch
from modules import app_gps
class GpsStatusTest(unittest.TestCase):
def test_get_status_without_fix(self):
original_thread = app_gps._gps_thread
original_lat = app_gps.LocationX
original_lon = app_gps.LocationY
original_last_fix = app_gps.last_fix_at
try:
app_gps._gps_thread = None
app_gps.LocationX = None
app_gps.LocationY = None
app_gps.last_fix_at = None
status = app_gps.get_status()
self.assertFalse(status["thread_alive"])
self.assertFalse(status["has_fix"])
self.assertIsNone(status["fix_age_seconds"])
finally:
app_gps._gps_thread = original_thread
app_gps.LocationX = original_lat
app_gps.LocationY = original_lon
app_gps.last_fix_at = original_last_fix
@patch("modules.app_gps.get_fix_age", return_value=12.5)
def test_get_status_with_fix(self, get_fix_age):
original_thread = app_gps._gps_thread
original_lat = app_gps.LocationX
original_lon = app_gps.LocationY
original_last_fix = app_gps.last_fix_at
try:
app_gps._gps_thread = types.SimpleNamespace(is_alive=lambda: True)
app_gps.LocationX = "45.5017"
app_gps.LocationY = "-73.5673"
app_gps.last_fix_at = 1.0
status = app_gps.get_status()
self.assertTrue(status["thread_alive"])
self.assertTrue(status["has_fix"])
self.assertEqual(status["latitude"], "45.5017")
self.assertEqual(status["longitude"], "-73.5673")
self.assertEqual(status["fix_age_seconds"], 12.5)
get_fix_age.assert_called_once_with()
finally:
app_gps._gps_thread = original_thread
app_gps.LocationX = original_lat
app_gps.LocationY = original_lon
app_gps.last_fix_at = original_last_fix
if __name__ == "__main__":
unittest.main()