- enclosure: fetch SES element descriptor page (0x07) and label temp/fan/ psu/voltage elements with human-readable names - temps service + /api/temps: per-enclosure hotspot (max across SES sensors and housed drive temps) plus named SES sensor list - mqtt_publisher: paho-mqtt with HA discovery (device per enclosure, hotspot + per-sensor entities), LWT availability, opt-in via MQTT_HOST - secrets: OpenBao KV v2 reader; MQTT creds sourced from secret/home_assistant with env fallback - compose/requirements/README updated
227 lines
9.0 KiB
Python
227 lines
9.0 KiB
Python
"""MQTT publisher with Home Assistant discovery for enclosure temperatures.
|
|
|
|
Opt-in: only starts when ``MQTT_HOST`` is set. Publishes one retained
|
|
discovery config per HA entity (a device per enclosure, with a hotspot
|
|
sensor plus one sensor per named SES temperature element) and pushes a
|
|
single JSON state message per enclosure on each poll. Availability is
|
|
tracked via an LWT so HA marks entities unavailable if the monitor dies.
|
|
|
|
Topic layout (defaults)::
|
|
|
|
homeassistant/sensor/<node>_enc<id>/hotspot/config (retained discovery)
|
|
homeassistant/sensor/<node>_enc<id>/temp<n>/config (retained discovery)
|
|
jbod-monitor/<node>/status online | offline (LWT)
|
|
jbod-monitor/<node>/enclosure/<id>/state {"hotspot_c":.., "sensor_0":..}
|
|
"""
|
|
import asyncio
|
|
import json
|
|
import logging
|
|
import os
|
|
import re
|
|
|
|
from services.secrets import read_kv2
|
|
from services.temps import build_temp_snapshot, get_node_id
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
try:
|
|
import paho.mqtt.client as mqtt
|
|
from paho.mqtt.enums import CallbackAPIVersion
|
|
|
|
_PAHO_AVAILABLE = True
|
|
except ImportError: # pragma: no cover - exercised only without the dep
|
|
mqtt = None
|
|
CallbackAPIVersion = None
|
|
_PAHO_AVAILABLE = False
|
|
|
|
|
|
def mqtt_enabled() -> bool:
|
|
"""MQTT publishing is opt-in via MQTT_HOST."""
|
|
return bool(os.environ.get("MQTT_HOST"))
|
|
|
|
|
|
def _slug(value: str) -> str:
|
|
"""Sanitize a string for use in MQTT topics / HA unique_ids."""
|
|
return re.sub(r"[^a-zA-Z0-9_-]", "_", value).strip("_") or "x"
|
|
|
|
|
|
def _resolve_credentials() -> tuple[str | None, str | None]:
|
|
"""Resolve broker credentials, preferring OpenBao over plaintext env.
|
|
|
|
If ``MQTT_SECRET_PATH`` is set, read username/password from that OpenBao
|
|
KV v2 path (keys ``username``/``password``). Falls back to the
|
|
``MQTT_USERNAME``/``MQTT_PASSWORD`` env vars when unset or on failure.
|
|
"""
|
|
username = os.environ.get("MQTT_USERNAME") or None
|
|
password = os.environ.get("MQTT_PASSWORD") or None
|
|
|
|
secret_path = os.environ.get("MQTT_SECRET_PATH")
|
|
if secret_path:
|
|
user_key = os.environ.get("MQTT_SECRET_USER_KEY", "username")
|
|
pass_key = os.environ.get("MQTT_SECRET_PASS_KEY", "password")
|
|
data = read_kv2(secret_path)
|
|
if data:
|
|
username = data.get(user_key, username)
|
|
password = data.get(pass_key, password)
|
|
logger.info("Loaded MQTT credentials from OpenBao (%s)", secret_path)
|
|
else:
|
|
logger.warning(
|
|
"MQTT_SECRET_PATH set (%s) but OpenBao read failed — "
|
|
"falling back to env credentials", secret_path,
|
|
)
|
|
return username, password
|
|
|
|
|
|
class MqttPublisher:
|
|
def __init__(self) -> None:
|
|
self.host = os.environ["MQTT_HOST"]
|
|
self.port = int(os.environ.get("MQTT_PORT", "1883"))
|
|
self.username, self.password = _resolve_credentials()
|
|
self.discovery_prefix = os.environ.get("MQTT_DISCOVERY_PREFIX", "homeassistant")
|
|
self.base_topic = os.environ.get("MQTT_BASE_TOPIC", "jbod-monitor")
|
|
self.interval = int(os.environ.get("MQTT_PUBLISH_INTERVAL", "60"))
|
|
self.node = _slug(get_node_id())
|
|
|
|
self.availability_topic = f"{self.base_topic}/{self.node}/status"
|
|
# unique_ids for which we've already published discovery this connection.
|
|
self._discovered: set[str] = set()
|
|
|
|
self._client = mqtt.Client(
|
|
CallbackAPIVersion.VERSION2,
|
|
client_id=os.environ.get("MQTT_CLIENT_ID", f"jbod-monitor-{self.node}"),
|
|
)
|
|
if self.username:
|
|
self._client.username_pw_set(self.username, self.password)
|
|
self._client.will_set(self.availability_topic, "offline", qos=1, retain=True)
|
|
self._client.reconnect_delay_set(min_delay=1, max_delay=60)
|
|
self._client.on_connect = self._on_connect
|
|
self._client.on_disconnect = self._on_disconnect
|
|
|
|
def start(self) -> None:
|
|
logger.info("MQTT connecting to %s:%d", self.host, self.port)
|
|
self._client.connect_async(self.host, self.port, keepalive=60)
|
|
self._client.loop_start()
|
|
|
|
def stop(self) -> None:
|
|
try:
|
|
self._client.publish(self.availability_topic, "offline", qos=1, retain=True)
|
|
except Exception:
|
|
pass
|
|
self._client.loop_stop()
|
|
try:
|
|
self._client.disconnect()
|
|
except Exception:
|
|
pass
|
|
|
|
# paho callbacks (run in the network thread) -----------------------------
|
|
def _on_connect(self, client, userdata, flags, reason_code, properties=None):
|
|
if reason_code != 0:
|
|
logger.warning("MQTT connect failed: %s", reason_code)
|
|
return
|
|
logger.info("MQTT connected")
|
|
# Re-announce discovery + availability on every (re)connect.
|
|
self._discovered.clear()
|
|
client.publish(self.availability_topic, "online", qos=1, retain=True)
|
|
|
|
def _on_disconnect(self, client, userdata, flags, reason_code, properties=None):
|
|
logger.warning("MQTT disconnected: %s", reason_code)
|
|
|
|
# discovery / state ------------------------------------------------------
|
|
def _device_block(self, enc: dict) -> dict:
|
|
enc_id = enc["id"]
|
|
vendor = enc.get("vendor", "").strip()
|
|
model = enc.get("model", "").strip()
|
|
name = " ".join(p for p in [vendor, model] if p) or "JBOD enclosure"
|
|
return {
|
|
"identifiers": [f"{self.node}_enc_{_slug(enc_id)}"],
|
|
"name": f"JBOD {name} ({enc_id})",
|
|
"manufacturer": vendor or None,
|
|
"model": model or None,
|
|
"via_device": f"{self.node}_jbod_monitor",
|
|
}
|
|
|
|
def _publish_discovery(self, enc: dict) -> None:
|
|
enc_id = enc["id"]
|
|
enc_slug = _slug(enc_id)
|
|
state_topic = f"{self.base_topic}/{self.node}/enclosure/{enc_slug}/state"
|
|
device = self._device_block(enc)
|
|
|
|
def announce(object_id: str, payload: dict) -> None:
|
|
uid = payload["unique_id"]
|
|
if uid in self._discovered:
|
|
return
|
|
topic = (
|
|
f"{self.discovery_prefix}/sensor/"
|
|
f"{self.node}_enc{enc_slug}/{object_id}/config"
|
|
)
|
|
self._client.publish(topic, json.dumps(payload), qos=1, retain=True)
|
|
self._discovered.add(uid)
|
|
|
|
common = {
|
|
"device_class": "temperature",
|
|
"unit_of_measurement": "°C",
|
|
"state_class": "measurement",
|
|
"state_topic": state_topic,
|
|
"availability_topic": self.availability_topic,
|
|
"device": device,
|
|
}
|
|
|
|
# Hotspot (max across SES sensors + drive temps).
|
|
announce("hotspot", {
|
|
**common,
|
|
"name": "Hotspot",
|
|
"unique_id": f"{self.node}_enc{enc_slug}_hotspot",
|
|
"value_template": "{{ value_json.hotspot_c }}",
|
|
"icon": "mdi:thermometer-high",
|
|
"json_attributes_topic": state_topic,
|
|
"json_attributes_template":
|
|
'{"drive_max_c": {{ value_json.drive_max_c | default("null") }}, '
|
|
'"drive_temp_count": {{ value_json.drive_temp_count | default(0) }}}',
|
|
})
|
|
|
|
# One sensor per named SES temperature element.
|
|
for s in enc.get("sensors", []):
|
|
idx = s["index"]
|
|
label = s.get("name") or f"Temp {idx}"
|
|
announce(f"temp{idx}", {
|
|
**common,
|
|
"name": label,
|
|
"unique_id": f"{self.node}_enc{enc_slug}_temp{idx}",
|
|
"value_template": f"{{{{ value_json.sensor_{idx} }}}}",
|
|
})
|
|
|
|
def _publish_state(self, enc: dict) -> None:
|
|
enc_slug = _slug(enc["id"])
|
|
state_topic = f"{self.base_topic}/{self.node}/enclosure/{enc_slug}/state"
|
|
payload: dict = {
|
|
"hotspot_c": enc.get("hotspot_c"),
|
|
"drive_max_c": enc.get("drive_max_c"),
|
|
"drive_temp_count": enc.get("drive_temp_count", 0),
|
|
}
|
|
for s in enc.get("sensors", []):
|
|
payload[f"sensor_{s['index']}"] = s.get("temperature_c")
|
|
payload[f"sensor_{s['index']}_status"] = s.get("status")
|
|
self._client.publish(state_topic, json.dumps(payload), qos=0, retain=True)
|
|
|
|
def publish_snapshot(self, snapshot: dict) -> None:
|
|
for enc in snapshot.get("enclosures", []):
|
|
self._publish_discovery(enc)
|
|
self._publish_state(enc)
|
|
|
|
async def run(self) -> None:
|
|
"""Background loop: build a snapshot and publish on an interval."""
|
|
await asyncio.sleep(3) # let the first SMART poll populate the cache
|
|
while True:
|
|
try:
|
|
snapshot = await build_temp_snapshot()
|
|
self.publish_snapshot(snapshot)
|
|
logger.info(
|
|
"MQTT published temps for %d enclosure(s)",
|
|
len(snapshot.get("enclosures", [])),
|
|
)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as e:
|
|
logger.error("MQTT publish loop error: %s", e)
|
|
await asyncio.sleep(self.interval)
|