From e85864555c8791fd5823c71aa09b28498980ab08 Mon Sep 17 00:00:00 2001 From: jeffreyjacques <44651844+jeffreyjacques@users.noreply.github.com> Date: Sat, 5 Sep 2026 09:58:47 -0400 Subject: [PATCH] Add disk temperature sensor and DISK/TEMPERATURE theme fields MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Report the temperature (°C) of the drive backing "/" as a new stat, rendered via the existing TEXT / GRAPH / RADIAL theme widgets under STATS > DISK > TEMPERATURE. - sensors.Disk gains an abstract disk_temperature(); implemented in all backends: Python (Linux drivetemp/NVMe via sysfs hwmon, with root-drive matching and a cached last-good value for SSDs that answer SMART intermittently), LibreHardwareMonitor (Windows), and both stubs. - stats.py renders the DISK/TEMPERATURE section when present. The section is optional: themes without it are unaffected (backward compatible). - theme_example.yaml documents the new fields, defaulting to SHOW: False. Where no sensor is available the value is NaN and the fields stay blank (on Linux, SATA drives need 'sudo modprobe drivetemp'). --- library/sensors/sensors.py | 5 + .../sensors/sensors_librehardwaremonitor.py | 18 ++++ library/sensors/sensors_python.py | 95 +++++++++++++++++++ library/sensors/sensors_stub_random.py | 4 + library/sensors/sensors_stub_static.py | 4 + library/stats.py | 27 ++++++ res/themes/default.yaml | 7 ++ res/themes/theme_example.yaml | 59 ++++++++++++ 8 files changed, 219 insertions(+) diff --git a/library/sensors/sensors.py b/library/sensors/sensors.py index 5b01aa7b7..b9e990fa0 100644 --- a/library/sensors/sensors.py +++ b/library/sensors/sensors.py @@ -118,6 +118,11 @@ def disk_used() -> int: # In bytes def disk_free() -> int: # In bytes pass + @staticmethod + @abstractmethod + def disk_temperature() -> float: # In °C + pass + class Net(ABC): @staticmethod diff --git a/library/sensors/sensors_librehardwaremonitor.py b/library/sensors/sensors_librehardwaremonitor.py index 473059c75..72ebf4f12 100644 --- a/library/sensors/sensors_librehardwaremonitor.py +++ b/library/sensors/sensors_librehardwaremonitor.py @@ -465,6 +465,24 @@ def disk_used() -> int: # In bytes def disk_free() -> int: # In bytes return psutil.disk_usage("/").free + @staticmethod + def disk_temperature() -> float: # In °C + # LibreHardwareMonitor enumerates physical drives. Mapping a drive back + # to the "/" mountpoint is not reliably available here, so the first + # storage device that reports a temperature is used - correct for the + # common single-drive case. + try: + for hardware in handle.Hardware: + if hardware.HardwareType == Hardware.HardwareType.Storage: + hardware.Update() + for sensor in hardware.Sensors: + if sensor.SensorType == Hardware.SensorType.Temperature and sensor.Value is not None: + return float(sensor.Value) + except: + pass + + return math.nan + class Net(sensors.Net): # Previous psutil counters, per interface: {interface name: (monotonic timestamp, counters)} diff --git a/library/sensors/sensors_python.py b/library/sensors/sensors_python.py index fc76af1bd..179b0d6d0 100644 --- a/library/sensors/sensors_python.py +++ b/library/sensors/sensors_python.py @@ -21,9 +21,13 @@ # This file will use Python libraries (psutil, GPUtil, etc.) to get hardware sensors # For all platforms (Linux, Windows, macOS) but not all HW is supported +import glob import math +import os import platform +import re import sys +import time from collections import namedtuple from enum import IntEnum, auto from typing import Tuple @@ -119,6 +123,93 @@ def is_cpu_fan(label: str) -> bool: return ("cpu" in label.lower()) or ("proc" in label.lower()) +_disk_temp_paths = None +_disk_temp_last = math.nan + + +def _find_disk_temp_paths(): + """hwmon temp files for the drive backing "/". + + Only that drive's sensors are returned when it has any. Falling back to + another drive would silently report the wrong disk's temperature, so the + system-wide scan is used only when the root drive exposes no sensor at all. + """ + try: + root_device = None + for part in psutil.disk_partitions(all=False): + if part.mountpoint == "/": + root_device = part.device + break + + # /dev/sde1 -> sde ; /dev/nvme0n1p2 -> nvme0n1 + base = None + if root_device: + name = os.path.basename(root_device) + m = re.match(r"^(nvme\d+n\d+)p\d+$", name) or re.match(r"^([a-zA-Z]+)\d*$", name) + if m: + base = m.group(1) + + if base: + own = [os.path.join(h, "temp1_input") + for h in sorted(glob.glob(f"/sys/block/{base}/device/hwmon/hwmon*"))] + own = [c for c in own if os.path.exists(c)] + if own: + return own + + # Root drive has no sensor: fall back to any drive on the system. + other = [] + for hwmon in sorted(glob.glob("/sys/class/hwmon/hwmon*")): + try: + with open(os.path.join(hwmon, "name")) as f: + if f.read().strip() not in ("drivetemp", "nvme"): + continue + except OSError: + continue + candidate = os.path.join(hwmon, "temp1_input") + if os.path.exists(candidate): + other.append(candidate) + return other + except Exception: + return [] + + +def _disk_temperature() -> float: + """Temperature (°C) of the drive backing "/". + + SATA drives need the `drivetemp` kernel module; NVMe drives expose this + natively. Some SATA SSDs (e.g. Samsung 870 EVO) refuse the underlying SMART + query while busy and return EIO on most reads, so retry briefly and fall + back to this drive's last good value rather than reporting nothing. + """ + global _disk_temp_paths, _disk_temp_last + + if _disk_temp_paths is None: + _disk_temp_paths = _find_disk_temp_paths() + + # Do NOT retry rapidly. These drives serialise SMART queries poorly: a burst + # of reads contends with itself and drives the success rate down (measured + # 8/9 with a single read per cycle, 1/9 while a second reader was polling). + # One read per cycle plus the cached fallback is far more reliable. + # The only exception is a cold start, where there is no cache to fall back + # on yet, and even then attempts are spaced a full second apart. + attempts = 1 if not math.isnan(_disk_temp_last) else 3 + + for attempt in range(attempts): + for path in _disk_temp_paths: + try: + with open(path) as f: + value = int(f.read().strip()) / 1000.0 + _disk_temp_last = value + return value + except (OSError, ValueError): + continue + if attempt < attempts - 1: + time.sleep(1.0) + + # Every read failed this cycle: reuse this drive's last good value. + return _disk_temp_last + + class Cpu(sensors.Cpu): @staticmethod def percentage(interval: float) -> float: @@ -473,6 +564,10 @@ def disk_free() -> int: # In bytes except: return -1 + @staticmethod + def disk_temperature() -> float: # In °C + return _disk_temperature() + class Net(sensors.Net): @staticmethod diff --git a/library/sensors/sensors_stub_random.py b/library/sensors/sensors_stub_random.py index b5c804f36..18a75f0c0 100644 --- a/library/sensors/sensors_stub_random.py +++ b/library/sensors/sensors_stub_random.py @@ -106,6 +106,10 @@ def disk_used() -> int: # In bytes def disk_free() -> int: # In bytes return random.randint(1000000000, 2000000000000) + @staticmethod + def disk_temperature() -> float: # In °C + return random.uniform(30, 60) + class Net(sensors.Net): @staticmethod diff --git a/library/sensors/sensors_stub_static.py b/library/sensors/sensors_stub_static.py index 3ad96a620..b3b278173 100644 --- a/library/sensors/sensors_stub_static.py +++ b/library/sensors/sensors_stub_static.py @@ -120,6 +120,10 @@ def disk_used() -> int: # In bytes def disk_free() -> int: # In bytes return int(DISK_TOTAL_SIZE_GB / 100 * (100 - PERCENTAGE_SENSOR_VALUE)) * 1000000000 + @staticmethod + def disk_temperature() -> float: # In °C + return TEMPERATURE_SENSOR_VALUE + class Net(sensors.Net): @staticmethod diff --git a/library/stats.py b/library/stats.py index 9abdc6f48..12272a552 100644 --- a/library/stats.py +++ b/library/stats.py @@ -646,6 +646,7 @@ def stats(cls): class Disk: last_values_disk_usage = [] + disk_temp_warning_shown = False @classmethod def stats(cls): @@ -681,6 +682,32 @@ def stats(cls): unit=" G" ) + # Disk temperature. Optional: themes written before this existed have no + # TEMPERATURE section, so skip quietly rather than raising KeyError. + disk_temp_theme_data = disk_theme_data.get('TEMPERATURE') + if disk_temp_theme_data: + disk_temperature = sensors.Disk.disk_temperature() + + disk_temp_text_data = disk_temp_theme_data.get('TEXT', {}) + disk_temp_radial_data = disk_temp_theme_data.get('RADIAL', {}) + disk_temp_graph_data = disk_temp_theme_data.get('GRAPH', {}) + + if math.isnan(disk_temperature): + # Do NOT disable the fields permanently here: some SATA SSDs only + # answer the SMART temperature query intermittently, so a failed + # read is usually transient. Warn once and skip this cycle. + if not cls.disk_temp_warning_shown and ( + disk_temp_text_data.get('SHOW') or disk_temp_radial_data.get('SHOW') + or disk_temp_graph_data.get('SHOW')): + cls.disk_temp_warning_shown = True + logger.warning( + "Disk temperature unavailable. On Linux, SATA drives need the " + "'drivetemp' kernel module loaded: sudo modprobe drivetemp") + else: + display_themed_temperature_value(disk_temp_text_data, disk_temperature) + display_themed_progress_bar(disk_temp_graph_data, disk_temperature) + display_themed_temperature_radial_bar(disk_temp_radial_data, disk_temperature) + class Net: last_values_wlo_upload = [] diff --git a/res/themes/default.yaml b/res/themes/default.yaml index a915bcb67..cb9397330 100644 --- a/res/themes/default.yaml +++ b/res/themes/default.yaml @@ -165,6 +165,13 @@ STATS: FREE: TEXT: SHOW: False + TEMPERATURE: + TEXT: + SHOW: False + GRAPH: + SHOW: False + RADIAL: + SHOW: False NET: INTERVAL: 0 WLO: diff --git a/res/themes/theme_example.yaml b/res/themes/theme_example.yaml index e249ada7a..0126966ff 100644 --- a/res/themes/theme_example.yaml +++ b/res/themes/theme_example.yaml @@ -1213,6 +1213,65 @@ STATS: BACKGROUND_IMAGE: background.png ALIGN: left # left / center / right ANCHOR: lt # Check https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html + # Disk temperature (°C) of the drive backing "/". + # On Linux, SATA drives need the 'drivetemp' kernel module loaded + # (sudo modprobe drivetemp); NVMe drives expose it natively. On Windows it is + # read through LibreHardwareMonitor. Where no sensor is available the fields + # are left blank. This section is optional: themes without it are unaffected. + # Refreshes together with the other DISK stats (uses the DISK INTERVAL above). + TEMPERATURE: + TEXT: + SHOW: False + SHOW_UNIT: True + X: 204 + Y: 460 + # Text sensors may vary in size and create "ghosting" effects where old value stay displayed under the new one. + # To avoid this use one of these 2 methods (or both): + # - either use a monospaced font (fonts with "mono" in name, see res/fonts/ for available fonts) + # - or force a static width/height for the text field. Be sure to have enough space for the longest value that can be displayed (e.g. "100°C") + # WIDTH: 200 # Uncomment to force a static width + # HEIGHT: 50 # Uncomment to force static height + FONT: jetbrains-mono/JetBrainsMono-Bold.ttf + FONT_SIZE: 23 + FONT_COLOR: 255, 255, 255 + # BACKGROUND_COLOR: 132, 154, 165 + BACKGROUND_IMAGE: background.png + ALIGN: left # left / center / right + ANCHOR: lt # Check https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html + GRAPH: + SHOW: False + X: 115 + Y: 490 + WIDTH: 178 + HEIGHT: 13 + MIN_VALUE: 0 + MAX_VALUE: 100 + BAR_COLOR: 255, 0, 0 + BAR_OUTLINE: False + # BACKGROUND_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png + REVERSE_DIRECTION: False + RADIAL: + SHOW: False + X: 100 + Y: 510 + RADIUS: 40 + WIDTH: 10 + MIN_VALUE: 0 + MAX_VALUE: 100 + ANGLE_START: 120 + ANGLE_END: 60 + ANGLE_STEPS: 20 + ANGLE_SEP: 5 + CLOCKWISE: True + BAR_COLOR: 0, 255, 0 + SHOW_TEXT: True + SHOW_UNIT: True + FONT: roboto-mono/RobotoMono-Bold.ttf + FONT_SIZE: 13 + FONT_COLOR: 200, 200, 200 + # BACKGROUND_COLOR: 0, 0, 0 + BACKGROUND_IMAGE: background.png NET: INTERVAL: 1 WLO: