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
|
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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,10 +102,13 @@
|
|||||||
<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>
|
||||||
|
<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>
|
<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>
|
<i class="fas fa-crosshairs" aria-hidden="true"></i>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
</div>
|
</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>
|
||||||
|
|||||||
@@ -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