Skip to content

Commit b584f67

Browse files
author
cherry
committed
Add disk temperature sensor and DISK/TEMPERATURE theme fields
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').
1 parent 4df61d9 commit b584f67

7 files changed

Lines changed: 212 additions & 0 deletions

File tree

library/sensors/sensors.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,11 @@ def disk_used() -> int: # In bytes
118118
def disk_free() -> int: # In bytes
119119
pass
120120

121+
@staticmethod
122+
@abstractmethod
123+
def disk_temperature() -> float: # In °C
124+
pass
125+
121126

122127
class Net(ABC):
123128
@staticmethod

library/sensors/sensors_librehardwaremonitor.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,24 @@ def disk_used() -> int: # In bytes
465465
def disk_free() -> int: # In bytes
466466
return psutil.disk_usage("/").free
467467

468+
@staticmethod
469+
def disk_temperature() -> float: # In °C
470+
# LibreHardwareMonitor enumerates physical drives. Mapping a drive back
471+
# to the "/" mountpoint is not reliably available here, so the first
472+
# storage device that reports a temperature is used - correct for the
473+
# common single-drive case.
474+
try:
475+
for hardware in handle.Hardware:
476+
if hardware.HardwareType == Hardware.HardwareType.Storage:
477+
hardware.Update()
478+
for sensor in hardware.Sensors:
479+
if sensor.SensorType == Hardware.SensorType.Temperature and sensor.Value is not None:
480+
return float(sensor.Value)
481+
except:
482+
pass
483+
484+
return math.nan
485+
468486

469487
class Net(sensors.Net):
470488
# Previous psutil counters, per interface: {interface name: (monotonic timestamp, counters)}

library/sensors/sensors_python.py

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,13 @@
2121
# This file will use Python libraries (psutil, GPUtil, etc.) to get hardware sensors
2222
# For all platforms (Linux, Windows, macOS) but not all HW is supported
2323

24+
import glob
2425
import math
26+
import os
2527
import platform
28+
import re
2629
import sys
30+
import time
2731
from collections import namedtuple
2832
from enum import IntEnum, auto
2933
from typing import Tuple
@@ -119,6 +123,93 @@ def is_cpu_fan(label: str) -> bool:
119123
return ("cpu" in label.lower()) or ("proc" in label.lower())
120124

121125

126+
_disk_temp_paths = None
127+
_disk_temp_last = math.nan
128+
129+
130+
def _find_disk_temp_paths():
131+
"""hwmon temp files for the drive backing "/".
132+
133+
Only that drive's sensors are returned when it has any. Falling back to
134+
another drive would silently report the wrong disk's temperature, so the
135+
system-wide scan is used only when the root drive exposes no sensor at all.
136+
"""
137+
try:
138+
root_device = None
139+
for part in psutil.disk_partitions(all=False):
140+
if part.mountpoint == "/":
141+
root_device = part.device
142+
break
143+
144+
# /dev/sde1 -> sde ; /dev/nvme0n1p2 -> nvme0n1
145+
base = None
146+
if root_device:
147+
name = os.path.basename(root_device)
148+
m = re.match(r"^(nvme\d+n\d+)p\d+$", name) or re.match(r"^([a-zA-Z]+)\d*$", name)
149+
if m:
150+
base = m.group(1)
151+
152+
if base:
153+
own = [os.path.join(h, "temp1_input")
154+
for h in sorted(glob.glob(f"/sys/block/{base}/device/hwmon/hwmon*"))]
155+
own = [c for c in own if os.path.exists(c)]
156+
if own:
157+
return own
158+
159+
# Root drive has no sensor: fall back to any drive on the system.
160+
other = []
161+
for hwmon in sorted(glob.glob("/sys/class/hwmon/hwmon*")):
162+
try:
163+
with open(os.path.join(hwmon, "name")) as f:
164+
if f.read().strip() not in ("drivetemp", "nvme"):
165+
continue
166+
except OSError:
167+
continue
168+
candidate = os.path.join(hwmon, "temp1_input")
169+
if os.path.exists(candidate):
170+
other.append(candidate)
171+
return other
172+
except Exception:
173+
return []
174+
175+
176+
def _disk_temperature() -> float:
177+
"""Temperature (°C) of the drive backing "/".
178+
179+
SATA drives need the `drivetemp` kernel module; NVMe drives expose this
180+
natively. Some SATA SSDs (e.g. Samsung 870 EVO) refuse the underlying SMART
181+
query while busy and return EIO on most reads, so retry briefly and fall
182+
back to this drive's last good value rather than reporting nothing.
183+
"""
184+
global _disk_temp_paths, _disk_temp_last
185+
186+
if _disk_temp_paths is None:
187+
_disk_temp_paths = _find_disk_temp_paths()
188+
189+
# Do NOT retry rapidly. These drives serialise SMART queries poorly: a burst
190+
# of reads contends with itself and drives the success rate down (measured
191+
# 8/9 with a single read per cycle, 1/9 while a second reader was polling).
192+
# One read per cycle plus the cached fallback is far more reliable.
193+
# The only exception is a cold start, where there is no cache to fall back
194+
# on yet, and even then attempts are spaced a full second apart.
195+
attempts = 1 if not math.isnan(_disk_temp_last) else 3
196+
197+
for attempt in range(attempts):
198+
for path in _disk_temp_paths:
199+
try:
200+
with open(path) as f:
201+
value = int(f.read().strip()) / 1000.0
202+
_disk_temp_last = value
203+
return value
204+
except (OSError, ValueError):
205+
continue
206+
if attempt < attempts - 1:
207+
time.sleep(1.0)
208+
209+
# Every read failed this cycle: reuse this drive's last good value.
210+
return _disk_temp_last
211+
212+
122213
class Cpu(sensors.Cpu):
123214
@staticmethod
124215
def percentage(interval: float) -> float:
@@ -473,6 +564,10 @@ def disk_free() -> int: # In bytes
473564
except:
474565
return -1
475566

567+
@staticmethod
568+
def disk_temperature() -> float: # In °C
569+
return _disk_temperature()
570+
476571

477572
class Net(sensors.Net):
478573
@staticmethod

library/sensors/sensors_stub_random.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,10 @@ def disk_used() -> int: # In bytes
106106
def disk_free() -> int: # In bytes
107107
return random.randint(1000000000, 2000000000000)
108108

109+
@staticmethod
110+
def disk_temperature() -> float: # In °C
111+
return random.uniform(30, 60)
112+
109113

110114
class Net(sensors.Net):
111115
@staticmethod

library/sensors/sensors_stub_static.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,10 @@ def disk_used() -> int: # In bytes
120120
def disk_free() -> int: # In bytes
121121
return int(DISK_TOTAL_SIZE_GB / 100 * (100 - PERCENTAGE_SENSOR_VALUE)) * 1000000000
122122

123+
@staticmethod
124+
def disk_temperature() -> float: # In °C
125+
return TEMPERATURE_SENSOR_VALUE
126+
123127

124128
class Net(sensors.Net):
125129
@staticmethod

library/stats.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,7 @@ def stats(cls):
646646

647647
class Disk:
648648
last_values_disk_usage = []
649+
disk_temp_warning_shown = False
649650

650651
@classmethod
651652
def stats(cls):
@@ -681,6 +682,32 @@ def stats(cls):
681682
unit=" G"
682683
)
683684

685+
# Disk temperature. Optional: themes written before this existed have no
686+
# TEMPERATURE section, so skip quietly rather than raising KeyError.
687+
disk_temp_theme_data = disk_theme_data.get('TEMPERATURE')
688+
if disk_temp_theme_data:
689+
disk_temperature = sensors.Disk.disk_temperature()
690+
691+
disk_temp_text_data = disk_temp_theme_data.get('TEXT', {})
692+
disk_temp_radial_data = disk_temp_theme_data.get('RADIAL', {})
693+
disk_temp_graph_data = disk_temp_theme_data.get('GRAPH', {})
694+
695+
if math.isnan(disk_temperature):
696+
# Do NOT disable the fields permanently here: some SATA SSDs only
697+
# answer the SMART temperature query intermittently, so a failed
698+
# read is usually transient. Warn once and skip this cycle.
699+
if not cls.disk_temp_warning_shown and (
700+
disk_temp_text_data.get('SHOW') or disk_temp_radial_data.get('SHOW')
701+
or disk_temp_graph_data.get('SHOW')):
702+
cls.disk_temp_warning_shown = True
703+
logger.warning(
704+
"Disk temperature unavailable. On Linux, SATA drives need the "
705+
"'drivetemp' kernel module loaded: sudo modprobe drivetemp")
706+
else:
707+
display_themed_temperature_value(disk_temp_text_data, disk_temperature)
708+
display_themed_progress_bar(disk_temp_graph_data, disk_temperature)
709+
display_themed_temperature_radial_bar(disk_temp_radial_data, disk_temperature)
710+
684711

685712
class Net:
686713
last_values_wlo_upload = []

res/themes/theme_example.yaml

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1213,6 +1213,65 @@ STATS:
12131213
BACKGROUND_IMAGE: background.png
12141214
ALIGN: left # left / center / right
12151215
ANCHOR: lt # Check https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html
1216+
# Disk temperature (°C) of the drive backing "/".
1217+
# On Linux, SATA drives need the 'drivetemp' kernel module loaded
1218+
# (sudo modprobe drivetemp); NVMe drives expose it natively. On Windows it is
1219+
# read through LibreHardwareMonitor. Where no sensor is available the fields
1220+
# are left blank. This section is optional: themes without it are unaffected.
1221+
# Refreshes together with the other DISK stats (uses the DISK INTERVAL above).
1222+
TEMPERATURE:
1223+
TEXT:
1224+
SHOW: False
1225+
SHOW_UNIT: True
1226+
X: 204
1227+
Y: 460
1228+
# Text sensors may vary in size and create "ghosting" effects where old value stay displayed under the new one.
1229+
# To avoid this use one of these 2 methods (or both):
1230+
# - either use a monospaced font (fonts with "mono" in name, see res/fonts/ for available fonts)
1231+
# - 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")
1232+
# WIDTH: 200 # Uncomment to force a static width
1233+
# HEIGHT: 50 # Uncomment to force static height
1234+
FONT: jetbrains-mono/JetBrainsMono-Bold.ttf
1235+
FONT_SIZE: 23
1236+
FONT_COLOR: 255, 255, 255
1237+
# BACKGROUND_COLOR: 132, 154, 165
1238+
BACKGROUND_IMAGE: background.png
1239+
ALIGN: left # left / center / right
1240+
ANCHOR: lt # Check https://pillow.readthedocs.io/en/stable/handbook/text-anchors.html
1241+
GRAPH:
1242+
SHOW: False
1243+
X: 115
1244+
Y: 490
1245+
WIDTH: 178
1246+
HEIGHT: 13
1247+
MIN_VALUE: 0
1248+
MAX_VALUE: 100
1249+
BAR_COLOR: 255, 0, 0
1250+
BAR_OUTLINE: False
1251+
# BACKGROUND_COLOR: 0, 0, 0
1252+
BACKGROUND_IMAGE: background.png
1253+
REVERSE_DIRECTION: False
1254+
RADIAL:
1255+
SHOW: False
1256+
X: 100
1257+
Y: 510
1258+
RADIUS: 40
1259+
WIDTH: 10
1260+
MIN_VALUE: 0
1261+
MAX_VALUE: 100
1262+
ANGLE_START: 120
1263+
ANGLE_END: 60
1264+
ANGLE_STEPS: 20
1265+
ANGLE_SEP: 5
1266+
CLOCKWISE: True
1267+
BAR_COLOR: 0, 255, 0
1268+
SHOW_TEXT: True
1269+
SHOW_UNIT: True
1270+
FONT: roboto-mono/RobotoMono-Bold.ttf
1271+
FONT_SIZE: 13
1272+
FONT_COLOR: 200, 200, 200
1273+
# BACKGROUND_COLOR: 0, 0, 0
1274+
BACKGROUND_IMAGE: background.png
12161275
NET:
12171276
INTERVAL: 1
12181277
WLO:

0 commit comments

Comments
 (0)