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:
@@ -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
|
||||
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)
|
||||
|
||||
@@ -131,6 +131,10 @@ def uplink_connect():
|
||||
def 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>')
|
||||
def trip_route(trip_id):
|
||||
trip = app_sql.get_trip_route(trip_id)
|
||||
|
||||
@@ -179,6 +179,17 @@ def get_fix_age():
|
||||
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():
|
||||
"""Start the background thread that continuously polls the SIM868 for a GNSS fix."""
|
||||
global _gps_thread
|
||||
|
||||
@@ -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);
|
||||
|
||||
+12
-3
@@ -87,6 +87,11 @@
|
||||
<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>
|
||||
<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>
|
||||
@@ -97,9 +102,12 @@
|
||||
<div class="card shadow-sm h-100">
|
||||
<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>
|
||||
<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 class="d-flex align-items-center gap-2">
|
||||
<span id="gps-live-badge" class="badge text-bg-secondary">Checking…</span>
|
||||
<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 class="card-body p-0">
|
||||
<div id="map"></div>
|
||||
@@ -116,6 +124,7 @@
|
||||
</div>
|
||||
</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>
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user