Compare commits

...

7 Commits

Author SHA1 Message Date
d7f2ae69ca k8s: exclude passthrough source keys from jbod-mqtt secret
excludeRaw alone left all home_assistant source keys (api_key, vantage_*, etc.)
in the synced secret, so envFrom injected them into the web container. Add
transformation.excludes ['.*'] so only the templated MQTT_USERNAME/MQTT_PASSWORD
remain.
2026-06-16 20:09:19 +00:00
0f0bdf4f6c k8s: add registry-cred VSS + imagePullSecrets after prereq check
Prereqs verified on the cluster: VSO 1.3.0 (transformation supported,
openbao-kubernetes VaultAuth healthy), MQTT egress to 10.5.30.3:1883 reachable
from a pod, target nodes untainted, registry requires auth. Add
registry-cred-vaultstaticsecret.yaml (secret/registry/nexus -> dockerconfigjson,
mirroring existing cluster pattern) and wire imagePullSecrets into the DaemonSet.
README updated with verified prereqs + apply order.
2026-06-16 19:56:01 +00:00
5032c58f7d host_sensors: read extra hwmon chips (dell_smm) as env sensors
Boxes without IPMI (Dell workstations like dev-linux) expose CPU/ambient/
SODIMM/board temps via the BIOS SMM interface (dell_smm hwmon). Add
read_hwmon_env(): labelled temps from chips in HOST_HWMON_CHIPS (default
'dell_smm') as env sensors, named '<chip> <label>' with index suffix for
repeated labels. Merged into the host-poller env sweep. No-op on hosts without
the configured chips.
2026-06-16 19:46:39 +00:00
4c68bff964 Add k8s DaemonSet design for RKE2 nodes (Model A, per-node self-contained)
Design artifact (NOT applied). Per-node pod = host-poller + redis + web sharing
pod localhost; only host-poller privileged with hostPath /dev+/sys. No SAS
poller (no enclosures). MQTT_NODE_ID from spec.nodeName; MQTT creds via VSO
VaultStaticSecret reading secret/home_assistant (mqtt_user/pass -> MQTT_USERNAME/
PASSWORD). nodeSelector jbod-monitor=enabled to pin to pascal/currie/fermi.
2026-06-16 19:41:51 +00:00
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
ea045433e5 Split host-poller into separate env (IPMI/CPU) and drive (SMART) loops
Two concurrent loops in one process sharing the lock + hardware gate (IPMI and
SMART never run concurrently) but on separate schedules: env every
HOST_ENV_INTERVAL (60s, responsive inlet/CPU) and drives every
HOST_DRIVE_INTERVAL (300s, gentle SMART). Each loop has its own heartbeat
(env_meta/drive_meta) + restart-storm guard.
2026-06-16 18:50:16 +00:00
822829aae4 host-poller: poll chassis sensors every 60s (inlet temp 1/min) 2026-06-16 18:45:33 +00:00
8 changed files with 358 additions and 65 deletions

View File

@@ -52,7 +52,8 @@ services:
- REDIS_HOST=127.0.0.1 - REDIS_HOST=127.0.0.1
- REDIS_PORT=6379 - REDIS_PORT=6379
- REDIS_DB=0 - REDIS_DB=0
- HOST_POLL_INTERVAL=120 - HOST_ENV_INTERVAL=60 # IPMI/iDRAC + CPU — responsive (inlet temp 1/min)
- HOST_DRIVE_INTERVAL=300 # on-metal SMART — gentle, isolated from env
- POLL_DRIVE_GAP=0.5 - POLL_DRIVE_GAP=0.5
- POLL_CONCURRENCY=1 - POLL_CONCURRENCY=1
- POLL_STARTUP_JITTER=10 - POLL_STARTUP_JITTER=10

View File

@@ -1,12 +1,16 @@
"""Host-poller — server chassis temps (IPMI/iDRAC + CPU) and on-metal drives. """Host-poller — server chassis temps, split into two independent loops.
Separate process/container from the SAS poller: this owns the chassis Separate process/container from the SAS poller. Two concurrent loops in one
subsystem (in-band IPMI, CPU coretemp, PERC/onboard drives), which is process, sharing the single-instance lock AND the per-process hardware gate
independent of the external SAS shelves and shouldn't share their hardware (so IPMI and SMART never hit hardware at the same instant), but on separate
gate's bus. Writes to Redis; the web/MQTT consumer publishes these on the hub schedules:
device. Own single-instance lock + heartbeat; paced via the per-process gate.
Host-drive ZFS annotation reuses jbod:zfs_map written by the SAS poller. - env loop (fast): IPMI/iDRAC env + CPU coretemp -> jbod:host_sensors
- drive loop (slow): on-metal drive SMART -> jbod:host_drives
Keeping SMART on a gentler cadence than the responsive inlet/CPU reads. Each
loop has its own heartbeat + restart-storm guard. Host-drive ZFS annotation
reuses jbod:zfs_map written by the SAS poller.
""" """
import asyncio import asyncio
import logging import logging
@@ -18,7 +22,7 @@ import time
from services import store from services import store
from services.cache import close_cache, init_cache, redis_available from services.cache import close_cache, init_cache, redis_available
from services.host import get_host_drives from services.host import get_host_drives
from services.host_sensors import read_coretemp, read_ipmi_temps from services.host_sensors import read_coretemp, read_hwmon_env, read_ipmi_temps
logging.basicConfig( logging.basicConfig(
level=logging.INFO, level=logging.INFO,
@@ -26,25 +30,24 @@ logging.basicConfig(
) )
logger = logging.getLogger("host-poller") logger = logging.getLogger("host-poller")
SWEEP_INTERVAL = int(os.environ.get("HOST_POLL_INTERVAL", "120")) ENV_INTERVAL = int(os.environ.get("HOST_ENV_INTERVAL", "60"))
DRIVE_INTERVAL = int(os.environ.get("HOST_DRIVE_INTERVAL", "300"))
STARTUP_JITTER = float(os.environ.get("POLL_STARTUP_JITTER", "10")) STARTUP_JITTER = float(os.environ.get("POLL_STARTUP_JITTER", "10"))
LOCK_TTL = 30 LOCK_TTL = 30
TOKEN = f"{socket.gethostname()}-hostpoll-{os.getpid()}" TOKEN = f"{socket.gethostname()}-hostpoll-{os.getpid()}"
async def sweep() -> None: async def sweep_env() -> None:
"""IPMI/iDRAC environmental + CPU temps (the responsive loop)."""
t0 = time.monotonic() t0 = time.monotonic()
errors: list[str] = [] errors: list[str] = []
# 1. IPMI (iDRAC) environmental + CPU temps.
try: try:
ipmi = await read_ipmi_temps() ipmi = await read_ipmi_temps()
except Exception as e: except Exception as e:
errors.append(f"ipmi:{e}") errors.append(f"ipmi:{e}")
ipmi = [] ipmi = []
env = [s for s in ipmi if s.get("kind") == "env"] env = [s for s in ipmi if s.get("kind") == "env"] + read_hwmon_env()
# Prefer kernel coretemp for CPU package temps; fall back to IPMI CPU temps.
cpu = read_coretemp() or [s for s in ipmi if s.get("kind") == "cpu"] cpu = read_coretemp() or [s for s in ipmi if s.get("kind") == "cpu"]
await store.set_host_sensors({ await store.set_host_sensors({
@@ -53,30 +56,57 @@ async def sweep() -> None:
"cpu": cpu, "cpu": cpu,
"ts": store.now(), "ts": store.now(),
}) })
# 2. On-metal (non-enclosure) drives — annotated from the SAS poller's zfs map.
try:
pool_map = await store.get_zfs_map()
host_drives = await get_host_drives(pool_map)
await store.set_host_drives(host_drives)
except Exception as e:
errors.append(f"hostdrives:{e}")
host_drives = []
duration = round(time.monotonic() - t0, 1)
await store.set_meta({ await store.set_meta({
"last_sweep_ts": store.now(), "last_sweep_ts": store.now(),
"last_sweep_duration": duration, "last_sweep_duration": round(time.monotonic() - t0, 1),
"env_count": len(env), "env_count": len(env),
"cpu_count": len(cpu), "cpu_count": len(cpu),
"host_drive_count": len(host_drives),
"errors": errors[:20], "errors": errors[:20],
"sweep_interval": SWEEP_INTERVAL, "interval": ENV_INTERVAL,
}, key=store.K_HOST_META) }, key=store.K_HOST_ENV_META)
logger.info( logger.info("env sweep: %d env, %d cpu, %d error(s)", len(env), len(cpu), len(errors))
"host sweep done: %d env, %d cpu, %d drives, %.1fs, %d error(s)",
len(env), len(cpu), len(host_drives), duration, len(errors),
) async def sweep_drives() -> None:
"""On-metal (non-enclosure) drive SMART (the gentle loop)."""
t0 = time.monotonic()
errors: list[str] = []
drives: list = []
try:
pool_map = await store.get_zfs_map()
drives = await get_host_drives(pool_map)
await store.set_host_drives(drives)
except Exception as e:
errors.append(f"hostdrives:{e}")
await store.set_meta({
"last_sweep_ts": store.now(),
"last_sweep_duration": round(time.monotonic() - t0, 1),
"host_drive_count": len(drives),
"errors": errors[:20],
"interval": DRIVE_INTERVAL,
}, key=store.K_HOST_DRIVE_META)
logger.info("drive sweep: %d drives, %.1fs, %d error(s)",
len(drives), round(time.monotonic() - t0, 1), len(errors))
async def run_loop(name: str, fn, interval: int, meta_key: str) -> None:
"""Run `fn` every `interval`s, with a restart-storm guard from its meta."""
meta = await store.get_meta(key=meta_key)
if meta and meta.get("last_sweep_ts"):
since = store.now() - meta["last_sweep_ts"]
if 0 <= since < interval:
wait = interval - since
logger.info("%s: last sweep %.0fs ago — deferring first %.0fs", name, since, wait)
await asyncio.sleep(wait)
while True:
t0 = time.monotonic()
try:
await fn()
except asyncio.CancelledError:
raise
except Exception as e:
logger.error("%s loop error: %s", name, e)
await asyncio.sleep(max(5, interval - (time.monotonic() - t0)))
async def lock_refresher() -> None: async def lock_refresher() -> None:
@@ -117,30 +147,17 @@ async def main() -> None:
if os.geteuid() != 0: if os.geteuid() != 0:
logger.warning("not running as root — ipmitool/smartctl may fail") logger.warning("not running as root — ipmitool/smartctl may fail")
jitter = random.uniform(0, STARTUP_JITTER) await asyncio.sleep(random.uniform(0, STARTUP_JITTER))
logger.info("startup jitter %.1fs", jitter)
await asyncio.sleep(jitter)
# Restart-storm guard (mirrors the SAS poller).
meta = await store.get_meta(key=store.K_HOST_META)
if meta and meta.get("last_sweep_ts"):
since = store.now() - meta["last_sweep_ts"]
if 0 <= since < SWEEP_INTERVAL:
wait = SWEEP_INTERVAL - since
logger.info("last host sweep %.0fs ago — deferring first sweep %.0fs", since, wait)
await asyncio.sleep(wait)
env_task = asyncio.create_task(
run_loop("env", sweep_env, ENV_INTERVAL, store.K_HOST_ENV_META))
drive_task = asyncio.create_task(
run_loop("drive", sweep_drives, DRIVE_INTERVAL, store.K_HOST_DRIVE_META))
try: try:
while True: await asyncio.gather(env_task, drive_task)
t0 = time.monotonic()
try:
await sweep()
except Exception as e:
logger.error("host sweep error: %s", e)
elapsed = time.monotonic() - t0
await asyncio.sleep(max(5, SWEEP_INTERVAL - elapsed))
finally: finally:
refresher.cancel() for t in (refresher, env_task, drive_task):
t.cancel()
await close_cache() await close_cache()

66
k8s/README.md Normal file
View File

@@ -0,0 +1,66 @@
# jbod-monitor on RKE2 (DaemonSet)
Per-node self-contained deployment for Kubernetes worker nodes that can't run
the docker-compose stack. Each opted-in node runs one pod (`host-poller` +
`redis` + `web`) and publishes its own Home Assistant device
(`JBOD Monitor (<node>)`) over MQTT.
This mirrors the docker-compose stack with **no code changes** — each node is
independent, exactly like the standalone hosts.
## What it collects
- **host-poller** (privileged, hostPath `/dev`+`/sys`): IPMI/iDRAC env, CPU
coretemp, on-metal drive SMART → node-local redis.
- **web**: reads redis, publishes per-node MQTT discovery to the broker.
- No SAS poller (no SES enclosures on these nodes). Consequence: host-drive
entities have no ZFS pool label (no zfs_map producer).
## Prereqs — verified 2026-06-16
1. **Registry pull**: registry requires auth. `registry-cred-vaultstaticsecret.yaml`
syncs `secret/registry/nexus``docker-registry-cred` (dockerconfigjson),
referenced as `imagePullSecrets` in the DaemonSet. (Mirrors the cluster's
existing `*-registry-cred` pattern.)
2. **VSO**: v1.3.0; `openbao-kubernetes` VaultAuth healthy; `transformation.templates`
supported. No existing VSS in the cluster uses transformation, so watch the
`jbod-mqtt` VSS status on first apply for template errors.
3. **MQTT egress**: confirmed — a cluster pod reached `10.5.30.3:1883`.
4. **Nodes**: pascal/currie/fermi are untainted → no tolerations needed.
## Apply order
```bash
# 1. Label ONLY the intended nodes (do not blanket-label all workers):
kubectl label node usco-dc-pascal usco-dc-currie usco-dc-fermi jbod-monitor=enabled
# (canary: label just usco-dc-fermi first)
# 2. Namespace + DaemonSet:
kubectl apply -f k8s/daemonset.yaml
# 3. Secrets (VSO → docker-registry-cred + jbod-mqtt). Apply, then confirm sync:
kubectl apply -f k8s/registry-cred-vaultstaticsecret.yaml
kubectl apply -f k8s/mqtt-vaultstaticsecret.yaml
kubectl -n jbod-monitor get vaultstaticsecret # SYNCED=True for both
kubectl -n jbod-monitor get secret jbod-mqtt -o jsonpath='{.data.MQTT_USERNAME}' | base64 -d
# 4. Roll the DaemonSet so pods pick up the secrets if they raced ahead:
kubectl -n jbod-monitor rollout restart daemonset/jbod-monitor
```
## Verify
```bash
kubectl -n jbod-monitor get pods -o wide # one per labeled node
kubectl -n jbod-monitor logs <pod> -c host-poller | grep sweep
kubectl -n jbod-monitor logs <pod> -c web | grep -i mqtt # "MQTT connected"
```
Then check Home Assistant for `JBOD Monitor (usco-dc-pascal|currie|fermi)`.
## Notes
- pascal (R620) already has an iDRAC "System Board Inlet Temp" in HA via another
integration — its env temp will partially duplicate; drives + CPU are net-new.
- Pin image tags by SHA (already done: host-poller `514502e`, web `1839e9d`).
- Removal: `kubectl delete -f k8s/daemonset.yaml` and unlabel the nodes.

103
k8s/daemonset.yaml Normal file
View File

@@ -0,0 +1,103 @@
# jbod-monitor on RKE2 nodes — per-node self-contained DaemonSet (Model A).
#
# Each scheduled node runs one pod = host-poller + redis + web, sharing the
# pod's localhost (no hostNetwork needed). Only host-poller is privileged and
# mounts /dev + /sys to read IPMI / SMART / coretemp. web egresses to the MQTT
# broker and publishes a per-node HA device (MQTT_NODE_ID = spec.nodeName).
#
# No SAS poller (these nodes have no SES enclosures). NOTE: without it there is
# no zfs_map, so host-drive entities won't carry a ZFS pool label.
#
# Target nodes are chosen by label — see k8s/README.md (label only
# pascal/currie/fermi). Apply order: namespace -> VaultStaticSecret -> this.
---
apiVersion: v1
kind: Namespace
metadata:
name: jbod-monitor
---
apiVersion: apps/v1
kind: DaemonSet
metadata:
name: jbod-monitor
namespace: jbod-monitor
labels:
app: jbod-monitor
spec:
selector:
matchLabels:
app: jbod-monitor
template:
metadata:
labels:
app: jbod-monitor
spec:
# Only nodes explicitly opted in (see README: kubectl label node ...).
nodeSelector:
jbod-monitor: "enabled"
imagePullSecrets:
- name: docker-registry-cred # synced by registry-cred-vaultstaticsecret.yaml
containers:
- name: redis
image: redis:7-alpine
args: ["redis-server", "--save", "60", "1", "--loglevel", "warning"]
volumeMounts:
- name: redis-data
mountPath: /data
resources:
requests: {cpu: 25m, memory: 32Mi}
limits: {memory: 128Mi}
- name: host-poller
image: docker.adamksmith.xyz/jbod-host-poller:514502e
securityContext:
privileged: true # raw /dev access for ipmitool + smartctl
env:
- {name: TZ, value: "America/Denver"}
- {name: REDIS_HOST, value: "127.0.0.1"}
- {name: REDIS_PORT, value: "6379"}
- {name: HOST_ENV_INTERVAL, value: "60"}
- {name: HOST_DRIVE_INTERVAL, value: "300"}
- {name: POLL_CONCURRENCY, value: "1"}
- {name: POLL_DRIVE_GAP, value: "0.5"}
- {name: POLL_STARTUP_JITTER, value: "10"}
- {name: POLL_DATA_TTL, value: "900"}
volumeMounts:
- {name: dev, mountPath: /dev}
- {name: sys, mountPath: /sys, readOnly: true}
- {name: udev, mountPath: /run/udev, readOnly: true}
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {memory: 256Mi}
- name: web
image: docker.adamksmith.xyz/jbod-web:1839e9d
env:
- {name: TZ, value: "America/Denver"}
- {name: REDIS_HOST, value: "127.0.0.1"}
- {name: REDIS_PORT, value: "6379"}
- {name: MQTT_HOST, value: "10.5.30.3"}
- {name: MQTT_PORT, value: "1883"}
- {name: MQTT_DISCOVERY_PREFIX, value: "homeassistant"}
- {name: MQTT_BASE_TOPIC, value: "jbod-monitor"}
- {name: MQTT_PUBLISH_INTERVAL, value: "60"}
- name: MQTT_NODE_ID
valueFrom:
fieldRef:
fieldPath: spec.nodeName
envFrom:
- secretRef:
name: jbod-mqtt # MQTT_USERNAME / MQTT_PASSWORD (see VaultStaticSecret)
resources:
requests: {cpu: 25m, memory: 64Mi}
limits: {memory: 256Mi}
volumes:
- name: redis-data
emptyDir: {}
- name: dev
hostPath: {path: /dev}
- name: sys
hostPath: {path: /sys}
- name: udev
hostPath: {path: /run/udev}

View File

@@ -0,0 +1,35 @@
# MQTT broker creds for the web container, synced from OpenBao by VSO.
#
# Reads secret/home_assistant (keys mqtt_user / mqtt_pass) and renders a k8s
# Secret `jbod-mqtt` with MQTT_USERNAME / MQTT_PASSWORD keys (consumed via
# envFrom in the DaemonSet). VSO's `vso-read` policy can read secret/data/*,
# so unlike the claude-read token it CAN read home_assistant — no creds ever
# touch these manifests.
#
# Verify the transformation syntax against the installed VSO version.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: jbod-mqtt
namespace: jbod-monitor
spec:
vaultAuthRef: vault-secrets-operator-system/openbao-kubernetes
mount: secret
type: kv-v2
path: home_assistant
refreshAfter: 1h
destination:
name: jbod-mqtt
create: true
transformation:
excludeRaw: true
# Drop ALL passthrough source keys (api_key, vantage_*, etc.) — keep only
# the templated MQTT_USERNAME/MQTT_PASSWORD below. Without this, envFrom
# would inject the entire home_assistant secret into the web container.
excludes:
- ".*"
templates:
MQTT_USERNAME:
text: '{{ .Secrets.mqtt_user }}'
MQTT_PASSWORD:
text: '{{ .Secrets.mqtt_pass }}'

View File

@@ -0,0 +1,18 @@
# Image pull secret for docker.adamksmith.xyz, synced by VSO from OpenBao.
# Mirrors the cluster's existing *-registry-cred pattern (secret/registry/nexus
# -> dockerconfigjson). Referenced as imagePullSecrets in the DaemonSet.
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: docker-registry-cred
namespace: jbod-monitor
spec:
vaultAuthRef: vault-secrets-operator-system/openbao-kubernetes
mount: secret
path: registry/nexus
type: kv-v2
refreshAfter: 1h
destination:
name: docker-registry-cred
create: true
type: kubernetes.io/dockerconfigjson

View File

@@ -7,7 +7,9 @@ coretemp is a cheap sysfs read.
""" """
import asyncio import asyncio
import logging import logging
import os
import re import re
from collections import Counter
from pathlib import Path from pathlib import Path
from services.hwgate import gate from services.hwgate import gate
@@ -45,6 +47,10 @@ def _parse_ipmi(text: str) -> list[dict]:
if len(parts) < 5: if len(parts) < 5:
continue continue
name, _sid, status, entity, reading = parts[:5] 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) m = re.search(r"(-?\d+(?:\.\d+)?)\s*degrees?\s*C", reading, re.IGNORECASE)
if not m: # "no reading", "disabled", "ns", etc. if not m: # "no reading", "disabled", "ns", etc.
continue continue
@@ -63,6 +69,46 @@ def _parse_ipmi(text: str) -> list[dict]:
return sensors return sensors
def read_hwmon_env() -> list[dict]:
"""Read labelled temps from extra hwmon chips as env sensors.
For boxes without IPMI (e.g. Dell workstations), the BIOS SMM interface
(`dell_smm`) exposes CPU/ambient/SODIMM/board temps. Chips are configurable
via HOST_HWMON_CHIPS (comma list; default "dell_smm"); set to "" to disable.
Names are "<chip> <label>", with the temp index appended when a label
repeats within a chip (e.g. dell_smm's multiple "Other" sensors).
"""
chips = [c.strip() for c in os.environ.get("HOST_HWMON_CHIPS", "dell_smm").split(",") if c.strip()]
if not chips or not HWMON.is_dir():
return []
out: list[dict] = []
for hw in sorted(HWMON.iterdir()):
try:
chip = (hw / "name").read_text().strip()
except OSError:
continue
if chip not in chips:
continue
entries: list[tuple[str, str, int]] = [] # (idx, label, temp)
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
idx = label_f.name[len("temp"):-len("_label")]
entries.append((idx, label, temp))
dup = {lbl for lbl, n in Counter(l for _, l, _ in entries).items() if n > 1}
for idx, label, temp in entries:
name = f"{chip} {label}" + (f" {idx}" if label in dup else "")
out.append({"name": name, "temperature_c": temp, "status": "ok", "kind": "env"})
return out
def read_coretemp() -> list[dict]: def read_coretemp() -> list[dict]:
"""CPU package temps from coretemp hwmon → [{name, temperature_c, kind}].""" """CPU package temps from coretemp hwmon → [{name, temperature_c, kind}]."""
cpus: list[dict] = [] cpus: list[dict] = []
@@ -74,22 +120,28 @@ def read_coretemp() -> list[dict]:
continue continue
except OSError: except OSError:
continue continue
packages: list[dict] = []
cores: list[int] = []
for label_f in sorted(hw.glob("temp*_label")): for label_f in sorted(hw.glob("temp*_label")):
try: try:
label = label_f.read_text().strip() label = label_f.read_text().strip()
except OSError: except OSError:
continue continue
if not label.lower().startswith("package id"):
continue
input_f = label_f.with_name(label_f.name.replace("_label", "_input")) input_f = label_f.with_name(label_f.name.replace("_label", "_input"))
try: try:
milli = int(input_f.read_text().strip()) temp = round(int(input_f.read_text().strip()) / 1000)
except (OSError, ValueError): except (OSError, ValueError):
continue continue
pkg = label.split("id")[-1].strip() low = label.lower()
cpus.append({ if low.startswith("package id"):
"name": f"CPU {pkg}", pkg = label.split("id")[-1].strip()
"temperature_c": round(milli / 1000), packages.append({"name": f"CPU {pkg}", "temperature_c": temp, "kind": "cpu"})
"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 return cpus

View File

@@ -25,7 +25,8 @@ K_INVENTORY = "jbod:inventory"
K_HOST_DRIVES = "jbod:host_drives" K_HOST_DRIVES = "jbod:host_drives"
K_HOST_SENSORS = "jbod:host_sensors" K_HOST_SENSORS = "jbod:host_sensors"
K_META = "jbod:poll:meta" K_META = "jbod:poll:meta"
K_HOST_META = "jbod:host_poll:meta" K_HOST_ENV_META = "jbod:host_poll:env_meta"
K_HOST_DRIVE_META = "jbod:host_poll:drive_meta"
K_LED_QUEUE = "jbod:led:queue" K_LED_QUEUE = "jbod:led:queue"
K_LOCK = "jbod:poll:lock" K_LOCK = "jbod:poll:lock"
K_HOST_LOCK = "jbod:host_poll:lock" K_HOST_LOCK = "jbod:host_poll:lock"