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