Add Raspberry Pi health monitoring

Co-authored-by: Copilot <[email protected]>
This commit is contained in:
Mathieu Moran
2026-08-09 19:57:07 -04:00
co-authored by Copilot
parent d3bc313e7f
commit 8b664667e5
8 changed files with 355 additions and 70 deletions
+27 -7
View File
@@ -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 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. consecutive stationary heartbeats were 60.02 seconds apart rather than five seconds.
### Phase 19: Pi health monitoring card — 🚧 PLANNED ### Phase 19: Pi health monitoring card — ✅ DONE
**New phase, small and standalone - no dependencies.** CPU temperature, free disk space, uptime, Added a dependency-free `/api/health` endpoint and responsive dashboard card reporting CPU
and RAM usage are all cheap to read (`vcgencmd measure_temp`, `psutil`/`/proc` stats) and cheap to temperature, usable disk pressure/free bytes, RAM usage, human-readable uptime, and Raspberry Pi
display, but currently invisible - the only way to notice a problem (thermal throttling in a hot firmware power/throttling flags. Data comes from thermal sysfs, `/proc`, `shutil.disk_usage`, and
car, an SD card filling up) today is to SSH in and check manually. Add a small backend endpoint `vcgencmd get_throttled`; individual unavailable sources are reported explicitly without hiding
and a dashboard card (built on the Phase 15 component set) surfacing these at a glance. Also the remaining readings. The endpoint exposes structured current and historical flags for Phase
feeds directly into Phase 20's alerting. 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 ### Phase 20: Alerts/notifications — 🚧 PLANNED
**New phase - depends on Phases 13/17/19 existing first**, since it needs real data sources to **New phase - depends on Phases 13/17/19 existing first**, since it needs real data sources to
+5 -1
View File
@@ -1,5 +1,5 @@
from flask import Flask, render_template, make_response, json, jsonify, request, send_file 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 import os, time
from gevent.pywsgi import WSGIServer from gevent.pywsgi import WSGIServer
@@ -140,6 +140,10 @@ def local_video_delete():
def upload_status(): def upload_status():
return jsonify(app_upload.get_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']) @app.route('/api/upload/dashcam_toggle', methods=['POST'])
def upload_dashcam_toggle(): def upload_dashcam_toggle():
body = request.get_json(silent=True) or {} body = request.get_json(silent=True) or {}
-34
View File
@@ -1,44 +1,10 @@
from flask_socketio import SocketIO from flask_socketio import SocketIO
from threading import Thread
from modules import app_obd from modules import app_obd
import time, threading
socketio = SocketIO() socketio = SocketIO()
uptime_lock = threading.Lock()
thread_uptime = None
@socketio.on('connect') @socketio.on('connect')
def connect_event(): def connect_event():
print('Client connected') print('Client connected')
print('Getting current OBD status...') print('Getting current OBD status...')
app_obd.obd_connection() 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()
+137
View File
@@ -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
+5 -3
View File
@@ -57,6 +57,8 @@ i have a dream
WiFi/Ethernet is unavailable (dashcam video stays off cellular) WiFi/Ethernet is unavailable (dashcam video stays off cellular)
- 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second - 🗄️ **Motion-aware local-first SQLite logging** — five-second motion checks without five-second
parked writes, plus retry-safe remote sync and automatic backoff 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 - 🛣️ **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 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 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 ## 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, 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 dashcam import, resumable remote upload, trip history/replay, deployment, and the shared UI
foundation are implemented. 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 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 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 planned later instead. The next roadmap work is alerts, followed by settings and a final UI polish
final UI polish pass. Full phase history, verification notes, and current scope live in pass. Full phase history, verification notes, and current scope live in
[PLAN.md](./PLAN.md). [PLAN.md](./PLAN.md).
+104 -8
View File
@@ -7,11 +7,11 @@ const socket = io();
// Generic placeholder center (not a real location) - re-centers automatically // 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). // 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); 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: '&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors', attribution: '&copy; <a href="https://www.maptiler.com/copyright/" target="_blank">MapTiler</a> &copy; <a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a> contributors',
minZoom: 8, minZoom: 8,
maxZoom: 15 maxZoom: 15
}).addTo(map); });
var carMarker = L.marker(map.getCenter()).addTo(map) var carMarker = L.marker(map.getCenter()).addTo(map)
.bindPopup('your car... soon(tm)'); .bindPopup('your car... soon(tm)');
@@ -60,12 +60,16 @@ socket.on('obd_status', function(data) {
}); });
socket.on('obd_speed', 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 time = data[0]; // Assuming data[0] is the time value
const speed = data[1]; // Assuming data[1] is the speed value const speed = data[1]; // Assuming data[1] is the speed value
const maxDataPoints = 8; // Adjust this value as needed const maxDataPoints = 8; // Adjust this value as needed
const status = document.getElementById('obd-speed');
status.innerHTML = speed; status.innerHTML = speed;
// Get a reference to the chart's data // 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; let mapCentered = false;
socket.on('gps_location', function(data) { socket.on('gps_location', function(data) {
@@ -115,6 +114,9 @@ socket.on('gps_location', function(data) {
const lat = data[0]; const lat = data[0];
const lon = data[1]; const lon = data[1];
if (lat != null && lon != null) { if (lat != null && lon != null) {
if (!map.hasLayer(mapTiles)) {
mapTiles.addTo(map);
}
carMarker.setLatLng([lat, lon]); carMarker.setLatLng([lat, lon]);
if (!mapCentered) { if (!mapCentered) {
map.setView([lat, lon], 15); 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);
+28 -17
View File
@@ -11,7 +11,7 @@
<h3 class="text-dark mb-0">Dashboard</h3><a class="btn btn-primary btn-sm d-none d-sm-inline-block" role="button" href="#"><i class="fas fa-download fa-sm text-white-50"></i>&nbsp;Placeholder button</a> <h3 class="text-dark mb-0">Dashboard</h3><a class="btn btn-primary btn-sm d-none d-sm-inline-block" role="button" href="#"><i class="fas fa-download fa-sm text-white-50"></i>&nbsp;Placeholder button</a>
</div> </div>
<div class="row"> <div class="row">
<div class="col-md-6 col-xl-3 mb-4"> <div class="col-md-6 col-xl-4 mb-4">
<div class="card shadow border-start-primary py-2"> <div class="card shadow border-start-primary py-2">
<div class="card-body"> <div class="card-body">
<div class="row align-items-center no-gutters"> <div class="row align-items-center no-gutters">
@@ -24,7 +24,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-xl-3 mb-4"> <div class="col-md-6 col-xl-4 mb-4">
<div class="card shadow border-start-success py-2"> <div class="card shadow border-start-success py-2">
<div class="card-body"> <div class="card-body">
<div class="row align-items-center no-gutters"> <div class="row align-items-center no-gutters">
@@ -37,7 +37,7 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-xl-3 mb-4"> <div class="col-md-6 col-xl-4 mb-4">
<div class="card shadow border-start-info py-2"> <div class="card shadow border-start-info py-2">
<div class="card-body"> <div class="card-body">
<div class="row align-items-center no-gutters"> <div class="row align-items-center no-gutters">
@@ -50,20 +50,31 @@
</div> </div>
</div> </div>
</div> </div>
<div class="col-md-6 col-xl-3 mb-4"> </div>
<div class="card shadow border-start-warning py-2"> <div class="card shadow mb-4" id="pi-health-card">
<div class="card-body"> <div class="card-header d-flex flex-wrap justify-content-between align-items-center gap-2">
<div class="row align-items-center no-gutters"> <h6 class="text-primary fw-bold m-0">Pi health</h6>
<div class="col me-2"> <span id="pi-health-updated" class="small text-muted">Loading...</span>
<div class="text-uppercase text-info fw-bold text-xs mb-1"><span>UPTIME</span></div> </div>
<div class="row g-0 align-items-center"> <div class="card-body">
<div class="col-auto"> <div id="pi-health-warning" class="alert alert-warning" role="alert" hidden></div>
<div id="rpi-uptime" class="text-dark fw-bold h5 mb-0 me-3"><span>unknown</span></div> <div id="pi-health-error" class="alert alert-danger" role="alert" hidden></div>
</div> <div class="row g-4">
</div> <div class="col-6 col-lg-3">
</div> <div class="text-uppercase text-muted fw-bold text-xs mb-1">CPU temperature</div>
<div class="col-auto"><i class="fas fa-clipboard-list fa-2x text-gray-300"></i></div> <div id="pi-temperature" class="h5 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">Memory used</div>
<div id="pi-memory" class="h5 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">Disk used</div>
<div id="pi-disk" class="h5 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">Uptime</div>
<div id="pi-uptime" class="h5 text-dark fw-bold mb-0">--</div>
</div> </div>
</div> </div>
</div> </div>
+49
View File
@@ -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()