"""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/_enc/hotspot/config (retained discovery) homeassistant/sensor/_enc/temp/config (retained discovery) jbod-monitor//status online | offline (LWT) jbod-monitor//enclosure//state {"hotspot_c":.., "sensor_0":..} """ import asyncio import json import logging import os import re from services import store 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_raw = get_node_id() self.node = _slug(self.node_raw) # Parent "hub" device that the per-enclosure devices nest under. self.hub_id = f"{self.node}_jbod_monitor" 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 _hub_device_block(self) -> dict: """The parent device the enclosures nest under (named, so HA doesn't show an 'Unnamed Device').""" return { "identifiers": [self.hub_id], "name": f"JBOD Monitor ({self.node_raw})", "manufacturer": "jbod-monitor", "model": "SES/SMART poller", } 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": self.hub_id, } def _publish_hub(self) -> None: """Announce the hub device via a connectivity binary_sensor, so the parent device is named and shows monitor online/offline status.""" uid = f"{self.hub_id}_status" if uid in self._discovered: return payload = { "name": "Status", "unique_id": uid, "device_class": "connectivity", "state_topic": self.availability_topic, "payload_on": "online", "payload_off": "offline", "entity_category": "diagnostic", "device": self._hub_device_block(), } topic = f"{self.discovery_prefix}/binary_sensor/{self.hub_id}/status/config" self._client.publish(topic, json.dumps(payload), qos=1, retain=True) self._discovered.add(uid) 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: self._publish_hub() # name the parent device before nesting enclosures for enc in snapshot.get("enclosures", []): self._publish_discovery(enc) self._publish_state(enc) def publish_host(self, sensors: dict, drives: list) -> None: """Publish chassis temps (IPMI env, CPU, on-metal drives) on the hub.""" self._publish_hub() state_topic = f"{self.base_topic}/{self.node}/host/state" device = self._hub_device_block() payload: dict = {} def announce(object_id: str, cfg: dict) -> None: uid = cfg["unique_id"] if uid not in self._discovered: topic = f"{self.discovery_prefix}/sensor/{self.hub_id}/{object_id}/config" self._client.publish(topic, json.dumps(cfg), 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, } def add(kind: str, name: str, temp, icon: str | None = None) -> None: if temp is None or not name: return key = f"{kind}_{_slug(name)}" payload[key] = temp cfg = { **common, "name": name, "unique_id": f"{self.hub_id}_{key}", "value_template": f"{{{{ value_json.{key} }}}}", } if icon: cfg["icon"] = icon announce(key, cfg) for s in sensors.get("env", []): add("env", s.get("name"), s.get("temperature_c")) for c in sensors.get("cpu", []): add("cpu", c.get("name"), c.get("temperature_c"), icon="mdi:cpu-64-bit") def add_drive(d: dict) -> None: dev = d.get("device") if dev: add("drive", f"Drive {dev}", d.get("temperature_c"), icon="mdi:harddisk") for d in drives: add_drive(d) for pd in d.get("physical_drives", []): add_drive(pd) self._client.publish(state_topic, json.dumps(payload), qos=0, retain=True) 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) host_sensors = await store.get_host_sensors() or {} host_drives = await store.get_host_drives() or [] self.publish_host(host_sensors, host_drives) logger.info( "MQTT published temps for %d enclosure(s) + host (%d env, %d cpu)", len(snapshot.get("enclosures", [])), len(host_sensors.get("env", [])), len(host_sensors.get("cpu", [])), ) except asyncio.CancelledError: raise except Exception as e: logger.error("MQTT publish loop error: %s", e) await asyncio.sleep(self.interval)