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

@ -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."""