diff --git a/PLAN.md b/PLAN.md index 71bf14b..d538750 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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) diff --git a/app.py b/app.py index 4a616ca..f56dca3 100644 --- a/app.py +++ b/app.py @@ -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/') def trip_route(trip_id): trip = app_sql.get_trip_route(trip_id) diff --git a/modules/app_gps.py b/modules/app_gps.py index dbc3ef4..eb8c950 100644 --- a/modules/app_gps.py +++ b/modules/app_gps.py @@ -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 diff --git a/static/js/yaris.js b/static/js/yaris.js index 5c19a71..a1f3182 100644 --- a/static/js/yaris.js +++ b/static/js/yaris.js @@ -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); diff --git a/templates/index.html b/templates/index.html index 1d7c3c5..7b0abc5 100644 --- a/templates/index.html +++ b/templates/index.html @@ -87,6 +87,11 @@
Uptime
--
+
+
GPS
+
--
+
Waiting…
+
@@ -97,9 +102,12 @@
Live GPS
- +
+ Checking… + +
@@ -116,6 +124,7 @@
+
Waiting for GPS status…
diff --git a/tests/test_app_gps.py b/tests/test_app_gps.py new file mode 100644 index 0000000..b7150b2 --- /dev/null +++ b/tests/test_app_gps.py @@ -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()