checks / unit (pull_request) Successful in 7m59s
0.11.0 shipped `pv_entity` as a single string. The reference site's owner reported within minutes of it going live that they have several PV inverters and could configure one - which is ordinary for a multi-string roof, not a property of one house. `pv_entities` is the roster and the row carries the SITE total. ⚠️ THE HARD PART IS ABSENCE, NOT THE LIST. Three configured, one `unavailable`, and the sum of the other two is a SMALLER NUMBER INDISTINGUISHABLE FROM A REAL ONE. Read back by OPT-10 that says "there was less PV than there was" - the "no irradiance" conclusion the channel exists to TEST. So `pv.total()` answers `None` the moment any configured entity is absent: the topic carries HA's own `None`, the entity goes to `unknown`, the recorder records a GAP. The alternative (the partial sum beside a contributor count) was implemented and run as mutation N1b; it loses because the MQTT row is read COLD a week later by an attribution that would believe it, while the qualifying count would live in a second entity with its own history. The partial information is not thrown away - the STATUS PAGE names every entity that reported, with its watts, and every one that did not, with its reason, because that surface is read LIVE by a human who wants the diagnostic. ⚠️ AND THE MIRROR: a member honestly reporting 0 W is SUMMED. The test is `is None`, never falsiness - a roster that blanked the site on a zero would report an absence on every clear evening. THE SURFACE IS MEASURED, NOT PREFERRED. `pv_entity` could not become a list: tools/supervisor_shape_probe.py §G shows Supervisor 2026.08.0 answering an 0.11.0 install's stored string with `Invalid list for option 'pv_entity'`, and deepmerge's list strategy is `override` so a `[]` default does not rescue it - the live add-on would not start. `match(.*)?` does accept both shapes and is rejected for a different, measured reason: it validates nothing (a string, a list and the integer 417 all pass) and renders as a plain text box. So `pv_entities` joins `pv_entity` and REPLACES it when set, CFG-04's rule for `inverters:`, with a status caution saying which key won - a union would double-count the inverter the old key still names. STILL NOT A CONTROL INPUT. tools/tel11_pv_trace.py was extended rather than replaced and now grades five claims over three document sets. The control hash is UNMOVED - 846a10a5b89e… on `unset` and on `one`, on both trees - and the roster control document is byte-identical between the two trees. 2/5 is a claim TEL-11 could not make: with 0.11.0 as the baseline, "one entity behaves exactly as 0.11.0 does" is a real cross-tree identity over control AND telemetry. ⚠️ TEL-11's OWN `--compare` REFUSAL HAD TO CHANGE, and that is MUTATION.md habit 24: a discriminator defined as "the baseline lacks this feature" dies on the release that ships the feature, and dies as a false refusal. Twelve mutations, each on a fresh purged `cp -a`, no survivors. N5 is +0 B (`sum` -> `max`, the same three characters) and was run on a fresh copy with `cmp` confirming the edit on disk. The fixture is three distinct NON-ZERO contributions in two units with absence driven at EVERY position - a member contributing 0 W could go absent without moving the sum of the rest, which is habit 23 with a coinciding value in the arithmetic rather than in the plant. 31/31 in the CI image. Not deployable as merged: config.yaml stays at 0.11.0, so predeploy_check.py reports a NO-OP until PKG cuts a version. A CHANGELOG entry is wanted and deliberately not written - 11b forbids `## Unreleased` and the bump is PKG's call; DECISIONS/TEL-12-several-pv-inverters.md is the material it needs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
521 lines
27 KiB
Python
521 lines
27 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"),
|
|
]
|
|
|
|
# --- 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 discovery_payloads(units=(), omit=(), include=()) -> 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).
|
|
"""
|
|
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)))
|
|
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)))
|
|
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)))
|
|
return out
|
|
|
|
|
|
class MqttPublisher:
|
|
def __init__(self, host, port, username=None, password=None, omit=(),
|
|
units=(), include=()):
|
|
# `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]
|
|
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.include):
|
|
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:
|
|
# ⚠️ 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
|