Files
jbod-monitor/services/host_sensors.py
adam 514502e045 host_sensors: filter IPMI margin sensors; coretemp per-core CPU fallback
- skip IPMI '*Margin' sensors (thermal margin / distance-to-throttle reads in
  degrees C but is not an absolute temperature, e.g. robin's P1 Therm Margin)
- coretemp: when a CPU has no 'Package id' sensor (older Xeons like robin's
  X3430), fall back to the hottest Core as the CPU temp
2026-06-16 19:05:10 +00:00

106 lines
3.8 KiB
Python

"""Host/chassis temperature collectors: IPMI (iDRAC) + CPU coretemp.
Used by the host-poller (separate process from the SAS poller). In-band IPMI
via ipmitool (/dev/ipmi0) gives environmental sensors (Inlet/Exhaust/…); the
kernel coretemp hwmon gives CPU package temps. IPMI is gated for serialization;
coretemp is a cheap sysfs read.
"""
import asyncio
import logging
import re
from pathlib import Path
from services.hwgate import gate
logger = logging.getLogger(__name__)
HWMON = Path("/sys/class/hwmon")
async def read_ipmi_temps() -> list[dict]:
"""`ipmitool sdr type temperature` → [{name, temperature_c, status, kind}]."""
try:
async with gate():
proc = await asyncio.create_subprocess_exec(
"ipmitool", "sdr", "type", "temperature",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, err = await proc.communicate()
except FileNotFoundError:
logger.warning("ipmitool not found")
return []
if proc.returncode != 0:
logger.warning("ipmitool failed: %s", err.decode(errors="replace").strip())
return []
return _parse_ipmi(out.decode(errors="replace"))
def _parse_ipmi(text: str) -> list[dict]:
# e.g. "Inlet Temp | 04h | ok | 7.1 | 23 degrees C"
# "Temp | 0Eh | ok | 3.1 | 40 degrees C" (CPU1)
sensors: list[dict] = []
for line in text.splitlines():
parts = [p.strip() for p in line.split("|")]
if len(parts) < 5:
continue
name, _sid, status, entity, reading = parts[:5]
# Skip relative/derived sensors (e.g. "P1 Therm Margin") — they read in
# "degrees C" but are a distance-to-throttle, not an absolute temp.
if "margin" in name.lower():
continue
m = re.search(r"(-?\d+(?:\.\d+)?)\s*degrees?\s*C", reading, re.IGNORECASE)
if not m: # "no reading", "disabled", "ns", etc.
continue
temp = float(m.group(1))
kind = "env"
# Dell reports CPU temps as "Temp" under entity 3.x — relabel them.
if name.lower() == "temp" and entity.startswith("3."):
name = f"CPU {entity.split('.')[-1]}"
kind = "cpu"
sensors.append({
"name": name,
"temperature_c": temp,
"status": status or "ok",
"kind": kind,
})
return sensors
def read_coretemp() -> list[dict]:
"""CPU package temps from coretemp hwmon → [{name, temperature_c, kind}]."""
cpus: list[dict] = []
if not HWMON.is_dir():
return cpus
for hw in sorted(HWMON.iterdir()):
try:
if (hw / "name").read_text().strip() != "coretemp":
continue
except OSError:
continue
packages: list[dict] = []
cores: list[int] = []
for label_f in sorted(hw.glob("temp*_label")):
try:
label = label_f.read_text().strip()
except OSError:
continue
input_f = label_f.with_name(label_f.name.replace("_label", "_input"))
try:
temp = round(int(input_f.read_text().strip()) / 1000)
except (OSError, ValueError):
continue
low = label.lower()
if low.startswith("package id"):
pkg = label.split("id")[-1].strip()
packages.append({"name": f"CPU {pkg}", "temperature_c": temp, "kind": "cpu"})
elif low.startswith("core"):
cores.append(temp)
# Prefer the per-package sensor; older CPUs (no package sensor) fall back
# to the hottest core as the CPU temp.
if packages:
cpus.extend(packages)
elif cores:
cpus.append({"name": "CPU", "temperature_c": max(cores), "kind": "cpu"})
return cpus