diff --git a/AppImage/components/hardware.tsx b/AppImage/components/hardware.tsx
index 6afcd8e1..d079df9d 100644
--- a/AppImage/components/hardware.tsx
+++ b/AppImage/components/hardware.tsx
@@ -15,6 +15,7 @@ import {
type GPU,
type PCIDevice,
type StorageDevice,
+ type Temperature,
type CoralTPU,
type UsbDevice,
fetcher as swrFetcher,
@@ -241,11 +242,13 @@ const translateDeviceType = (type: string | null | undefined, t: TFunction): str
return text
}
-const groupAndSortTemperatures = (temperatures: any[]) => {
+const groupAndSortTemperatures = (temperatures: Temperature[]) => {
const groups = {
CPU: [] as any[],
GPU: [] as any[],
NVME: [] as any[],
+ HDD: [] as any[],
+ SSD: [] as any[],
PCI: [] as any[],
OTHER: [] as any[],
}
@@ -254,7 +257,21 @@ const groupAndSortTemperatures = (temperatures: any[]) => {
const nameLower = temp.name.toLowerCase()
const adapterLower = temp.adapter?.toLowerCase() || ""
- if (nameLower.includes("cpu") || nameLower.includes("core") || nameLower.includes("package")) {
+ if (temp.type === "cpu") {
+ groups.CPU.push(temp)
+ } else if (temp.type === "gpu") {
+ groups.GPU.push(temp)
+ } else if (temp.type === "nvme") {
+ groups.NVME.push(temp)
+ } else if (temp.type === "hdd") {
+ groups.HDD.push(temp)
+ } else if (temp.type === "ssd") {
+ groups.SSD.push(temp)
+ } else if (temp.type === "pci") {
+ groups.PCI.push(temp)
+ } else if (temp.type) {
+ groups.OTHER.push(temp)
+ } else if (nameLower.includes("cpu") || nameLower.includes("core") || nameLower.includes("package")) {
groups.CPU.push(temp)
} else if (nameLower.includes("gpu") || adapterLower.includes("gpu")) {
groups.GPU.push(temp)
@@ -270,6 +287,58 @@ const groupAndSortTemperatures = (temperatures: any[]) => {
return groups
}
+const StorageTemperatureGroup = ({ title, temperatures }: { title: string; temperatures: Temperature[] }) => {
+ if (temperatures.length === 0) return null
+
+ return (
+
1 ? "md:col-span-2" : ""}>
+
+
+
{title}
+
+ {temperatures.length}
+
+
+
1 ? "md:grid-cols-2" : ""}`}>
+ {temperatures.map((temp, index) => {
+ const percentage = temp.critical && temp.critical > 0 ? (temp.current / temp.critical) * 100 : temp.current
+ const isHot = temp.current > (temp.high || 80)
+ const isCritical = temp.current > (temp.critical || 90)
+ const devices = temp.devices?.length ? temp.devices : temp.device ? [temp.device] : []
+
+ return (
+
+
+
+ {temp.model || temp.name}
+
+
+ {temp.current.toFixed(1)}°C
+
+
+
+ {devices.length > 0 ? (
+
+ {devices.map((device) => `/dev/${device}`).join(" · ")}
+
+ ) : (
+ temp.adapter &&
{temp.adapter}
+ )}
+
+ )
+ })}
+
+
+ )
+}
+
export default function Hardware() {
const t = useT()
@@ -834,52 +903,18 @@ export default function Hardware() {
)}
- {/* NVME Sensors */}
- {groupAndSortTemperatures(hardwareData.temperatures).NVME.length > 0 && (
- 1 ? "md:col-span-2" : ""
- }
- >
-
-
-
NVME
-
- {groupAndSortTemperatures(hardwareData.temperatures).NVME.length}
-
-
-
1 ? "md:grid-cols-2" : ""}`}
- >
- {groupAndSortTemperatures(hardwareData.temperatures).NVME.map((temp, index) => {
- const percentage =
- temp.critical > 0 ? (temp.current / temp.critical) * 100 : (temp.current / 100) * 100
- const isHot = temp.current > (temp.high || 80)
- const isCritical = temp.current > (temp.critical || 90)
-
- return (
-
-
- {temp.name}
-
- {temp.current.toFixed(1)}°C
-
-
-
- {temp.adapter &&
{temp.adapter}}
-
- )
- })}
-
-
- )}
+
+
+
{/* PCI Sensors */}
{groupAndSortTemperatures(hardwareData.temperatures).PCI.length > 0 && (
diff --git a/AppImage/scripts/build_appimage.sh b/AppImage/scripts/build_appimage.sh
index 65213e91..f2abd051 100755
--- a/AppImage/scripts/build_appimage.sh
+++ b/AppImage/scripts/build_appimage.sh
@@ -135,6 +135,7 @@ chmod +x "$APP_DIR/usr/bin/update_docker_engine.py" 2>/dev/null || true
cp "$APPIMAGE_ROOT/../json/app_tracking_hints.json" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ app_tracking_hints.json not found"
cp "$SCRIPT_DIR/flask_terminal_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_terminal_routes.py not found"
cp "$SCRIPT_DIR/hardware_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ hardware_monitor.py not found"
+cp "$SCRIPT_DIR/temperature_sensor_resolver.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ temperature_sensor_resolver.py not found"
cp "$SCRIPT_DIR/proxmox_storage_monitor.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ proxmox_storage_monitor.py not found"
cp "$SCRIPT_DIR/flask_script_runner.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_script_runner.py not found"
cp "$SCRIPT_DIR/security_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ security_manager.py not found"
diff --git a/AppImage/scripts/flask_server.py b/AppImage/scripts/flask_server.py
index 046ea35d..81deb39c 100644
--- a/AppImage/scripts/flask_server.py
+++ b/AppImage/scripts/flask_server.py
@@ -77,6 +77,7 @@ from smartctl_resolver import ( # noqa: E402
smartctl_probe_types,
smartctl_type_args,
)
+from temperature_sensor_resolver import get_storage_temperatures # noqa: E402
from flask_script_runner import script_runner
import threading
from proxmox_storage_monitor import classify_storage_state, proxmox_storage_monitor
@@ -6907,6 +6908,30 @@ def identify_temperature_sensor(sensor_name, adapter, chip_name=None):
return sensor_name
+def classify_temperature_sensor(sensor_name, adapter, chip_name=None, identified_name=None):
+ sensor_lower = sensor_name.lower()
+ adapter_lower = adapter.lower() if adapter else ""
+ chip_lower = chip_name.lower() if chip_name else ""
+ identified_lower = identified_name.lower() if identified_name else ""
+ combined = f"{sensor_lower} {adapter_lower} {chip_lower} {identified_lower}"
+
+ if "nvme" in chip_lower or "nvme" in sensor_lower or "composite" in sensor_lower or "nvme" in identified_lower:
+ return "nvme"
+ if "drivetemp" in chip_lower or "sata" in sensor_lower or "ata" in sensor_lower or "sata" in identified_lower:
+ return "storage"
+ if identified_lower.startswith("cpu") or any(
+ cpu_label in sensor_lower for cpu_label in ["cpu", "package", "tctl", "tccd", "core"]
+ ):
+ return "cpu"
+ if identified_lower.startswith("gpu") or any(
+ gpu_driver in combined for gpu_driver in ["nouveau", "amdgpu", "radeon", "i915"]
+ ):
+ return "gpu"
+ if identified_lower.startswith("pci") or ("pci" in adapter_lower and "temp" in sensor_lower):
+ return "pci"
+ return "other"
+
+
def identify_fan(sensor_name, adapter, chip_name=None):
"""Identify what a fan sensor corresponds to, using hardware_monitor for GPU detection"""
sensor_lower = sensor_name.lower()
@@ -7071,6 +7096,13 @@ def get_temperature_info():
"""Get detailed temperature information from sensors command"""
temperatures = []
power_meter = None
+ covered_storage_kinds = set()
+
+ try:
+ storage_temperatures, covered_storage_kinds = get_storage_temperatures()
+ temperatures.extend(storage_temperatures)
+ except Exception:
+ covered_storage_kinds = set()
try:
sensors_output = get_cached_sensors_output()
@@ -7119,6 +7151,13 @@ def get_temperature_info():
# Parse temperature sensors
elif '°C' in value_part or 'C' in value_part:
try:
+ chip_lower = current_chip.lower() if current_chip else ""
+ if (
+ ("nvme" in chip_lower and "nvme" in covered_storage_kinds)
+ or ("drivetemp" in chip_lower and "drivetemp" in covered_storage_kinds)
+ ):
+ continue
+
# Extract temperature value
temp_match = re.search(r'([+-]?[\d.]+)\s*°?C', value_part)
if temp_match:
@@ -7138,6 +7177,12 @@ def get_temperature_info():
continue
identified_name = identify_temperature_sensor(sensor_name, current_adapter, current_chip)
+ sensor_type = classify_temperature_sensor(
+ sensor_name,
+ current_adapter,
+ current_chip,
+ identified_name,
+ )
temperatures.append({
'name': identified_name,
@@ -7145,7 +7190,8 @@ def get_temperature_info():
'current': temp_value,
'high': high_value,
'critical': crit_value,
- 'adapter': current_adapter
+ 'adapter': current_adapter,
+ 'type': sensor_type
})
except ValueError:
pass
diff --git a/AppImage/scripts/temperature_sensor_resolver.py b/AppImage/scripts/temperature_sensor_resolver.py
new file mode 100644
index 00000000..f218dbd8
--- /dev/null
+++ b/AppImage/scripts/temperature_sensor_resolver.py
@@ -0,0 +1,231 @@
+#!/usr/bin/env python3
+
+import os
+import re
+import time
+from pathlib import Path
+
+
+_TOPOLOGY_CACHE = {
+ "key": None,
+ "time": 0.0,
+ "entries": [],
+}
+_TOPOLOGY_CACHE_TTL = 60
+
+
+def _read_text(path):
+ try:
+ return Path(path).read_text(encoding="utf-8", errors="replace").strip()
+ except (OSError, ValueError):
+ return ""
+
+
+def _read_temperature(path):
+ value = _read_text(path)
+ if not value:
+ return 0.0
+ try:
+ return float(value) / 1000.0
+ except ValueError:
+ return 0.0
+
+
+def _related_path_distance(first, second):
+ first_parts = Path(first).parts
+ second_parts = Path(second).parts
+ common = 0
+ for first_part, second_part in zip(first_parts, second_parts):
+ if first_part != second_part:
+ break
+ common += 1
+ if common == min(len(first_parts), len(second_parts)):
+ return (len(first_parts) - common) + (len(second_parts) - common)
+ return None
+
+
+def _block_devices_for_hwmon(hwmon_device, block_root):
+ exact = []
+ related = []
+ try:
+ block_entries = sorted(Path(block_root).iterdir(), key=lambda item: item.name)
+ except OSError:
+ return []
+
+ hwmon_real = os.path.realpath(hwmon_device)
+ for block_entry in block_entries:
+ block_device = block_entry / "device"
+ if not block_device.exists():
+ continue
+ block_real = os.path.realpath(block_device)
+ if block_real == hwmon_real:
+ exact.append(block_entry.name)
+ continue
+ distance = _related_path_distance(hwmon_real, block_real)
+ if distance is not None:
+ related.append((distance, block_entry.name))
+
+ if exact:
+ return exact
+ if not related:
+ return []
+
+ minimum_distance = min(distance for distance, _ in related)
+ return [name for distance, name in related if distance == minimum_distance]
+
+
+def _device_metadata(device_name, hwmon_kind, block_root):
+ block_path = Path(block_root) / device_name
+ rotational = _read_text(block_path / "queue" / "rotational")
+ if hwmon_kind == "nvme":
+ sensor_type = "nvme"
+ elif rotational == "1":
+ sensor_type = "hdd"
+ elif rotational == "0":
+ sensor_type = "ssd"
+ else:
+ sensor_type = "storage"
+
+ return {
+ "type": sensor_type,
+ "model": _read_text(block_path / "device" / "model"),
+ "serial": _read_text(block_path / "device" / "serial"),
+ }
+
+
+def _build_topology(hwmon_root, block_root):
+ entries = []
+ try:
+ hwmon_entries = sorted(Path(hwmon_root).glob("hwmon*"), key=lambda item: item.name)
+ except OSError:
+ return entries
+
+ for hwmon_path in hwmon_entries:
+ hwmon_kind = _read_text(hwmon_path / "name").lower()
+ if hwmon_kind not in {"nvme", "drivetemp"}:
+ continue
+
+ devices = _block_devices_for_hwmon(hwmon_path / "device", block_root)
+ device = devices[0] if devices else ""
+ metadata = _device_metadata(device, hwmon_kind, block_root) if device else {
+ "type": "nvme" if hwmon_kind == "nvme" else "storage",
+ "model": "",
+ "serial": "",
+ }
+ entries.append({
+ "hwmon_path": str(hwmon_path),
+ "kind": hwmon_kind,
+ "device": device,
+ "devices": devices,
+ **metadata,
+ })
+ return entries
+
+
+def _get_topology(hwmon_root, block_root, cache_ttl):
+ cache_key = (os.path.realpath(hwmon_root), os.path.realpath(block_root))
+ now = time.monotonic()
+ if (
+ _TOPOLOGY_CACHE["key"] == cache_key
+ and now - _TOPOLOGY_CACHE["time"] < cache_ttl
+ ):
+ return _TOPOLOGY_CACHE["entries"]
+
+ entries = _build_topology(hwmon_root, block_root)
+ _TOPOLOGY_CACHE.update({
+ "key": cache_key,
+ "time": now,
+ "entries": entries,
+ })
+ return entries
+
+
+def clear_temperature_topology_cache():
+ _TOPOLOGY_CACHE.update({"key": None, "time": 0.0, "entries": []})
+
+
+def _temperature_inputs(entry):
+ hwmon_path = Path(entry["hwmon_path"])
+ inputs = sorted(hwmon_path.glob("temp*_input"), key=lambda item: item.name)
+ if entry["kind"] == "drivetemp":
+ preferred = [item for item in inputs if item.name == "temp1_input"]
+ return preferred or inputs[:1]
+
+ selected = []
+ for input_path in inputs:
+ match = re.fullmatch(r"temp(\d+)_input", input_path.name)
+ if not match:
+ continue
+ index = match.group(1)
+ label = _read_text(hwmon_path / f"temp{index}_label")
+ if label.lower() == "composite" or (not label and index == "1"):
+ selected.append(input_path)
+ return selected
+
+
+def get_storage_temperatures(
+ hwmon_root="/sys/class/hwmon",
+ block_root="/sys/block",
+ cache_ttl=_TOPOLOGY_CACHE_TTL,
+):
+ temperatures = []
+ expected_by_kind = {}
+ resolved_by_kind = {}
+
+ for entry in _get_topology(hwmon_root, block_root, cache_ttl):
+ inputs = _temperature_inputs(entry)
+ if not inputs:
+ continue
+ expected_by_kind[entry["kind"]] = expected_by_kind.get(entry["kind"], 0) + 1
+
+ input_path = inputs[0]
+ match = re.fullmatch(r"temp(\d+)_input", input_path.name)
+ if not match:
+ continue
+ index = match.group(1)
+ current = _read_temperature(input_path)
+ if current == 0.0 and not _read_text(input_path):
+ # Sensor present but no readable temperature (e.g. an NVMe in
+ # low power state that leaves temp1_input empty). Mark the
+ # kind as covered anyway so the lm-sensors fallback does not
+ # add a duplicate entry for the same hwmon device.
+ resolved_by_kind[entry["kind"]] = resolved_by_kind.get(entry["kind"], 0) + 1
+ continue
+
+ sensor_type = entry["type"]
+ if sensor_type == "nvme":
+ name = "NVMe SSD"
+ adapter = "PCI adapter"
+ elif sensor_type == "hdd":
+ name = "HDD"
+ adapter = "SCSI adapter"
+ elif sensor_type == "ssd":
+ name = "SSD"
+ adapter = "SCSI adapter"
+ else:
+ name = "Storage device"
+ adapter = "SCSI adapter"
+
+ label = _read_text(Path(entry["hwmon_path"]) / f"temp{index}_label")
+ temperatures.append({
+ "name": name,
+ "original_name": label or f"temp{index}",
+ "current": current,
+ "high": _read_temperature(Path(entry["hwmon_path"]) / f"temp{index}_max"),
+ "critical": _read_temperature(Path(entry["hwmon_path"]) / f"temp{index}_crit"),
+ "adapter": adapter,
+ "type": sensor_type,
+ "device": entry["device"],
+ "devices": entry["devices"],
+ "model": entry["model"],
+ "serial": entry["serial"],
+ })
+ resolved_by_kind[entry["kind"]] = resolved_by_kind.get(entry["kind"], 0) + 1
+
+ covered_kinds = {
+ kind
+ for kind, expected in expected_by_kind.items()
+ if resolved_by_kind.get(kind, 0) == expected
+ }
+ temperatures.sort(key=lambda item: (item["type"], item["device"], item["name"]))
+ return temperatures, covered_kinds
diff --git a/AppImage/scripts/tests/test_temperature_sensor_resolver.py b/AppImage/scripts/tests/test_temperature_sensor_resolver.py
new file mode 100644
index 00000000..e4f66584
--- /dev/null
+++ b/AppImage/scripts/tests/test_temperature_sensor_resolver.py
@@ -0,0 +1,160 @@
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+
+
+SCRIPTS_DIR = Path(__file__).resolve().parents[1]
+if str(SCRIPTS_DIR) not in sys.path:
+ sys.path.insert(0, str(SCRIPTS_DIR))
+
+from temperature_sensor_resolver import ( # noqa: E402
+ clear_temperature_topology_cache,
+ get_storage_temperatures,
+)
+
+
+class TemperatureSensorResolverTests(unittest.TestCase):
+ def setUp(self):
+ clear_temperature_topology_cache()
+ self.temporary = tempfile.TemporaryDirectory()
+ self.root = Path(self.temporary.name)
+ self.hwmon_root = self.root / "sys" / "class" / "hwmon"
+ self.block_root = self.root / "sys" / "block"
+ self.devices_root = self.root / "sys" / "devices"
+ self.hwmon_root.mkdir(parents=True)
+ self.block_root.mkdir(parents=True)
+ self.devices_root.mkdir(parents=True)
+
+ def tearDown(self):
+ self.temporary.cleanup()
+
+ def _add_sensor(self, hwmon_index, kind, device_path, values):
+ hwmon_path = self.hwmon_root / f"hwmon{hwmon_index}"
+ hwmon_path.mkdir()
+ (hwmon_path / "name").write_text(kind, encoding="utf-8")
+ (hwmon_path / "device").symlink_to(device_path)
+ for filename, value in values.items():
+ (hwmon_path / filename).write_text(str(value), encoding="utf-8")
+
+ def _add_block_device(self, name, device_path, rotational, model, serial):
+ block_path = self.block_root / name
+ (block_path / "queue").mkdir(parents=True)
+ (block_path / "queue" / "rotational").write_text(str(rotational), encoding="utf-8")
+ (block_path / "device").symlink_to(device_path)
+ (device_path / "model").write_text(model, encoding="utf-8")
+ (device_path / "serial").write_text(serial, encoding="utf-8")
+
+ def test_nvme_sensor_resolves_to_its_exact_namespace(self):
+ controller = self.devices_root / "pci0000:00" / "0000:01:00.0" / "nvme" / "nvme0"
+ controller.mkdir(parents=True)
+ self._add_block_device("nvme0n1", controller, 0, "WD Red SN700", "NVME-SERIAL")
+ self._add_sensor(0, "nvme", controller, {
+ "temp1_label": "Composite",
+ "temp1_input": 17900,
+ "temp1_max": 64800,
+ "temp1_crit": 79800,
+ "temp2_label": "Sensor 1",
+ "temp2_input": 22000,
+ })
+
+ temperatures, covered = get_storage_temperatures(
+ self.hwmon_root,
+ self.block_root,
+ cache_ttl=0,
+ )
+
+ self.assertEqual(len(temperatures), 1)
+ self.assertEqual(temperatures[0]["type"], "nvme")
+ self.assertEqual(temperatures[0]["device"], "nvme0n1")
+ self.assertEqual(temperatures[0]["model"], "WD Red SN700")
+ self.assertEqual(temperatures[0]["serial"], "NVME-SERIAL")
+ self.assertEqual(temperatures[0]["current"], 17.9)
+ self.assertEqual(temperatures[0]["high"], 64.8)
+ self.assertEqual(temperatures[0]["critical"], 79.8)
+ self.assertEqual(covered, {"nvme"})
+
+ def test_multiple_nvme_controllers_keep_their_own_device_identity(self):
+ first = self.devices_root / "pci0000:00" / "0000:01:00.0" / "nvme" / "nvme0"
+ second = self.devices_root / "pci0000:00" / "0000:04:00.0" / "nvme" / "nvme1"
+ first.mkdir(parents=True)
+ second.mkdir(parents=True)
+ self._add_block_device("nvme0n1", first, 0, "NVMe One", "SERIAL-ONE")
+ self._add_block_device("nvme1n1", second, 0, "NVMe Two", "SERIAL-TWO")
+ self._add_sensor(0, "nvme", second, {
+ "temp1_label": "Composite",
+ "temp1_input": 42000,
+ })
+ self._add_sensor(1, "nvme", first, {
+ "temp1_label": "Composite",
+ "temp1_input": 37000,
+ })
+
+ temperatures, _ = get_storage_temperatures(
+ self.hwmon_root,
+ self.block_root,
+ cache_ttl=0,
+ )
+
+ by_device = {item["device"]: item for item in temperatures}
+ self.assertEqual(by_device["nvme0n1"]["model"], "NVMe One")
+ self.assertEqual(by_device["nvme0n1"]["current"], 37.0)
+ self.assertEqual(by_device["nvme1n1"]["model"], "NVMe Two")
+ self.assertEqual(by_device["nvme1n1"]["current"], 42.0)
+
+ def test_drivetemp_uses_rotational_flag_to_identify_hdd(self):
+ drive = self.devices_root / "pci0000:00" / "ata4" / "host3" / "target3:0:0" / "3:0:0:0"
+ drive.mkdir(parents=True)
+ self._add_block_device("sda", drive, 1, "ST4000VN006", "HDD-SERIAL")
+ self._add_sensor(1, "drivetemp", drive, {
+ "temp1_input": 35000,
+ "temp1_max": 60000,
+ "temp1_crit": 85000,
+ })
+
+ temperatures, covered = get_storage_temperatures(
+ self.hwmon_root,
+ self.block_root,
+ cache_ttl=0,
+ )
+
+ self.assertEqual(temperatures[0]["name"], "HDD")
+ self.assertEqual(temperatures[0]["type"], "hdd")
+ self.assertEqual(temperatures[0]["device"], "sda")
+ self.assertEqual(temperatures[0]["original_name"], "temp1")
+ self.assertEqual(covered, {"drivetemp"})
+
+ def test_drivetemp_does_not_assume_every_drive_is_rotational(self):
+ drive = self.devices_root / "pci0000:00" / "ata5" / "host4" / "target4:0:0" / "4:0:0:0"
+ drive.mkdir(parents=True)
+ self._add_block_device("sdb", drive, 0, "SATA SSD", "SSD-SERIAL")
+ self._add_sensor(2, "drivetemp", drive, {"temp1_input": 31000})
+
+ temperatures, _ = get_storage_temperatures(
+ self.hwmon_root,
+ self.block_root,
+ cache_ttl=0,
+ )
+
+ self.assertEqual(temperatures[0]["type"], "ssd")
+ self.assertEqual(temperatures[0]["device"], "sdb")
+
+ def test_topology_cache_does_not_cache_temperature_values(self):
+ controller = self.devices_root / "pci0000:00" / "0000:01:00.0" / "nvme" / "nvme0"
+ controller.mkdir(parents=True)
+ self._add_block_device("nvme0n1", controller, 0, "NVMe Live", "SERIAL-LIVE")
+ self._add_sensor(0, "nvme", controller, {
+ "temp1_label": "Composite",
+ "temp1_input": 25000,
+ })
+
+ first, _ = get_storage_temperatures(self.hwmon_root, self.block_root, cache_ttl=60)
+ (self.hwmon_root / "hwmon0" / "temp1_input").write_text("29000", encoding="utf-8")
+ second, _ = get_storage_temperatures(self.hwmon_root, self.block_root, cache_ttl=60)
+
+ self.assertEqual(first[0]["current"], 25.0)
+ self.assertEqual(second[0]["current"], 29.0)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/AppImage/types/hardware.ts b/AppImage/types/hardware.ts
index 092b1dd1..10a3af1a 100644
--- a/AppImage/types/hardware.ts
+++ b/AppImage/types/hardware.ts
@@ -7,6 +7,11 @@ export interface Temperature {
high?: number
critical?: number
adapter?: string
+ type?: "cpu" | "gpu" | "nvme" | "hdd" | "ssd" | "storage" | "pci" | "other"
+ device?: string
+ devices?: string[]
+ model?: string
+ serial?: string
}
export interface PowerMeter {
diff --git a/web/messages/en/docs/monitor/dashboard/hardware.json b/web/messages/en/docs/monitor/dashboard/hardware.json
index 11df20db..52009e57 100644
--- a/web/messages/en/docs/monitor/dashboard/hardware.json
+++ b/web/messages/en/docs/monitor/dashboard/hardware.json
@@ -33,11 +33,13 @@
"memoryTitle": "Memory Modules",
"memoryBody": "One row per populated slot from dmidecode: slot label, module size, type (DDR4 / DDR5 / ECC variants), speed (configured and rated), manufacturer, part number and serial. Empty slots are listed greyed-out so you can see the upgrade headroom at a glance.",
"thermalTitle": "Thermal Monitoring",
- "thermalIntro": "Five sub-blocks, each fed by lm-sensors + tool-specific scrapers. A block hides itself when no sensors are reported in that category.",
+ "thermalIntro": "Seven sub-blocks, fed by lm-sensors, sysfs and tool-specific scrapers. Storage hwmon sensors are correlated with the physical block device, so each detected drive includes its model and /dev/... path. A block hides itself when no sensors are reported in that category.",
"thermalItems": [
"CPU — package and per-core temperatures.",
"GPU — discrete-GPU sensors via nvidia-smi / amdgpu_top / Intel iGPU. Includes hot-spot and memory-junction when the driver exposes them.",
- "NVME — composite + per-sensor temperatures from nvme.",
+ "NVME — controller composite temperature associated with its exact NVMe namespace.",
+ "HDD — drivetemp readings for rotational block devices.",
+ "SSD — drivetemp readings for non-rotational SATA/SAS block devices.",
"PCI — sensors that surface as PCI-attached devices (HBAs, network cards with internal sensors).",
"OTHER — chipset, VRM, ambient sensors that don't fit elsewhere."
]
@@ -182,7 +184,7 @@
{
"section": "Live sensor values",
"endpoint": "/api/hardware/live",
- "source": "sensors (lm-sensors), package temperatures, fan RPM. Refreshed each request."
+ "source": "sensors (lm-sensors) plus /sys/class/hwmon and /sys/block for storage-device identity. Temperature values remain live; only the hwmon-to-device topology is cached for 60 seconds."
},
{
"section": "CPU temperature history",
diff --git a/web/messages/es/docs/monitor/dashboard/hardware.json b/web/messages/es/docs/monitor/dashboard/hardware.json
index 12ed076c..71995ba0 100644
--- a/web/messages/es/docs/monitor/dashboard/hardware.json
+++ b/web/messages/es/docs/monitor/dashboard/hardware.json
@@ -33,11 +33,13 @@
"memoryTitle": "Memory Modules",
"memoryBody": "Una fila por slot poblado desde dmidecode: etiqueta del slot, tamaño del módulo, tipo (DDR4 / DDR5 / variantes ECC), velocidad (configurada y nominal), fabricante, part number y serial. Los slots vacíos se listan atenuados para que veas de un vistazo el margen de ampliación.",
"thermalTitle": "Thermal Monitoring",
- "thermalIntro": "Cinco sub-bloques, cada uno alimentado por lm-sensors + scrapers específicos por herramienta. Un bloque se oculta cuando no hay sensores reportados en esa categoría.",
+ "thermalIntro": "Siete sub-bloques alimentados por lm-sensors, sysfs y scrapers específicos por herramienta. Los sensores hwmon de almacenamiento se correlacionan con el dispositivo de bloques físico, por lo que cada unidad detectada incluye su modelo y la ruta /dev/.... Un bloque permanece oculto cuando no hay sensores en esa categoría.",
"thermalItems": [
"CPU — temperaturas de package y por core.",
"GPU — sensores de GPU discreta vía nvidia-smi / amdgpu_top / iGPU Intel. Incluye hot-spot y memory-junction cuando el driver los expone.",
- "NVME — temperaturas composite + por sensor de nvme.",
+ "NVME — temperatura composite del controlador asociada a su namespace NVMe exacto.",
+ "HDD — lecturas de drivetemp para dispositivos de bloques rotacionales.",
+ "SSD — lecturas de drivetemp para dispositivos de bloques SATA/SAS no rotacionales.",
"PCI — sensores que aparecen como dispositivos conectados a PCI (HBAs, tarjetas de red con sensores internos).",
"OTHER — chipset, VRM, sensores ambiente que no encajan en otro sitio."
]
@@ -182,7 +184,7 @@
{
"section": "Valores de sensores en vivo",
"endpoint": "/api/hardware/live",
- "source": "sensors (lm-sensors), temperaturas de package, RPM de ventiladores. Refrescado en cada petición."
+ "source": "sensors (lm-sensors) junto con /sys/class/hwmon y /sys/block para identificar los dispositivos de almacenamiento. Las temperaturas permanecen en vivo; solo la topología hwmon-dispositivo se guarda en caché durante 60 segundos."
},
{
"section": "Historial de temperatura de CPU",