"""Optional MQTT discovery, so the controller's state appears as real HA entities. Optional on purpose: the add-on runs headless without a broker and simply publishes nothing. Nothing in the control path depends on this - if MQTT breaks, the battery keeps being controlled correctly and only the dashboard goes stale. That separation is deliberate; observability must never be able to take down control. """ import json import logging try: import paho.mqtt.client as mqtt except ImportError: # pragma: no cover - container always has it mqtt = None _LOG = logging.getLogger("goodwe.mqtt") DEVICE = { "identifiers": ["goodwe_rs485_controller"], "name": "GoodWe RS485 Controller", "manufacturer": "GoodWe (via RS485 meter emulation)", "model": "ES/BP series", } # (key, name, unit, device_class, state_class, icon) SENSORS = [ ("setpoint", "GoodWe setpoint", "W", "power", "measurement", None), ("grid", "GoodWe grid power", "W", "power", "measurement", None), ("battery", "GoodWe battery power", "W", "power", "measurement", None), ("soc", "GoodWe battery SoC", "%", "battery", "measurement", None), ("phase", "GoodWe maintenance phase", None, None, None, "mdi:battery-sync"), ("status", "GoodWe controller status", None, None, None, "mdi:heart-pulse"), ] BASE = "goodwe_ctl" AVAILABILITY = f"{BASE}/availability" class MqttPublisher: def __init__(self, host, port, username=None, password=None): self.enabled = mqtt is not None and bool(host) self.client = None if not self.enabled: _LOG.info("MQTT not configured - status entities will not be published") return self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="goodwe_rs485_controller") if username: self.client.username_pw_set(username, password or "") self.client.will_set(AVAILABILITY, "offline", retain=True) try: self.client.connect(host, int(port), keepalive=60) self.client.loop_start() self._announce() _LOG.info("MQTT connected to %s:%s", host, port) except OSError as err: _LOG.warning("MQTT connect failed (%s) - continuing without it", err) self.enabled = False def _announce(self) -> None: for key, name, unit, dev_class, state_class, icon in SENSORS: cfg = { "name": name, "unique_id": f"{BASE}_{key}", "state_topic": f"{BASE}/{key}", "availability_topic": AVAILABILITY, "device": DEVICE, } if unit: cfg["unit_of_measurement"] = unit if dev_class: cfg["device_class"] = dev_class if state_class: cfg["state_class"] = state_class if icon: cfg["icon"] = icon self.client.publish( f"homeassistant/sensor/{BASE}_{key}/config", json.dumps(cfg), retain=True, ) self.client.publish(AVAILABILITY, "online", retain=True) def publish(self, values: dict) -> None: if not self.enabled or self.client is None: return try: for key, value in values.items(): if value is None: continue self.client.publish(f"{BASE}/{key}", str(value)) except OSError as err: # pragma: no cover _LOG.debug("MQTT publish failed: %s", err) def close(self) -> None: if not self.enabled or self.client is None: return try: self.client.publish(AVAILABILITY, "offline", retain=True) self.client.loop_stop() self.client.disconnect() except OSError: # pragma: no cover pass