"""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") # ⚠️ The DEVICE name is half of every entity_id. Home Assistant composes # entity_id from device name + entity name, so "GoodWe RS485 Controller" plus # "GoodWe battery power" yields # sensor.goodwe_rs485_controller_goodwe_battery_power. object_id in the # discovery payload did NOT override it (tested on HA 2026.8). So the device is # named "GoodWe" and the entities are named without repeating it - that is what # makes the ids short, predictable, and identical on every install. DEVICE = { "identifiers": ["goodwe_rs485_controller"], "name": "GoodWe", "manufacturer": "GoodWe (via RS485 meter emulation)", "model": "ES/BP series", } # (key, object_id, name, unit, device_class, state_class, icon) # # ⚠️ object_id is what pins the entity_id. Without it Home Assistant derives the # id from the DEVICE name plus the entity name and produces # `sensor.goodwe_rs485_controller_goodwe_battery_power` - unpredictable, ugly, # and different if anyone renames the device. Dashboards and documentation need # these ids to be stable across every install, so they are declared, not derived. SENSORS = [ ("setpoint", "goodwe_setpoint", "Setpoint", "W", "power", "measurement", None), ("grid", "goodwe_grid_power", "Grid power", "W", "power", "measurement", None), ("battery", "goodwe_battery_power", "Battery power", "W", "power", "measurement", None), ("soc", "goodwe_battery_soc", "Battery SoC", "%", "battery", "measurement", None), ("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"), ("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"), # ⚠️ This one deliberately breaks the goodwe_ prefix above: the entity id # must be exactly `sensor.p1_sample_age_s`, because SAFETY-01's firmware # watchdog subscribes to that literal id and the ENV-01 simulation rig # asserts on it. Renaming it silently disarms a safety layer. It is seconds # since the newest accepted P1 telegram, republished every second so that a # meter frozen at a constant value still shows a climbing age - which is the # false-trip that this entity exists to remove. ("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None), ] BASE = "goodwe_ctl" AVAILABILITY = f"{BASE}/availability" class MqttPublisher: def __init__(self, host, port, username=None, password=None, omit=()): # `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a # sensor that nothing will ever publish to is not harmless here: # p1_sample_age_s is a watchdog input, and an entity that exists but is # never fed is a worse signal than one that does not exist at all. self.omit = set(omit) 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 # paho-mqtt 2.x requires a callback API version; 1.x has no such # argument and Alpine ships 1.x. Support both rather than pinning, so # the container can use the distro package instead of compiling. try: self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id="goodwe_rs485_controller") except AttributeError: self.client = mqtt.Client(client_id="goodwe_rs485_controller") if username: self.client.username_pw_set(username, password or "") self.client.will_set(AVAILABILITY, "offline", retain=True) # ⚠️ Announce from on_connect, never straight after connect(). paho # processes the CONNACK on its network thread, so a publish issued # immediately after connect() is made while still disconnected - and # paho DROPS QoS-0 publishes when disconnected, silently. The result is # an add-on that logs "MQTT connected" and creates no entities at all. # As a bonus, this also re-announces after every reconnect. self.client.on_connect = lambda *_args, **_kw: self._announce() try: self.client.connect(host, int(port), keepalive=60) self.client.loop_start() _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, object_id, name, unit, dev_class, state_class, icon in SENSORS: if key in self.omit: continue cfg = { "name": name, "object_id": object_id, "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