Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions library/sensors/sensors.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions library/sensors/sensors_librehardwaremonitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)}
Expand Down
95 changes: 95 additions & 0 deletions library/sensors/sensors_python.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions library/sensors/sensors_stub_random.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions library/sensors/sensors_stub_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
27 changes: 27 additions & 0 deletions library/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,6 +646,7 @@ def stats(cls):

class Disk:
last_values_disk_usage = []
disk_temp_warning_shown = False

@classmethod
def stats(cls):
Expand Down Expand Up @@ -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 = []
Expand Down
7 changes: 7 additions & 0 deletions res/themes/default.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ STATS:
FREE:
TEXT:
SHOW: False
TEMPERATURE:
TEXT:
SHOW: False
GRAPH:
SHOW: False
RADIAL:
SHOW: False
NET:
INTERVAL: 0
WLO:
Expand Down
59 changes: 59 additions & 0 deletions res/themes/theme_example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down