checks / unit (pull_request) Successful in 8m6s
OPT-10 measured that this site's pack reaches 90 % SoC on 2 days of 16 and that
20.5 kWh of surplus left the site while the pack had room - 12.6 kWh of it with
the battery not charging at all - and could not say WHY, because PV production
is recorded NOWHERE. Three causes need three different fixes (no irradiance:
nothing to fix; a charge-power limit; the optimiser declining to charge, which
is the only software defect) and guessing between them is how an optimiser gets
tuned to solve a cloud problem. This card adds the channel. It does not do the
attribution.
AN HA ENTITY, NOT A REGISTER, AND THAT IS A PRODUCT DECISION. This add-on's
RS485 bus carries the BATTERY inverter it drives; a household's PV inverter is
normally a different box from a different vendor reporting into Home Assistant
through its own integration. `pv_entity` takes whatever `sensor.*` the site's
own PV integration provides, any brand HA can read.
POSITIVE IS PRODUCTION, AND THERE IS NO `pv_invert`. The convention is derived
rather than assumed: `meter_entity` is positive when IMPORTING and `batt_entity`
is positive when DISCHARGING (DOCS.md, Sources), both of which are "power
arriving at the house's AC bus", and production arrives there too. Those two
keys carry invert flags because they are BIPOLAR and are READ BY THE CONTROL
PATH - `inv.batt` feeds SAFETY-30's per-unit sign check and SAFETY-31's
circulating-power sensor, so a reversed sensor hands the site a plausible number
it acts on. Production is unipolar and is read by nothing: a reversed sensor
announces itself as -4 kW at noon in the very history this card creates, and a
flag would only let an installer hide it. No negative-value alarm either - a PV
inverter's own standby draw makes a small negative reading at night ordinary, so
any threshold would be a number nobody measured.
ABSENCE IS NEVER ZERO, AND "DROPPED" IS NOT ABSENT. `publish()` SKIPS a None,
which is right for `circulation` and exactly wrong here: a skipped row leaves
Home Assistant holding the last number forever and the recorder draws a FLAT
LINE through the outage, which is indistinguishable from a measurement and
manufactures the "there was no surplus" conclusion this card exists to TEST. So
the absence is published, with HA's own payload: `mqtt/const.py` declares
`PAYLOAD_NONE = "None"` and `mqtt/sensor.py` tests it BEFORE the numeric branch,
so the entity's state becomes `unknown` and the recorder stores a GAP. Read out
of home-assistant/core `dev` on 2026-09-24 and quoted at the constant.
`ABSENT_AS_UNKNOWN` is a one-key set and the suite asserts `circulation: None`
is STILL dropped.
THE UNIT IS READ OFF THE ENTITY. `mW`, `W`, `kW`, `MW`, `GW`, `TW` - HA's own
`UnitOfPower`, every factor an exact power of ten. A sensor with no unit, or a
unit outside that table, is REFUSED by name and the status page says which.
`read_inputs()`'s `abs(raw) < 50` kW-or-W guess is safe for a quarter-hour
demand forecast and is NOT safe here: 30 W of dusk is an ordinary reading and
that heuristic would publish it as 30 kW.
GENUINELY OPTIONAL, AND NOT A SECOND `price_now_entity`. validate.py does not
mention the key - it cannot conflict with anything, because nothing else reads
it. The MQTT row lives in a new `OPTIONAL_SENSORS` list whose `include` argument
DEFAULTS TO EMPTY, so every caller that has never heard of `pv_entity` -
test_mqtt.py's fixtures, tools/rehearse_roster.py, anything written next year -
announces exactly what it announced before. Unset: no state call at all, no
entity, no topic, no status row, no page row. Set and failing: a CAUTION
(`ok=False, warn=True`), never a `bad` row, so a telemetry channel can never put
a battery site into NOT READY.
IT MUST NOT BECOME A CONTROL INPUT, AND THAT IS GRADED TWICE. `test_pv.py`
part 6 drives 40 cycles of the real `cycle()` and asserts the commands are
bit-identical across unset / no reading / 0 W / 8 kW, with a COMPUTED census
(33 modules under app/) showing the material is mentioned in exactly three:
pv.py, main.py, mqtt.py. `tools/tel11_pv_trace.py` is the cross-tree half -
`safety56_behaviour_trace.py`'s pattern with a DIFFERENT control clause, because
this card's claim is that nothing differs rather than that only sentences do: it
REFUSES unless the two `app/` fingerprints differ and unless exactly one side
reacts to the option. Measured on 41 stimulus cells:
control release/1.0 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 unset 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
control TEL-11 set 846a10a5b89e321086d535825925721e2f791012e8405948d4fd38d3f9d0be5f
telemetry release/1.0 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 unset a443a6ceaffe072b44bcd5f2ad946c1ed6c9e01b69e278eca631f7f4d7892164
telemetry TEL-11 set 4e6d1cb289a957223ad0205f732a6fec6110ce1f2032c98ad60694e67625274f
UNSET is byte-identical in BOTH documents, which is stronger than "the control
path is untouched": an install that does not set the option sees no change of
any kind, announcement and status rows included.
⚠️ AND THE CARD'S CENTRAL MUTATION WAS A SURVIVOR ON THE FIRST BATTERY.
`self.grid -= self.pv_w` in `read_inputs()` - +106 B, on disk, purged, genuinely
running - came back GREEN, because the fixture drove a 6 kW export over 12
cycles, which puts the loop AGAINST ITS RAIL: a command moving `slew_w` every
cycle produces the same ramp for -6000 W and for -14 000 W of apparent error.
MUTATION.md habit 23 with a saturated ACTUATOR in place of a coinciding value,
and it is in that file now as the habit's second instance. The fixture now
drives a 600 W import the loop SETTLES on, against a responsive meter, and asks
habit 23's own question as its own check: shift the METER by the same 8 kW and
confirm the commands really would have moved.
Evidence: 31/31 suites green in the CI image, checks.yml's exact command, purged
tree, `:ro`. Ten mutations, NO survivors, each on its own `cp -a` of the tree so
habit 18's revert hazard is closed by construction - table in
`DECISIONS/TEL-11-pv-production.md` §7 with byte deltas and sha256s.
⚠️ THIS IS A RUNTIME CHANGE BEHIND AN UNCHANGED VERSION, AND
`tools/predeploy_check.py` WILL REFUSE IT. `app/`, `config.yaml` and
`translations/` are all in its `RUNTIME` tuple and `config.yaml` still reads
0.10.1, so the gate reports a NO-OP deploy until a version is cut. That is PKG's
call and this card does not take it.
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 that
same packaging decision. The operator account is in DOCS.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
501 lines
26 KiB
Python
501 lines
26 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.
|
|
"""
|
|
return ("pv",) if str(opts.get("pv_entity") or "").strip() 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
|