From 42a017294e143b5fbab53cf719813d4fa4726450 Mon Sep 17 00:00:00 2001 From: Roland Date: Thu, 23 Jul 2026 19:42:52 -0600 Subject: [PATCH] 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 --- app/src/components/admin/Status.js | 12 ++++++++++++ controller/api/schemas.py | 1 + controller/lib/status.py | 12 ++++++++++++ controller/main.py | 1 + controller/templates/index.html | 10 ++++++++-- controller/test/test_status.py | 20 ++++++++++++++++++++ 6 files changed, 54 insertions(+), 2 deletions(-) diff --git a/app/src/components/admin/Status.js b/app/src/components/admin/Status.js index c6224df6..003235c1 100644 --- a/app/src/components/admin/Status.js +++ b/app/src/components/admin/Status.js @@ -93,6 +93,14 @@ function CPUTemperatureStatistic({temperature, high_temperature, critical_temper return } +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 +} + function IOWaitStatistic({percent_iowait, ...props}) { if (percent_iowait === null || percent_iowait === undefined) { return @@ -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} /> + @@ -332,6 +343,7 @@ export function StatusPage() { critical_temperature={critical_temperature} style={{marginRight: 0}} /> + diff --git a/controller/api/schemas.py b/controller/api/schemas.py index 4fdaf9dd..21abd626 100644 --- a/controller/api/schemas.py +++ b/controller/api/schemas.py @@ -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") # ============================================================================ diff --git a/controller/lib/status.py b/controller/lib/status.py index 8201770c..9541ea61 100644 --- a/controller/lib/status.py +++ b/controller/lib/status.py @@ -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, } diff --git a/controller/main.py b/controller/main.py index f5cde7e9..48eaa8cb 100644 --- a/controller/main.py +++ b/controller/main.py @@ -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, diff --git a/controller/templates/index.html b/controller/templates/index.html index 336e240b..5a9c192e 100644 --- a/controller/templates/index.html +++ b/controller/templates/index.html @@ -938,7 +938,7 @@

CPU

{{ cpu.percent if cpu.percent is not none else '--' }}%

-

{% if cpu.temperature_c %}{{ cpu.temperature_c }}°C{% else %}--{% endif %}

+

{% if cpu.temperature_c %}{{ cpu.temperature_c }}°C{% else %}--{% endif %}{% if cpu.fan_rpm %} · {{ cpu.fan_rpm }} RPM{% endif %}

Memory

@@ -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 + '°C' : '--'; + if (cpuSub) { + let cpuSubText = cpu.temperature ? cpu.temperature + '°C' : '--'; + if (cpu.fan_rpm) { + cpuSubText += ' · ' + cpu.fan_rpm + ' RPM'; + } + cpuSub.innerHTML = cpuSubText; + } // Update Memory card const memValue = document.querySelector('.status-card:nth-child(2) .value'); diff --git a/controller/test/test_status.py b/controller/test/test_status.py index 49725999..34f62af8 100644 --- a/controller/test/test_status.py +++ b/controller/test/test_status.py @@ -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."""