mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-14 18:56:52 +00:00
identify storage temperature sensors by their actual block device (#315)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user