thermal: watch NVMe/RP1 temps, fan stall, and undervoltage on the Pi 5

van-thermal grows sensor kinds beyond plain temperatures: "fan" alerts when
pwmfan is commanded on (pwm > 0) but reads 0 RPM (warn on first sample, crit
if it persists — 0 RPM with pwm 0 is just the firmware idling a cool SoC),
and "undervolt" goes crit on the live rpi_volt alarm plus a sticky warn off
the firmware's latched since-boot bit, so dips shorter than the 10s sample
interval still surface once. All kinds share the existing hysteresis /
journal / Pushover machinery (fan 🌀, undervolt ).

Config adds nvme Composite (65/70), rp1_adc (80/85 — the RP1 die drives all
USB/eth I/O), fan, and undervolt to the existing cpu sensor. The CSV logger
now derives per-kind columns and self-rotates when the header changes; the
Cockpit Temps card renders RPM + pwm duty and undervoltage state alongside
the temperatures.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Andreas Wrede
2026-07-12 06:41:17 -04:00
co-authored by Claude Fable 5
parent 6fe683000d
commit 2da97d26f4
4 changed files with 194 additions and 36 deletions
+16 -4
View File
@@ -143,14 +143,26 @@ function renderThermal(th) {
return;
}
const pillClass = { ok: "ok", warn: "warn", crit: "bad" };
let html = `<table><thead><tr><th>Sensor</th><th>Temp</th><th>Status</th><th>Warn / Crit</th></tr></thead><tbody>`;
let html = `<table><thead><tr><th>Sensor</th><th>Value</th><th>Status</th><th>Limits</th></tr></thead><tbody>`;
Object.keys(th.sensors).forEach(name => {
const s = th.sensors[name];
const temp = s.temp == null ? "—" : `${esc(s.temp)} °C`;
let value, limits;
if (s.kind === "fan") {
value = s.rpm == null ? "—" : `${esc(s.rpm)} RPM`;
limits = s.pwm == null ? "—" : `pwm ${esc(s.pwm)}/255`;
} else if (s.kind === "undervolt") {
value = s.now == null ? "—"
: s.now ? "undervoltage"
: s.since_boot ? "dip since boot" : "ok";
limits = "—";
} else {
value = s.temp == null ? "—" : `${esc(s.temp)} °C`;
limits = `${esc(s.warn)} / ${esc(s.crit)} °C`;
}
const lvl = s.level || "ok";
html += `<tr><td>${esc(name.toUpperCase())}</td><td>${temp}</td>` +
html += `<tr><td>${esc(name.toUpperCase())}</td><td>${value}</td>` +
`<td><span class="pill ${pillClass[lvl] || ""}">${esc(lvl)}</span></td>` +
`<td class="muted">${esc(s.warn)} / ${esc(s.crit)} °C</td></tr>`;
`<td class="muted">${limits}</td></tr>`;
});
html += `</tbody></table>`;
html += `<p class="muted">History: <code>/var/log/van-thermal.csv</code> · alerts: <code>journalctl -u van-thermal</code></p>`;
+25
View File
@@ -12,6 +12,31 @@
"warn": 80,
"crit": 85,
"clear_margin": 5
},
{
"name": "nvme",
"hwmon": "nvme",
"label": "Composite",
"warn": 65,
"crit": 70,
"clear_margin": 5
},
{
"name": "rp1",
"hwmon": "rp1_adc",
"warn": 80,
"crit": 85,
"clear_margin": 5
},
{
"name": "fan",
"kind": "fan",
"hwmon": "pwmfan"
},
{
"name": "undervolt",
"kind": "undervolt",
"hwmon": "rpi_volt"
}
]
}
+151 -30
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""van-thermal — temperature monitor for the campervan router (wayback).
"""van-thermal — temperature + health monitor for the campervan router.
One small daemon that does three jobs off a single sysfs sample loop:
1. publishes /run/van-thermal/state.json (the Cockpit "Temps" card reads this,
@@ -10,7 +10,10 @@ One small daemon that does three jobs off a single sysfs sample loop:
3. appends throttled CSV history to /var/log/van-thermal.csv with self-rotation
Sensors are resolved by hwmon *name* + *label* at runtime, never by hwmonN index
(that number is assigned at boot and is not stable). Pushover credentials are shared
(that number is assigned at boot and is not stable). Besides temperatures a sensor
spec may set "kind": "fan" (alerts when the fan is commanded on but reads 0 RPM) or
"kind": "undervolt" (live rpi_volt alarm, plus the firmware's latched since-boot bit
so dips between samples still surface). Pushover credentials are shared
with van-battery (/etc/van-battery/pushover.json, 0600); missing/placeholder creds
disable sending but nothing else. Stdlib only.
"""
@@ -18,6 +21,7 @@ disable sending but nothing else. Stdlib only.
import json
import os
import socket
import subprocess
import sys
import time
import urllib.parse
@@ -50,6 +54,7 @@ DEFAULTS = {
}
LEVELS = ("ok", "warn", "crit")
ICONS = {"fan": "🌀", "undervolt": "⚡"} # pushover title icons for non-temp kinds
PLACEHOLDERS = {"", "REPLACE_ME", "your-token-here", "your-user-key-here"}
@@ -126,15 +131,28 @@ def find_hwmon(name):
return None
def resolve_hwmon(spec, cache):
"""Cached hwmon-dir lookup by name; re-resolved when the dir vanished."""
d = cache.get(spec["hwmon"])
if d is None or not d.exists():
d = find_hwmon(spec["hwmon"])
cache[spec["hwmon"]] = d
return d
def read_hwmon_int(d, fname):
try:
return int((d / fname).read_text().strip())
except (OSError, ValueError):
return None
def read_temp(spec, cache):
"""Read one sensor's temperature in °C, or None if unavailable.
spec: {hwmon, label}. Resolves hwmon dir + the tempN whose *_label matches,
falling back to temp1 when the chip exposes no labels."""
d = cache.get(spec["hwmon"])
if d is None or not d.exists():
d = find_hwmon(spec["hwmon"])
cache[spec["hwmon"]] = d
d = resolve_hwmon(spec, cache)
if d is None:
return None
@@ -159,6 +177,65 @@ def read_temp(spec, cache):
return None
def read_fan(spec, cache):
"""(rpm, pwm) for a pwmfan hwmon, either None when unavailable."""
d = resolve_hwmon(spec, cache)
if d is None:
return None, None
rpm = read_hwmon_int(d, "fan1_input")
if rpm is None:
cache[spec["hwmon"]] = None # hwmon may have re-enumerated
return None, None
return rpm, read_hwmon_int(d, "pwm1")
def read_undervolt(spec, cache):
"""(now, since_boot) undervoltage flags.
Live alarm from the rpi_volt hwmon; the firmware's latched since-boot bit
(get_throttled bit 16) catches dips shorter than the sample interval."""
now = None
d = resolve_hwmon(spec, cache)
if d is not None:
v = read_hwmon_int(d, "in0_lcrit_alarm")
if v is None:
cache[spec["hwmon"]] = None
else:
now = bool(v)
since_boot = None
try:
out = subprocess.run(["vcgencmd", "get_throttled"], capture_output=True,
text=True, timeout=5).stdout
bits = int(out.split("=")[1], 16)
since_boot = bool(bits & 0x10000)
if now is None:
now = bool(bits & 0x1)
except Exception:
pass
return now, since_boot
def classify_fan(rpm, pwm, prev_level):
"""A fan commanded on (pwm > 0) reading 0 RPM is stalled/unplugged: first
such sample is warn, a consecutive one escalates to crit. pwm == 0 with
0 RPM is the firmware idling the fan on a cool SoC — that's ok."""
if rpm is None:
return prev_level
if rpm > 0 or pwm is None or pwm == 0:
return "ok"
return "crit" if prev_level in ("warn", "crit") else "warn"
def classify_undervolt(now, since_boot, prev_level):
"""crit while actively under-volted; warn (sticky until reboot) once a dip
has been latched, so a transient still gets one page + a yellow pill."""
if now is None:
return prev_level
if now:
return "crit"
return "warn" if since_boot else "ok"
def classify(temp, spec, prev_level):
"""Level with hysteresis: step up at the threshold, step down only after
dropping clear_margin below it, so a sensor on the line doesn't oscillate."""
@@ -175,7 +252,7 @@ def classify(temp, spec, prev_level):
return "ok"
def announce(name, temp, old, new):
def announce(name, disp, old, new):
"""Log a level change to the journal. Returns the crossing direction
("rising" / "falling") so the caller can decide whether to page via
Pushover, or None when the level is unchanged."""
@@ -184,7 +261,7 @@ def announce(name, temp, old, new):
rising = LEVELS.index(new) > LEVELS.index(old)
sev = {"crit": "crit", "warn": "warn", "ok": "info"}[new]
arrow = "rose to" if rising else "fell back to"
log(f"{name} {arrow} {new.upper()} ({temp:.1f}°C)", sev if rising else "info")
log(f"{name} {arrow} {new.upper()} ({disp})", sev if rising else "info")
return "rising" if rising else "falling"
@@ -196,20 +273,39 @@ def rotate_log(path, max_bytes):
log(f"log rotate failed: {e}", "warn")
def csv_columns(s):
unit = {"temp": "c", "fan": "rpm", "undervolt": "uv"}.get(s.get("kind", "temp"), "v")
return [f"{s['name']}_{unit}", f"{s['name']}_lvl"]
def csv_value(r):
kind = r.get("kind", "temp")
if kind == "fan":
return "" if r["rpm"] is None else str(r["rpm"])
if kind == "undervolt":
return "" if r["now"] is None else str(int(r["now"]))
return "" if r["temp"] is None else f"{r['temp']:.1f}"
def write_csv(cfg, readings):
path = Path(cfg["log_path"])
rotate_log(path, cfg["log_max_bytes"])
new = not path.exists()
header = ",".join(["time"] + sum([csv_columns(s) for s in cfg["sensors"]], []))
try:
path.parent.mkdir(parents=True, exist_ok=True)
if path.exists():
with open(path) as f:
if f.readline().rstrip("\n") != header:
# Sensor set changed — rotate so columns stay aligned with the header.
path.replace(path.with_suffix(path.suffix + ".1"))
new = not path.exists()
with open(path, "a") as f:
if new:
f.write(",".join(["time"] + sum(
[[f"{s['name']}_c", f"{s['name']}_lvl"] for s in cfg["sensors"]], [])) + "\n")
f.write(header + "\n")
row = [datetime.now(timezone.utc).isoformat(timespec="seconds")]
for s in cfg["sensors"]:
r = readings[s["name"]]
row += ["" if r["temp"] is None else f"{r['temp']:.1f}", r["level"]]
row += [csv_value(r), r["level"]]
f.write(",".join(row) + "\n")
except OSError as e:
log(f"csv write failed: {e}", "warn")
@@ -240,31 +336,56 @@ def main():
while True:
readings = {}
for s in cfg["sensors"]:
name, kind = s["name"], s.get("kind", "temp")
old = levels[name]
if kind == "fan":
rpm, pwm = read_fan(s, cache)
new = classify_fan(rpm, pwm, old)
disp = None if rpm is None else f"{rpm} RPM"
detail = (f"fan turning at {rpm} RPM (pwm {pwm}/255)." if new == "ok" else
f"fan reads 0 RPM while commanded on (pwm {pwm}/255) — "
"stalled, blocked, or unplugged.")
reading = {"kind": kind, "rpm": rpm, "pwm": pwm}
elif kind == "undervolt":
uv_now, uv_boot = read_undervolt(s, cache)
new = classify_undervolt(uv_now, uv_boot, old)
disp = None if uv_now is None else (
"UNDERVOLTAGE" if uv_now else
"dip since boot" if uv_boot else "supply ok")
detail = {"crit": "supply voltage below threshold right now — check PSU and cabling.",
"warn": "an undervoltage dip was latched since boot (supply ok now; "
"latch clears on reboot).",
"ok": "supply voltage ok."}[new]
reading = {"kind": kind, "now": uv_now, "since_boot": uv_boot}
else:
temp = read_temp(s, cache)
new = classify(temp, s, levels[s["name"]])
if temp is not None:
old = levels[s["name"]]
direction = announce(s["name"], temp, old, new)
if direction == "rising" and LEVELS.index(new) >= alert_idx:
icon = "🔥" if new == "crit" else "🌡"
new = classify(temp, s, old)
disp = None if temp is None else f"{temp:.1f}°C"
thr = s["crit"] if new == "crit" else s["warn"]
pushover(cfg, f"{icon} {HOST}: {s['name']} {new.upper()} {temp:.1f}°C",
f"{s['name']} temperature {temp:.1f}°C crossed {new.upper()} "
f"threshold ({thr}°C). warn {s['warn']}, crit {s['crit']}.",
detail = (f"below warn ({s['warn']}°C)." if new == "ok" else
f"crossed the {new.upper()} threshold ({thr}°C); "
f"warn {s['warn']}, crit {s['crit']}.")
reading = {"kind": kind, "temp": None if temp is None else round(temp, 1),
"warn": s["warn"], "crit": s["crit"]}
if disp is not None:
direction = announce(name, disp, old, new)
if direction == "rising" and LEVELS.index(new) >= alert_idx:
icon = ICONS.get(kind, "🔥" if new == "crit" else "🌡")
pushover(cfg, f"{icon} {HOST}: {name} {new.upper()} — {disp}",
f"{name}: {detail}",
priority=1 if new == "crit" else 0)
elif direction == "falling" and LEVELS.index(old) >= alert_idx:
# Recovery: page only when leaving a level we'd have paged about,
# so the phone that got the rising alert also gets the all-clear.
label = "NORMAL" if new == "ok" else new.upper()
pushover(cfg, f"✅ {HOST}: {s['name']} back to {label} {temp:.1f}°C",
f"{s['name']} temperature {temp:.1f}°C dropped back to {label} "
f"(warn {s['warn']}, crit {s['crit']}).",
priority=0)
levels[s["name"]] = new
readings[s["name"]] = {
"temp": None if temp is None else round(temp, 1),
"level": new, "warn": s["warn"], "crit": s["crit"],
}
pushover(cfg, f"✅ {HOST}: {name} back to {label} — {disp}",
f"{name}: {detail}", priority=0)
levels[name] = new
reading["level"] = new
readings[name] = reading
try:
write_state(readings)
+1 -1
View File
@@ -1,5 +1,5 @@
[Unit]
Description=Temperature monitor (CPU + NVMe) for the campervan router
Description=Thermal + health monitor (temps, fan, undervoltage) for the campervan router
# Order after network-online so a boot-time threshold crossing (common: the Pi
# boots hot) can actually reach Pushover — the very first sample fires within
# seconds of start. If no WAN comes up, wait-online times out and we start anyway.