controller: report cooling fan RPM in CPU status

The RPi 5 fan connector exposes a tachometer via the pwmfan hwmon
device. Report it as fan_rpm in cpu_stats and show it in both the
Controller fallback UI and the React Status page. Hidden when no fan
is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Roland 2026-07-23 19:42:52 -06:00 • committed by lrnselfreliance
parent f635671266
commit 42a017294e
6 changed files with 54 additions and 2 deletions

View file

@ -93,6 +93,14 @@ function CPUTemperatureStatistic({temperature, high_temperature, critical_temper
return <Statistic label='Temp C°' value={temperature} {...props}/>
}
function FanRPMStatistic({fan_rpm, ...props}) {
// Only rendered when the device reports a fan (e.g. RPi 5 fan connector).
if (fan_rpm === null || fan_rpm === undefined) {
return null;
}
return <Statistic label='Fan RPM' value={fan_rpm} {...props}/>
}
function IOWaitStatistic({percent_iowait, ...props}) {
if (percent_iowait === null || percent_iowait === undefined) {
return <Statistic label='IO Wait %' value='?'/>
@ -236,6 +244,7 @@ export function StatusPage() {
let temperature;
let high_temperature;
let critical_temperature;
let fan_rpm;
let processesStats = null;
let minute_1;
let minute_5;
@ -255,6 +264,7 @@ export function StatusPage() {
temperature = cpu_stats['temperature'];
high_temperature = cpu_stats['high_temperature'];
critical_temperature = cpu_stats['critical_temperature'];
fan_rpm = cpu_stats['fan_rpm'];
processesStats = processes_stats;
minute_1 = load_stats['minute_1'];
@ -312,6 +322,7 @@ export function StatusPage() {
high_temperature={high_temperature}
critical_temperature={critical_temperature}
/>
<FanRPMStatistic fan_rpm={fan_rpm}/>
<IOWaitStatistic percent_iowait={percent_iowait}/>
<UptimeStatistic uptime_seconds={uptime_seconds}/>
</Statistic.Group>
@ -332,6 +343,7 @@ export function StatusPage() {
critical_temperature={critical_temperature}
style={{marginRight: 0}}
/>
<FanRPMStatistic fan_rpm={fan_rpm}/>
<IOWaitStatistic percent_iowait={percent_iowait}/>
<UptimeStatistic uptime_seconds={uptime_seconds}/>
</Statistic.Group>

View file

@ -32,6 +32,7 @@ class CpuStatusResponse(BaseModel):
frequency_max_mhz: Optional[int] = Field(default=None, description="Maximum CPU frequency in MHz")
temperature_c: Optional[float] = Field(default=None, description="CPU temperature in Celsius")
cores: int = Field(description="Number of CPU cores")
fan_rpm: Optional[int] = Field(default=None, description="Cooling fan speed in RPM, if a fan is present")
# ============================================================================

View file

@ -92,6 +92,17 @@ def get_cpu_status() -> dict:
except Exception:
pass
# Get fan RPM (e.g. the RPi 5 fan connector's `pwmfan` hwmon device).
# sensors_fans only exists on Linux psutil.
fan_rpm = None
try:
fans = psutil.sensors_fans() if hasattr(psutil, "sensors_fans") else {}
readings = [i.current for readings_ in fans.values() for i in readings_ if i.current]
if readings:
fan_rpm = int(max(readings))
except Exception:
pass
return {
"cores": cpu_count,
"cur_frequency": cur_frequency - min_frequency if cur_frequency and min_frequency else None,
@ -101,6 +112,7 @@ def get_cpu_status() -> dict:
"temperature": int(temperature) if temperature else None,
"high_temperature": int(high_temperature) if high_temperature else None,
"critical_temperature": int(critical_temperature) if critical_temperature else None,
"fan_rpm": fan_rpm,
}

View file

@ -258,6 +258,7 @@ async def dashboard(request: Request):
"cpu": {
"percent": cpu["percent"],
"temperature_c": cpu["temperature"], # Field renamed
"fan_rpm": cpu.get("fan_rpm"),
},
"memory": {
"percent": round((memory["used"] / memory["total"]) * 100, 1) if memory["total"] > 0 else 0,

View file

@ -938,7 +938,7 @@
<div class="status-card">
<h3>CPU</h3>
<p class="value">{{ cpu.percent if cpu.percent is not none else '--' }}%</p>
<p class="sub">{% if cpu.temperature_c %}{{ cpu.temperature_c }}&deg;C{% else %}--{% endif %}</p>
<p class="sub">{% if cpu.temperature_c %}{{ cpu.temperature_c }}&deg;C{% else %}--{% endif %}{% if cpu.fan_rpm %} &middot; {{ cpu.fan_rpm }} RPM{% endif %}</p>
</div>
<div class="status-card">
<h3>Memory</h3>
@ -2692,7 +2692,13 @@
const cpuValue = document.querySelector('.status-card:nth-child(1) .value');
const cpuSub = document.querySelector('.status-card:nth-child(1) .sub');
if (cpuValue) cpuValue.textContent = (cpu.percent !== null ? cpu.percent : '--') + '%';
if (cpuSub) cpuSub.innerHTML = cpu.temperature ? cpu.temperature + '&deg;C' : '--';
if (cpuSub) {
let cpuSubText = cpu.temperature ? cpu.temperature + '&deg;C' : '--';
if (cpu.fan_rpm) {
cpuSubText += ' &middot; ' + cpu.fan_rpm + ' RPM';
}
cpuSub.innerHTML = cpuSubText;
}
// Update Memory card
const memValue = document.querySelector('.status-card:nth-child(2) .value');

View file

@ -52,6 +52,26 @@ class TestGetCpuStatus:
assert isinstance(result["cores"], int)
assert result["cores"] > 0
def test_fan_rpm(self):
"""Fan RPM should be reported when a fan sensor exists (e.g. RPi 5 pwmfan)."""
fan = mock.Mock()
fan.current = 2599
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, return_value={"pwmfan": [fan]}):
result = get_cpu_status()
assert result["fan_rpm"] == 2599
def test_fan_rpm_no_fan(self):
"""Fan RPM should be None when no fan sensor exists."""
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, return_value={}):
result = get_cpu_status()
assert result["fan_rpm"] is None
def test_fan_rpm_sensor_error(self):
"""Fan RPM should be None when the sensor read fails."""
with mock.patch("controller.lib.status.psutil.sensors_fans", create=True, side_effect=OSError("no sysfs")):
result = get_cpu_status()
assert result["fan_rpm"] is None
class TestGetMemoryStatus:
"""Tests for get_memory_status function."""