diff --git a/PLAN.md b/PLAN.md index b92c9be..6127138 100644 --- a/PLAN.md +++ b/PLAN.md @@ -981,13 +981,33 @@ high-water mark plus the latest trip rather than loading the unbounded full hist markers, DTCs, and video data untouched. The deployed motion-aware logger then proved two consecutive stationary heartbeats were 60.02 seconds apart rather than five seconds. -### Phase 19: Pi health monitoring card โ€” ๐Ÿšง PLANNED -**New phase, small and standalone - no dependencies.** CPU temperature, free disk space, uptime, -and RAM usage are all cheap to read (`vcgencmd measure_temp`, `psutil`/`/proc` stats) and cheap to -display, but currently invisible - the only way to notice a problem (thermal throttling in a hot -car, an SD card filling up) today is to SSH in and check manually. Add a small backend endpoint -and a dashboard card (built on the Phase 15 component set) surfacing these at a glance. Also -feeds directly into Phase 20's alerting. +### Phase 19: Pi health monitoring card โ€” โœ… DONE +Added a dependency-free `/api/health` endpoint and responsive dashboard card reporting CPU +temperature, usable disk pressure/free bytes, RAM usage, human-readable uptime, and Raspberry Pi +firmware power/throttling flags. Data comes from thermal sysfs, `/proc`, `shutil.disk_usage`, and +`vcgencmd get_throttled`; individual unavailable sources are reported explicitly without hiding +the remaining readings. The endpoint exposes structured current and historical flags for Phase +20 alerts, plus warning/degraded/ok status. The browser refreshes every 30 seconds. + +**Implemented and verified 2026-08-09:** +- Five focused tests cover memory parsing, missing memory fields, current/historical throttling + bits, malformed firmware output, and ext4 reserved-block-aware disk pressure. Local non-Pi + validation correctly reports thermal/firmware sources as unavailable while retaining disk, + memory, and uptime instead of failing the endpoint. +- Removed the old browser-triggered thread that emitted uptime once per second indefinitely; the + health poll now provides uptime cheaply with the rest of the system snapshot. +- Deployed uncommitted code to the real Pi. The API returned complete data with no source errors: + about 47-49ยฐC, 19-20% RAM, 59% usable disk pressure with 22.8GB free, and roughly two hours + uptime during testing. SQLite/app service behavior remained active. +- Real hardware monitoring immediately found an actionable condition: `vcgencmd get_throttled` + alternated between `0x50005` (current undervoltage and CPU throttling, both previously occurred) + and `0x50000` (historical occurrences after the current condition cleared). The dashboard + prominently distinguishes current warnings from prior events. The Pi power source/cable should + be corrected before sustained in-car use. +- Browser-tested the real dashboard through `http://yaris.local/` at 390x844 and 1280x900: + all four metrics, warning state, timestamp, responsive columns, and 30-second data path rendered + without overflow or new console errors. Also fixed disconnected OBD speed showing `undefined` + and deferred offline map tiles until a real GPS fix, avoiding irrelevant placeholder requests. ### Phase 20: Alerts/notifications โ€” ๐Ÿšง PLANNED **New phase - depends on Phases 13/17/19 existing first**, since it needs real data sources to diff --git a/app.py b/app.py index e28ea11..19fd03e 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from flask import Flask, render_template, make_response, json, jsonify, request, send_file -from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video +from modules import app_gps, app_events, app_car, app_account, app_gps,app_obd, app_sql, app_dash, app_upload, app_video, app_health import os, time from gevent.pywsgi import WSGIServer @@ -140,6 +140,10 @@ def local_video_delete(): def upload_status(): return jsonify(app_upload.get_status()) +@app.route('/api/health') +def health(): + return jsonify(app_health.collect_health()) + @app.route('/api/upload/dashcam_toggle', methods=['POST']) def upload_dashcam_toggle(): body = request.get_json(silent=True) or {} diff --git a/modules/app_events.py b/modules/app_events.py index 2d832bf..e51603f 100644 --- a/modules/app_events.py +++ b/modules/app_events.py @@ -1,44 +1,10 @@ from flask_socketio import SocketIO -from threading import Thread from modules import app_obd -import time, threading socketio = SocketIO() -uptime_lock = threading.Lock() -thread_uptime = None @socketio.on('connect') def connect_event(): print('Client connected') print('Getting current OBD status...') app_obd.obd_connection() - uptime() - -def uptime(): - global thread_uptime - - if thread_uptime is None or not thread_uptime.is_alive(): - thread_uptime = Thread(target=loop_uptime) - thread_uptime.daemon = True - thread_uptime.start() - print(f"Uptime thread started, alive: {thread_uptime.is_alive()}") - else: - print(f"Uptime thread already running, alive: {thread_uptime.is_alive()}") - -def loop_uptime(): - uptime_lock.acquire() - try: - loop = True - while loop == True: - boottime = time.clock_gettime(time.CLOCK_BOOTTIME) - # Convert to hours, minutes, and seconds - seconds = int(boottime) - minutes, seconds = divmod(seconds, 60) - hours, minutes = divmod(minutes, 60) - time.sleep(1) - uptime = f"{hours:02d}:{minutes:02d}:{seconds:02d}" - socketio.emit("rpi_uptime", uptime) - finally: - uptime_lock.release() - - diff --git a/modules/app_health.py b/modules/app_health.py new file mode 100644 index 0000000..929ce99 --- /dev/null +++ b/modules/app_health.py @@ -0,0 +1,137 @@ +import os +import shutil +import subprocess + + +_BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +THERMAL_PATH = "/sys/class/thermal/thermal_zone0/temp" +MEMINFO_PATH = "/proc/meminfo" +UPTIME_PATH = "/proc/uptime" + +THROTTLE_FLAGS = { + 0: "under_voltage", + 1: "frequency_capped", + 2: "throttled", + 3: "soft_temperature_limit", +} + + +def _read_text(path): + with open(path, encoding="ascii") as source: + return source.read().strip() + + +def parse_meminfo(text): + values = {} + for line in text.splitlines(): + name, separator, remainder = line.partition(":") + if separator and name in {"MemTotal", "MemAvailable"}: + values[name] = int(remainder.strip().split()[0]) * 1024 + if set(values) != {"MemTotal", "MemAvailable"}: + raise ValueError("MemTotal or MemAvailable missing from /proc/meminfo") + total = values["MemTotal"] + available = values["MemAvailable"] + return { + "total_bytes": total, + "used_bytes": total - available, + "available_bytes": available, + "used_percent": round((total - available) / total * 100, 1), + } + + +def parse_throttled(value): + prefix, separator, raw_value = value.strip().partition("=") + if prefix != "throttled" or not separator: + raise ValueError(f"Unexpected vcgencmd response: {value.strip()}") + bits = int(raw_value, 16) + current = [ + label for bit, label in THROTTLE_FLAGS.items() if bits & (1 << bit) + ] + occurred = [ + label for bit, label in THROTTLE_FLAGS.items() if bits & (1 << (bit + 16)) + ] + return { + "raw": f"0x{bits:x}", + "current": current, + "occurred": occurred, + } + + +def _temperature(): + return round(int(_read_text(THERMAL_PATH)) / 1000, 1) + + +def _memory(): + return parse_meminfo(_read_text(MEMINFO_PATH)) + + +def _uptime(): + return int(float(_read_text(UPTIME_PATH).split()[0])) + + +def _disk(): + usage = shutil.disk_usage(_BASE_DIR) + return { + "total_bytes": usage.total, + "used_bytes": usage.used, + "free_bytes": usage.free, + "used_percent": round(usage.used / (usage.used + usage.free) * 100, 1), + } + + +def _throttling(): + result = subprocess.run( + ["vcgencmd", "get_throttled"], + check=True, + capture_output=True, + text=True, + timeout=2, + ) + return parse_throttled(result.stdout) + + +def collect_health(): + readers = { + "temperature_c": _temperature, + "memory": _memory, + "disk": _disk, + "uptime_seconds": _uptime, + "throttling": _throttling, + } + health = {} + errors = {} + for name, reader in readers.items(): + try: + health[name] = reader() + except (OSError, ValueError, subprocess.SubprocessError) as error: + health[name] = None + errors[name] = str(error) + + warnings = [] + historical_warnings = [] + throttling = health.get("throttling") + if throttling: + warnings.extend(throttling["current"]) + historical_warnings.extend( + warning + for warning in throttling["occurred"] + if warning not in throttling["current"] + ) + if health.get("temperature_c") is not None and health["temperature_c"] >= 80: + warnings.append("high_temperature") + if health.get("disk") and health["disk"]["used_percent"] >= 90: + warnings.append("disk_nearly_full") + if health.get("memory") and health["memory"]["used_percent"] >= 90: + warnings.append("memory_pressure") + + health["status"] = ( + "warning" + if warnings + else "degraded" + if errors + else "ok" + ) + health["warnings"] = warnings + health["historical_warnings"] = historical_warnings + health["errors"] = errors + return health diff --git a/readme.md b/readme.md index 94f2579..b4ac888 100644 --- a/readme.md +++ b/readme.md @@ -57,6 +57,8 @@ i have a dream WiFi/Ethernet is unavailable (dashcam video stays off cellular) - ๐Ÿ—„๏ธ **Motion-aware local-first SQLite logging** โ€” five-second motion checks without five-second parked writes, plus retry-safe remote sync and automatic backoff +- ๐ŸŒก๏ธ **Pi health monitoring** โ€” dashboard temperature, RAM, disk space, uptime, and explicit + undervoltage/throttling warnings from the Raspberry Pi firmware - ๐Ÿ›ฃ๏ธ **Trip history and route replay** โ€” detects motion from OBD wheel speed with sustained GPS displacement as a fallback, summarizes distance and speed in metric/imperial units, and replays each route as a line on the offline map with a timestamped play/pause timeline and moving marker @@ -96,7 +98,7 @@ For the remote server (receives trip logs + dashcam uploads), see [server/README ## Project status -This project was revived after a couple of years on the shelf. Phases 1-13 and 15-18 are complete: +This project was revived after a couple of years on the shelf. Phases 1-13 and 15-19 are complete: real GNSS, hardened OBD support, offline maps/UI, automatic WiFi hotspot switching, local logging, dashcam import, resumable remote upload, trip history/replay, deployment, and the shared UI foundation are implemented. @@ -109,6 +111,6 @@ partition, and clean shutdown still needs delayed-power or ignition-sense hardwa The browser-based phone relay was removed after real-world testing confirmed that mobile browsers cannot reliably use the Pi's WiFi and cellular data simultaneously. A native Android companion is -planned later instead. The next roadmap work is Pi health, followed by alerts, settings, and a -final UI polish pass. Full phase history, verification notes, and current scope live in +planned later instead. The next roadmap work is alerts, followed by settings and a final UI polish +pass. Full phase history, verification notes, and current scope live in [PLAN.md](./PLAN.md). diff --git a/static/js/yaris.js b/static/js/yaris.js index c740734..d19efe1 100644 --- a/static/js/yaris.js +++ b/static/js/yaris.js @@ -7,11 +7,11 @@ const socket = io(); // Generic placeholder center (not a real location) - re-centers automatically // to the real live GPS fix once one comes in (see gps_location handler below). var map = L.map('map', { minZoom: 8, maxZoom: 15 }).setView([48.505, -0.09], 12); - L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', { + const mapTiles = L.tileLayer('/static/vendor/tiles/{z}/{x}/{y}.png', { attribution: '© MapTiler © OpenStreetMap contributors', minZoom: 8, maxZoom: 15 - }).addTo(map); + }); var carMarker = L.marker(map.getCenter()).addTo(map) .bindPopup('your car... soon(tm)'); @@ -60,12 +60,16 @@ socket.on('obd_status', function(data) { }); socket.on('obd_speed', function(data) { + const status = document.getElementById('obd-speed'); + if (!Array.isArray(data)) { + status.textContent = data; + return; + } const time = data[0]; // Assuming data[0] is the time value const speed = data[1]; // Assuming data[1] is the speed value const maxDataPoints = 8; // Adjust this value as needed - const status = document.getElementById('obd-speed'); status.innerHTML = speed; // Get a reference to the chart's data @@ -98,11 +102,6 @@ socket.on('obd_error', function(data) { } }); -socket.on('rpi_uptime', function(data) { - const status = document.getElementById('rpi-uptime'); - status.innerHTML = data; -}); - let mapCentered = false; socket.on('gps_location', function(data) { @@ -115,6 +114,9 @@ socket.on('gps_location', function(data) { const lat = data[0]; const lon = data[1]; if (lat != null && lon != null) { + if (!map.hasLayer(mapTiles)) { + mapTiles.addTo(map); + } carMarker.setLatLng([lat, lon]); if (!mapCentered) { map.setView([lat, lon], 15); @@ -122,3 +124,97 @@ socket.on('gps_location', function(data) { } } }); + +function formatUptime(seconds) { + const days = Math.floor(seconds / 86400); + const hours = Math.floor((seconds % 86400) / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + return days ? `${days}d ${hours}h` : `${hours}h ${minutes}m`; +} + +function setHealthValue(id, value) { + document.getElementById(id).textContent = value; +} + +function healthWarningLabel(warning) { + return { + under_voltage: "Power supply undervoltage", + frequency_capped: "CPU frequency capped", + throttled: "CPU currently throttled", + soft_temperature_limit: "CPU temperature limit active", + high_temperature: "CPU temperature is high", + disk_nearly_full: "Disk is nearly full", + memory_pressure: "Memory usage is high", + }[warning] || warning; +} + +function historicalHealthWarningLabel(warning) { + return { + under_voltage: "Power supply undervoltage", + frequency_capped: "CPU frequency capping", + throttled: "CPU throttling", + soft_temperature_limit: "CPU temperature limiting", + }[warning] || warning; +} + +async function refreshHealth() { + const response = await fetch("/api/health"); + const health = await response.json(); + if (!response.ok) { + throw new Error(health.error || "Could not load Pi health."); + } + + setHealthValue( + "pi-temperature", + health.temperature_c == null ? "Unavailable" : `${health.temperature_c.toFixed(1)} ยฐC` + ); + setHealthValue( + "pi-memory", + health.memory == null ? "Unavailable" : `${health.memory.used_percent.toFixed(1)}%` + ); + setHealthValue( + "pi-disk", + health.disk == null + ? "Unavailable" + : `${health.disk.used_percent.toFixed(1)}% ยท ${YarisUI.formatBytes(health.disk.free_bytes)} free` + ); + setHealthValue( + "pi-uptime", + health.uptime_seconds == null ? "Unavailable" : formatUptime(health.uptime_seconds) + ); + + const warning = document.getElementById("pi-health-warning"); + const currentWarnings = health.warnings.map(healthWarningLabel); + const historicalWarnings = health.historical_warnings.map( + historicalHealthWarningLabel + ); + const warnings = currentWarnings.concat( + historicalWarnings.length + ? [`Previously detected: ${historicalWarnings.join(", ")}`] + : [] + ); + warning.hidden = warnings.length === 0; + warning.textContent = warnings.join(" ยท "); + + const error = document.getElementById("pi-health-error"); + const unavailable = Object.keys(health.errors); + error.hidden = unavailable.length === 0; + error.textContent = unavailable.length + ? `Unavailable health sources: ${unavailable.join(", ")}` + : ""; + document.getElementById("pi-health-updated").textContent = + `Updated ${new Date().toLocaleTimeString()}`; +} + +refreshHealth().catch(function (error) { + const message = document.getElementById("pi-health-error"); + message.textContent = error.message; + message.hidden = false; +}); +window.setInterval(function () { + refreshHealth().catch(function (error) { + const message = document.getElementById("pi-health-error"); + message.textContent = error.message; + message.hidden = false; + }); +}, 30000); diff --git a/templates/index.html b/templates/index.html index 3dd7277..180a403 100644 --- a/templates/index.html +++ b/templates/index.html @@ -11,7 +11,7 @@

Dashboard

 Placeholder button
-
+
@@ -24,7 +24,7 @@
-
+
@@ -37,7 +37,7 @@
-
+
@@ -50,20 +50,31 @@
-
-
-
-
-
-
UPTIME
-
-
-
unknown
-
-
-
-
-
+
+
+
+
Pi health
+ Loading... +
+
+ + +
+
+
CPU temperature
+
--
+
+
+
Memory used
+
--
+
+
+
Disk used
+
--
+
+
+
Uptime
+
--
diff --git a/tests/test_app_health.py b/tests/test_app_health.py new file mode 100644 index 0000000..7113282 --- /dev/null +++ b/tests/test_app_health.py @@ -0,0 +1,49 @@ +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from modules import app_health + + +class HealthParserTest(unittest.TestCase): + def test_parses_memory_usage(self): + memory = app_health.parse_meminfo( + "MemTotal: 1000 kB\nMemAvailable: 250 kB\n" + ) + + self.assertEqual(memory["total_bytes"], 1024000) + self.assertEqual(memory["used_bytes"], 768000) + self.assertEqual(memory["used_percent"], 75.0) + + def test_rejects_incomplete_memory_data(self): + with self.assertRaises(ValueError): + app_health.parse_meminfo("MemTotal: 1000 kB\n") + + def test_parses_current_and_historical_throttling(self): + throttling = app_health.parse_throttled("throttled=0x50005\n") + + self.assertEqual(throttling["raw"], "0x50005") + self.assertEqual( + throttling["current"], ["under_voltage", "throttled"] + ) + self.assertEqual( + throttling["occurred"], ["under_voltage", "throttled"] + ) + + def test_rejects_unexpected_throttling_response(self): + with self.assertRaises(ValueError): + app_health.parse_throttled("unknown") + + @patch("modules.app_health.shutil.disk_usage") + def test_disk_percent_accounts_for_reserved_blocks(self, disk_usage): + disk_usage.return_value = SimpleNamespace( + total=100, used=88, free=9 + ) + + disk = app_health._disk() + + self.assertEqual(disk["used_percent"], 90.7) + + +if __name__ == "__main__": + unittest.main()