"""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 asyncio import json import logging import re 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), # SAFETY-31. `sum(|Bi|) - |sum(Bi)|` over MEASURED per-inverter power: the # power going round between the inverters, which the grid sensor above # CANNOT SHOW because it nets to zero there. The two are published side by # side on purpose - "grid 0 W, circulating 6000 W" is the whole phenomenon # in two rows of a dashboard, and no arithmetic on the grid figure alone can # ever produce the second one. # # ⚠️ Published as 0 at the shipped one-inverter roster, not omitted, and # that is a deliberate difference from `p1_age` below. An absent entity is # right for a watchdog input nothing is feeding; this is a quantity whose # value is KNOWN at N=1 - identically zero, for every reading - and a # dashboard row reading 0 W is the honest report of it. ("circulation", "goodwe_circulating_power", "Circulating power", "W", "power", "measurement", "mdi:sync-alert"), ("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"), ("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"), # ⚠️ THE LIVE ENTITY IS `sensor.goodwe_p1_sample_age`, NOT what this # object_id spells. An earlier version of this comment said the id "must be # exactly sensor.p1_sample_age_s" - that name has never existed on any HA, # and believing it is how a safety layer gets disarmed by someone tidying up. # # On HA 2026.8 `object_id` in a discovery payload does NOT determine the # entity_id. HA composes it from the device name plus the entity name: # "GoodWe RS485 Controller" + "P1 sample age" -> sensor.goodwe_p1_sample_age. # Verified against the live system 2026-08-26; the firmware subscribes to # that composed name (goodwe-master-stage4-failsafe.yaml, homeassistant # sensor p1_age_s), and its own comment records that sensor.p1_sample_age_s # never existed. # # So: do NOT "fix" the firmware to match the object_id below, and do not # rename the device or this entity's friendly name - either changes the # composed id and silently disarms the ingest guard, because has_state() # simply stays false forever and nothing reports it. The firmware calls this # out as the failure that "FAILS SILENTLY AND LOOKS CORRECT". # # The value: seconds since the newest accepted P1 telegram, republished # every second so a meter frozen at a constant value still shows a climbing # age - the false-trip this entity exists to remove. # # ⚠️ Separately, and not fixed here: the firmware's ingest guard tests this # sensor's VALUE rather than its arrival, so a dead publisher freezes the # number and reads permanently fresh. Filed as TEL-06. ("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None), # --- TEL-10: the loop's integrator, published for the first time --------- # # ⚠️ APPENDED, AND THE POSITION IS THE POINT. `discovery_payloads()` walks # this list in order and `_announce()` publishes what it returns, so a row # inserted ABOVE would move every later entity's place in the announcement. # Nothing existing is renamed, re-typed or re-ordered by these three; they # are three NEW retained topics after the nine that are already there, and # test_mqtt.py part 1 grades exactly that claim. # # ⚠️ AND THEY ARE SITE ROWS, NOT PER-UNIT ONES. There is one integrator - # §6.2's architecture is ONE integrator on the P1 meter whose output is then # SPLIT - so a per-unit copy would invent N of a thing there is one of, the # same argument `grid` and `p1_age` are site rows for. # # `state_class: measurement` on the two numbers is what puts them in the # recorder's statistics, which is the whole point: the reason this card # exists is that the last investigation had no history to read. ("loop_i", "goodwe_loop_integrator", "Loop integrator", "W", "power", "measurement", None), # ⚠️ NOT DERIVABLE FROM THE TWO ROWS EITHER SIDE OF IT, which is why it is # its own row rather than a subtraction a dashboard could do. `setpoint` is # the ARBITER's output after every other strategy has had its say, and it is # also THIS cycle's; the lead is measured against the command `compute()` # was handed, which is the PREVIOUS one. Subtracting the published rows # gives a different number on every cycle the arbiter overrode the loop. ("loop_lead", "goodwe_loop_integrator_lead", "Loop integrator lead", "W", "power", "measurement", None), # ⚠️ NOT `sensor.goodwe_controller_status` AND NOT THE "Why" ROW. Both of # those carry the ARBITER's sentence; this is `compute()`'s own word for # what the control law did with this cycle - `slew-limited` is the value # SAFETY-48 is about and it appears in neither of the other two. ("loop_reason", "goodwe_loop_reason", "Loop reason", None, None, None, "mdi:sigma"), ] # --- TEL-11: rows that exist ONLY when the operator configured a source ------ # # ⚠️ A SECOND LIST RATHER THAN A TENTH ENTRY IN `SENSORS`, AND THE DEFAULT IS # WHAT MAKES IT ONE. `SENSORS` is announced by every caller of # `discovery_payloads()`; `OPTIONAL_SENSORS` is announced only by a caller that # ASKS, so a caller that has not been taught about `pv_entity` - `test_mqtt.py`'s # fixtures, `tools/rehearse_roster.py`, anything written next year - announces # exactly what it announced before. `p1_age` solves the same problem the other # way round, with `omit`, because `p1_age` is announced by DEFAULT and withdrawn # when the meter is off; this one does not exist by default and appears when a # source for it does. Getting that direction wrong is the hazard `omit`'s own # note names: an entity that exists and is never fed is a worse signal than one # that does not exist at all, and MOST installs will never set `pv_entity`. # # ⚠️ `state_class: measurement` IS THE WHOLE POINT OF THE ROW. It is what puts # the number into the recorder's long-term statistics, and a week of statistics # is what OPT-10 needs to say whether the surplus that left this site was cloud, # a charge-power limit, or the optimiser declining to charge. OPTIONAL_SENSORS = [ ("pv", "goodwe_pv_power", "PV power", "W", "power", "measurement", None), ] # ⚠️ HA's OWN "this sensor has no value" PAYLOAD, and the one place this add-on # spells it. `homeassistant/components/mqtt/const.py` declares # `PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric # branch - `if payload == PAYLOAD_NONE: self._attr_native_value = None` - so the # entity's state becomes `unknown` rather than a number. (Read out of # github.com/home-assistant/core `dev` on 2026-09-24; the shipped MQTT-sensor # documentation says the same thing in its `state_topic` entry: "a 'None' value # will set the sensor to an unknown state".) PAYLOAD_NONE = "None" # The keys whose ABSENCE MUST BE PUBLISHED rather than skipped. ⚠️ THIS IS THE # DIFFERENCE BETWEEN "no PV reported" AND "PV reported 0 W" IN A WEEK OF # HISTORY, AND WITHOUT IT THE CHANNEL LIES IN THE WORSE DIRECTION. `publish()` # below drops a `None` - which is right for `circulation`, where the last figure # standing is better than a zero that reads as health - but a DROPPED pv row # leaves Home Assistant holding the last number it was told, forever, and the # recorder then draws a flat line at that value through an outage. Someone # reading it back would conclude the sun was not shining, which is precisely the # conclusion this card exists to TEST rather than to manufacture. ABSENT_AS_UNKNOWN = frozenset({"pv"}) def optional_keys(opts: dict) -> tuple: """Which `OPTIONAL_SENSORS` rows this configuration has a source for. ⚠️ ONE ANSWER TO "IS THE PV ROW LIVE", read by the ANNOUNCEMENT (through `MqttPublisher`) and by `Controller.publish()`. `announced_units()` above exists for exactly this reason and says it in full: two spellings of one predicate is how an entity gets announced that nothing feeds, or fed on a topic nothing announced. ⚠️ AND SINCE TEL-12 IT IS `pv.entities()` RATHER THAN A SECOND READING OF THE OPTION - `Controller.pv_roster` is the SAME call. TEL-11 got away with two spellings because both were `bool()` of one string; with `pv_entities` beside `pv_entity`, and `pv_entities` REPLACING it, a second spelling is how the row gets announced off one key and summed off the other. ⚠️ THE ROW IS ONE SITE TOTAL AT EVERY ROSTER SIZE: there is deliberately no per-entity topic, because the recorder statistic OPT-10 reads back is the site figure and a per-inverter breakdown is a dashboard question the operator's own PV integration already answers. """ # ⚠️ THE IMPORT IS FUNCTION-LOCAL AND THAT IS NOT A STYLE CHOICE. This # module is EXEC'D STANDALONE, outside its package, by `test_mqtt.py` # part 8's mutation harness - which says so in its own comment, and whose # honesty depends on it ("`app/mqtt.py` imports nothing from this package, # which is what makes exec'ing it standalone honest rather than a stub"). # A module-scope `from . import pv` turns that whole instrument into an # `ImportError` before it grades anything. `tools/tel11_pv_trace.py`'s # `_load()` is the same decision for the same kind of reason. Measured, not # assumed: move this line to module scope and test_mqtt.py part 8 dies. from . import pv as _pv return ("pv",) if _pv.entities(opts) else () BASE = "goodwe_ctl" AVAILABILITY = f"{BASE}/availability" # --- TEL-07: a SECOND device, for inverter B, and NOT one per inverter ------- # # ⚠️ READ THE DEVICE-NAME NOTE ABOVE BEFORE CHANGING ANYTHING HERE. The device # name is half of every entity_id, so re-scoping `DEVICE` renames every entity # the live install already has - and HA keeps the id it composed at FIRST # discovery, so what a rename actually produces is a NEW entity beside a dead # one: the history stays behind on the old id, and every dashboard, template # and automation that names it (including `goodwe_master_watchdog_held_*` and # `goodwe_master_parked_at_0_w_*`) keeps pointing at the entity that stopped # updating. That is silent. So: # # `DEVICE` IS NOT TOUCHED AND ITS ENTITIES ARE NOT TOUCHED. It is the SITE # device - the meter's view and the controller's own state - and at the # shipped roster of one it is also, in effect, inverter A's. # # THE FIRST ROSTER ENTRY GETS NO NEW DEVICE. Its per-unit rows are announced # ON THE DEVICE IT ALREADY HAS, under new `unique_id`s and new names. Nothing # existing is renamed, so nothing existing loses its history. # # EVERY LATER ENTRY GETS ITS OWN DEVICE (`via_device` the site one). # # ⚠️ THE ASYMMETRY IS THE POINT, AND IT WILL LOOK LIKE AN OVERSIGHT. "One # device per inverter", with A as `inverter-a` and B as `inverter-b`, is tidy, # symmetrical, and is the change that destroys the live system's recorded # history. DO NOT "CLEAN THIS UP". The composed entity_ids come out symmetric # anyway - `sensor.goodwe_inverter_a_battery_power` and # `sensor.goodwe_inverter_b_battery_power` - which is the whole benefit of the # tidy version, without the cost. # # WHAT IS PER-UNIT AND WHAT IS SITE, and why the split is where it is: # # PER-UNIT setpoint (this unit's share of the site target, the number its # own heartbeat re-emits), battery power (`inv.batt`, this unit's # own meter reading), SoC (`inv.soc`, THIS pack - the site row is # the EMPTIEST pack, which at N>=2 may be the other one), and the # SAFETY-02 sign latch, which is per actuator by construction. # # SITE `grid` and `p1_age`. There is ONE P1 meter and ONE grid # connection; announcing them per inverter would invent two of a # thing there is one of. `p1_sample_age_s` is additionally a # WATCHDOG INPUT (see `omit` below and the firmware note above), so # a second copy of it is a second copy of a safety signal - two # answers to "is the meter fresh?", one of which is always the one # nobody is looking at. Also site: circulating power (it is # `sum(|Bi|) - |sum(Bi)|`, a property of the SET), the controller # status, the maintenance phase, and the capacity-tariff peak. # # NOT ANNOUNCED, AND SAID OUT LOUD RATHER THAN LEFT LOOKING FINISHED: the # card also lists "inverter AC power" and per-unit HEARTBEATS as per-unit. # Neither has a source in this add-on today - nothing reads an AC-power # register per unit, and `inv.last_beat` is a monotonic loop clock with no # wall-clock age published anywhere. Announcing them would create entities # nothing ever feeds, which is exactly what the `omit` note below refuses to # do for `p1_sample_age_s`. They are gaps, not omissions. UNIT_SENSORS = [ ("setpoint", "setpoint", "Setpoint", "W", "power", "measurement", None), ("battery", "battery_power", "Battery power", "W", "power", "measurement", None), ("soc", "battery_soc", "Battery SoC", "%", "battery", "measurement", None), ("sign_latch", "sign_latch", "Sign latch", None, None, None, "mdi:flash-alert"), ] # ⚠️ A ROSTER OF ONE ANNOUNCES NOTHING NEW - not a device, not an entity, not a # topic. Every install running today is a roster of one, and "the upgrade added # four entities nobody asked for" is a change to a live system's entity list # for no information: at N=1 the site rows ARE this unit's rows, exactly. PER_UNIT_MIN_ROSTER = 2 def _slug(text) -> str: """Home Assistant's own object_id spelling: lowercase, `_`, no runs. ⚠️ ASCII-ONLY ON PURPOSE. `str.isalnum()` is true for 'é' and for '٤', and HA transliterates those rather than keeping them, so treating them as alphanumeric here would produce an id this module claims and HA does not. They become separators, which is conservative in the right direction: two names that differ ONLY outside ASCII collide, and `unit_slugs()` below is what stops a collision becoming two entities with one `unique_id`. """ out = [] for ch in str(text).strip().lower(): out.append(ch if (ch.isascii() and ch.isalnum()) else "_") slug = "".join(out) while "__" in slug: slug = slug.replace("__", "_") return slug.strip("_") def _title(name) -> str: """`inverter-b` -> `Inverter B`. The half of the entity_id HA composes.""" words = str(name).replace("-", " ").replace("_", " ").split() return " ".join(w[:1].upper() + w[1:] for w in words) or str(name) def unit_slugs(units) -> list: """One id-safe slug per roster entry, in roster order, ALL DISTINCT. ⚠️ THE DE-COLLISION IS NOT DECORATION. `validate.py` rule 9(c) refuses two entries with the SAME name; it does not refuse `inverter-b` beside `Inverter B`, and those two slug to one string. Two entities published with one `unique_id` is not an error in HA - the second one is DROPPED, so one inverter's telemetry silently stops existing. The suffix is the roster INDEX, so it is stable for a given roster and does not depend on what the other unit is called. """ slugs, seen = [], set() for i, name in enumerate(units): slug = _slug(name) or f"inverter_{i + 1}" while slug in seen: slug = f"{slug}_{i + 1}" seen.add(slug) slugs.append(slug) return slugs def announced_units(units) -> list: """The roster, in order - or EMPTY below `PER_UNIT_MIN_ROSTER`. ⚠️ ONE ANSWER TO "WHICH UNITS GET PER-UNIT ENTITIES", read by the announcement here AND by `Controller.publish()` in main.py. Two spellings of the same predicate is how an entity gets announced that nothing publishes to, or published to on a topic nothing announced - and at N=1 the second would be an added topic, which AC 4 forbids outright. """ names = [str(u) for u in units] if len(names) < PER_UNIT_MIN_ROSTER: return [] return names def unit_state_key(slug: str, key: str) -> str: """The `publish()` key - i.e. the state topic under `BASE` - for one unit. Written once, here, because main.py builds it and this module announces it; a second transcription is a topic announced and never fed. """ return f"unit/{slug}/{key}" def unit_device(name, slug: str) -> dict: """B's OWN device. ⚠️ Never returned for the first roster entry.""" return { "identifiers": [f"{DEVICE['identifiers'][0]}_{slug}"], "name": f"{DEVICE['name']} {_title(name)}", "manufacturer": DEVICE["manufacturer"], "model": DEVICE["model"], # Hangs B under the controller in HA's device tree rather than beside # it. Cosmetic, and the one line that says in the UI what the asymmetry # above means: there is one controller, and it drives both. "via_device": DEVICE["identifiers"][0], } def _payload(name, object_id, unique_id, state_topic, device, unit, dev_class, state_class, icon) -> dict: """One discovery payload. ⚠️ KEY ORDER IS PART OF THE OUTPUT. `json.dumps` writes keys in insertion order, so this dict's construction order IS the bytes on the wire, and AC 1 is a BYTE comparison against what `release/1.0` publishes. Do not reorder. """ cfg = { "name": name, "object_id": object_id, "unique_id": unique_id, "state_topic": state_topic, "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 return cfg def unit_unique_id(slug: str, key: str) -> str: """One unit's entity `unique_id` (also its config topic's middle part).""" return f"{BASE}_unit_{slug}_{key}" def _unit_places(names): """`(slug, device, prefix)` for each roster name, in order.""" slugs = unit_slugs(names) for i, (name, slug) in enumerate(zip(names, slugs)): # ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry # keeps the device it already has and carries its name in the ENTITY # name instead; every later one gets its own device and does not. # Either way HA composes device-name + entity-name into the same # string, which is why `object_id` can be written once for both. device = DEVICE if i == 0 else unit_device(name, slug) prefix = f"{_title(name)} " if i == 0 else "" yield slug, device, prefix def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list: """Every `(topic, payload)` this add-on announces, in announcement order. Split out of `_announce()` so that the acceptance criterion can be graded as a DIFF OF THE ANNOUNCED PAYLOADS against `release/1.0`, rather than as a reading of the code. `_announce()` publishes exactly this list and nothing else, which is itself checked. `include` names `OPTIONAL_SENSORS` keys to announce. ⚠️ IT DEFAULTS TO NOTHING AND THE ROWS GO LAST, so no existing row's POSITION moves under any configuration - not the site rows, not the per-unit ones. Order is part of the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an ordered LIST precisely because a re-order is a zero-byte edit that re-announces every entity on a live install). `modes` (OPT-11 P2) is the battery modes offered, empty when they are off. """ omit = set(omit) include = [k for k in include if k not in omit] out = [] for key, object_id, name, unit, dev_class, state_class, icon in SENSORS: if key in omit: continue out.append(( f"homeassistant/sensor/{BASE}_{key}/config", _payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}", DEVICE, unit, dev_class, state_class, icon))) site_slug = _slug(DEVICE["name"]) for slug, device, prefix in _unit_places(announced_units(units)): for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS: unique_id = unit_unique_id(slug, key) out.append(( f"homeassistant/sensor/{unique_id}/config", _payload(prefix + label, f"{site_slug}_{slug}_{oid}", unique_id, f"{BASE}/{unit_state_key(slug, key)}", device, unit, dev_class, state_class, icon))) for key, object_id, name, unit, dev_class, state_class, icon in OPTIONAL_SENSORS: if key not in include: continue out.append(( f"homeassistant/sensor/{BASE}_{key}/config", _payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}", DEVICE, unit, dev_class, state_class, icon))) # OPT-11 P2: last, and only when battery modes are on. out.extend(mode_payloads(units, modes)) return out # --- OPT-11 P2: each battery's switch and mode, as HA entities --------------- # Announced only when battery modes are on, after every other row, so no # existing row moves. A command goes through Controller.handle_action(), the # page's /action path. DECISIONS/OPT-11-battery-modes.md has the record. # # The select's options are these labels, in `packmode.MODES` order. A new mode # (P3) is a new entry here and a new option at the end; no id changes. MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar", "cheap_hours": "Cheap hours only"} # OPT-11 P3 USE_ON, USE_OFF = "ON", "OFF" # A longer command payload is refused before it is decoded. MAX_COMMAND_BYTES = 64 # The store key holding the mode config topics last announced, so that turning # modes off can remove exactly those (an empty retained config) and an install # that never turned them on publishes nothing new. MODE_CONFIGS_KEY = "mqtt_mode_configs" _MODE_CONFIG_RE = re.compile( rf"homeassistant/(select|switch)/{BASE}_unit_[a-z0-9_]+_(mode|use)/config") _COMMAND_RE = re.compile(rf"{BASE}/unit/([a-z0-9_]+)/(mode|use)/set") # (entity key, HA component, entity name, icon) MODE_ENTITIES = [ ("mode", "select", "Mode", "mdi:battery-sync"), ("use", "switch", "Use", "mdi:power"), ] def mode_payloads(units, modes) -> list: """The `(topic, payload)` for each battery's select and switch. Every roster entry gets them, also at a roster of one. The device rule is TEL-07's: the first battery's rows go on the site device with its name in front, each later battery's on its own device. Empty when `modes` is empty. """ if not modes: return [] site_slug = _slug(DEVICE["name"]) options = [MODE_LABELS[m] for m in modes] out = [] for slug, device, prefix in _unit_places([str(u) for u in units]): for key, component, label, icon in MODE_ENTITIES: unique_id = unit_unique_id(slug, key) state = f"{BASE}/{unit_state_key(slug, key)}" cfg = { "name": prefix + label, "object_id": f"{site_slug}_{slug}_{key}", "unique_id": unique_id, "state_topic": state, "command_topic": f"{state}/set", } if component == "select": cfg["options"] = options else: cfg["payload_on"] = USE_ON cfg["payload_off"] = USE_OFF # HA sends commands unretained; a retained one is ignored anyway. cfg["retain"] = False cfg["availability_topic"] = AVAILABILITY cfg["device"] = device cfg["icon"] = icon out.append((f"homeassistant/{component}/{unique_id}/config", cfg)) return out def mode_retractions(stored, current) -> list: """Config topics announced before and not now: each gets an empty retained config, which removes the entity from HA. Anything in the store that is not one of our mode config topics is ignored.""" if not isinstance(stored, list): return [] keep = set(current) return list(dict.fromkeys(t for t in stored if isinstance(t, str) and _MODE_CONFIG_RE.fullmatch(t) and t not in keep)) def mode_configs(units, modes, stored) -> tuple: """`(keep, retract)` at start-up: the mode config topics to store, and the ones to remove now. A removal stays in `keep`, so a start with no broker does not forget it; resending an empty config is harmless.""" current = [t for t, _ in mode_payloads(units, modes)] retract = mode_retractions(stored, current) return current + retract, retract def parse_command(topic, payload, retained, modes): """`(slug, action, value, reject)` for one message on a command topic. `action` is `pack_use` or `pack_mode`. `value` is what /action would be sent: a bool for the switch, a mode key for the select, or None for a payload that is neither (the store path then refuses it). `reject` is a reason when the message is dropped here: replayed by the broker (retained), too long, not UTF-8, or not a command topic. """ m = _COMMAND_RE.fullmatch(topic) if isinstance(topic, str) else None if m is None: return None, None, None, "topic" slug, key = m.group(1), m.group(2) action = "pack_use" if key == "use" else "pack_mode" if retained: return slug, action, None, "retained" if not isinstance(payload, (bytes, bytearray)) \ or len(payload) > MAX_COMMAND_BYTES: return slug, action, None, "size" try: text = bytes(payload).decode("utf-8") except UnicodeDecodeError: return slug, action, None, "encoding" if key == "use": value = {USE_ON: True, USE_OFF: False}.get(text) else: value = next((k for k in modes if MODE_LABELS.get(k) == text), None) return slug, action, value, None class MqttPublisher: # OPT-11 P2. Class defaults, so a publisher built without them (and every # test fixture that builds one with __new__) behaves as before. modes = () retract = () _loop = None _handler = None def __init__(self, host, port, username=None, password=None, omit=(), units=(), include=(), modes=(), retract=()): # `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) # TEL-07. The roster's NAMES, in roster order - `inverter.roster_names()`, # not a second reading of the option. Defaults to empty, which # `announced_units()` treats exactly as a roster of one: nothing # per-unit is announced, so every caller that has not been taught about # the roster keeps publishing what it published before. self.units = [str(u) for u in units] # TEL-11. `mqtt.optional_keys(opts)` - the shipped predicate, not a # second reading of the option. Defaults to empty, so a caller that has # not been taught about it announces exactly what it announced before. self.include = [str(k) for k in include] # OPT-11 P2: the modes offered (empty when battery modes are off), and # the mode config topics to remove (`mode_retractions()`). self.modes = tuple(modes) self.retract = [str(t) for t in retract] 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._on_connect() self.client.on_message = self._on_message 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: # ⚠️ NO PAYLOAD IS BUILT HERE. `discovery_payloads()` is the whole # announcement, so the check that diffs it against `release/1.0` is # diffing what actually goes on the wire - and a payload built here # instead would be one this add-on publishes and no check has ever seen. for topic, cfg in discovery_payloads(self.units, self.omit, self.include, self.modes): self.client.publish(topic, json.dumps(cfg), retain=True) for topic in self.retract: self.client.publish(topic, "", retain=True) self.client.publish(AVAILABILITY, "online", retain=True) def _on_connect(self) -> None: self._announce() if self.modes: for key in ("mode", "use"): self.client.subscribe(f"{BASE}/unit/+/{key}/set") def bind(self, loop, handler) -> None: """OPT-11 P2: hand each command to `handler(topic, payload, retained)`, a coroutine function run on `loop` (the controller's).""" self._loop, self._handler = loop, handler def _on_message(self, _client, _userdata, msg) -> None: # paho's network thread: nothing is done here but the handoff. loop, handler = self._loop, self._handler if loop is None or handler is None: _LOG.debug("MQTT command before start-up ignored: %s", msg.topic) return try: asyncio.run_coroutine_threadsafe( handler(msg.topic, bytes(msg.payload), bool(msg.retain)), loop) except RuntimeError as err: # the loop has closed _LOG.debug("MQTT command dropped: %s", err) 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: # ⚠️ TEL-11. A DROPPED ROW IS NOT AN ABSENT ROW. Skipping # leaves Home Assistant holding the last value it was told, # which is what `circulation` wants (its note in main.py # says why) and is exactly wrong for a measurement somebody # will read back as history: a flat line at the last number # is indistinguishable from a real one. See # `ABSENT_AS_UNKNOWN`. if key not in ABSENT_AS_UNKNOWN: continue value = PAYLOAD_NONE 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