`self.i_w` has been a separate quantity from the command since SAFETY-04 and has left the process nowhere at all - no entity, no MQTT attribute, no log line. The investigation behind SAFETY-48 could only RECONSTRUCT it from the grid trace plus a replay; the reconstruction reproduced about a third of the engagements it was checked against and was discarded, so the card it was gathered for had to be argued from a measurement nobody could stand behind. It could not have been made to work either: `i_w` depends on every cycle before it, including the ones where the arbiter overrode the loop and `main.py` re-seeded `self.i_w = self.target`, and that re-seed is invisible in the grid trace. THREE SITE ROWS, APPENDED AFTER release/1.0's EIGHT. `loop_i` is `Decision.i_w` - the accumulator after the cycle, before the output clamp, the slew limit and quantisation. `loop_lead` is `decision.i_w - prev_w` against the command `compute()` was HANDED, which is not a subtraction of the other two published rows: `Setpoint` is the arbiter's output and is this cycle's, so on any cycle the arbiter overrode the loop the two differ - and that is exactly the cycle on which the integrator was re-seeded. `loop_reason` is `compute()`'s own word; the arbiter's `explain()` prints the winning claim's NAME and has never carried the claim's reason, so `slew-limited` appears in neither the `Why` row nor `Controller status`. Measured on the same cycle: arbiter `'loop -> -5000 W'`, loop `'i-clamped'`. NOTHING IN THE CONTROL PATH MOVED. `app/control.py` is not in this diff. `compute()`, its returned `Decision` and every constant it reads are identical to release/1.0, and all nine of release/1.0's retained discovery messages are byte-identical and still FIRST in the announcement. Evidence: 30/30 suites green in the CI image. Nine mutations, ONE survivor, reported as one: `decision.i_w - self.target` for the lead is +2 B and green because `cycle()` has not reassigned `self.target` at that line - the name is kept for the coupling, not for a behaviour it buys, and M2b (+8 B) is the mutation that does change the value and is red. AND TWO OF THIS CARD'S OWN CHECKS COULD NOT REACH THE FAILURE THEY NAMED. "the published integrator reaches its bound" was GREEN under the card's named mutation (publish `prev_w`, -3 B), because the command reaches the bound too - twelve cycles later. It asserts the LEAD TIME now and is red on 5. "a stopped controller publishes not-running" was GREEN under deleting that branch's assignments (-45 B, -67 B), because the fixture STARTED stopped and `__init__` writes the same values; it stops the controller part-way through a run that has already wound the integrator to -5000 W. Both are MUTATION.md habit 4, found by the battery rather than by review. No CHANGELOG section: `test_version_gate.py` 11b refuses a newest `## ` heading that is not config.yaml's `version:`, and opening one would mean taking a packaging decision this card does not get to take. The operator account is in DOCS.md and the release note belongs to the next PKG ticket. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
416 lines
21 KiB
Python
416 lines
21 KiB
Python
"""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),
|
|
# 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"),
|
|
]
|
|
|
|
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 discovery_payloads(units=(), omit=()) -> 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.
|
|
"""
|
|
omit = set(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)))
|
|
names = announced_units(units)
|
|
slugs = unit_slugs(names)
|
|
site_slug = _slug(DEVICE["name"])
|
|
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` below can be written once for both.
|
|
device = DEVICE if i == 0 else unit_device(name, slug)
|
|
prefix = f"{_title(name)} " if i == 0 else ""
|
|
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
|
|
unique_id = f"{BASE}_unit_{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)))
|
|
return out
|
|
|
|
|
|
class MqttPublisher:
|
|
def __init__(self, host, port, username=None, password=None, omit=(),
|
|
units=()):
|
|
# `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]
|
|
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:
|
|
# ⚠️ 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.client.publish(topic, 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
|