T-1, and it was a fleet-wide trip to zero. publish() emitted p1_age unconditionally, and published_age_s counts from P1Ingest.__init__ when no sample has ever arrived. With meter_source defaulting to off, every existing install would have published sensor.p1_sample_age_s climbing without bound; the ESP32 does `has_state() && state >= max_age_s` and forces the layer-1 failsafe, so each of them would have pinned its inverter at 0 W within 30 s. Exactly the opposite of the zero-regression the off default was for. The key is now omitted from the payload AND from MQTT discovery when P1 is off, so the entity does not exist at all - which is the status quo, and what has_state() is testing for. The predicate is one function, is_enabled(), because the grid reading, the task start and the discovery announcement have to agree or this comes back. T-2, connect no longer manufactures a sample. get_states returns whatever HA currently holds, which after a Core restart is a RestoreEntity value of unknown age; stamping it with ingest_ts=now reset the age and reported a fresh meter that could have been dead for an hour. run()'s own docstring already said a reconnect must emit nothing - the code disagreed with it, and a test asserted the violation. The cache is still primed, so the first real state_changed builds a complete sample; the age just stays honest until one arrives. T-3, gaps are no longer filled with the last held value. The averager held a sample forward across any interval, so a meter dying at 5 kW and returning ten minutes later credited 5 kW x 600 s to the capacity-tariff accumulator - a fabricated peak on a permanent record. The hold is capped at max_age_s: past that the stretch is walked so block boundaries still land correctly, but nothing accumulates and elapsed does not grow, which is what finally makes the comment about a gap dragging the billed average down true. Same threshold for control and billing: a reading too old to steer by is too old to bill by. T-4, the out-of-order/duplicate guard is covered. It was untested, and the reason is worth recording: the obvious assertion passes without the guard, because the negative interval is separately refused by the covered > 0 test. What the guard prevents is the timestamp REWIND, which only shows up one sample later as a re-integrated window. The test now goes one sample later. T-6, DOCS was wrong about latency. meter_max_age_s and stale_input_s stack, so meter death to 0 W is 45 s and not 30. Documented as a table with both clocks. Also documented the T-5 asymmetry rather than papering over it: the age measures arrival, not change, so a stuck MQTT bridge republishing its last telegram still looks fresh. Correct on ha_dsmr, not detectable on mqtt_p1 without a change-detector. Written up as a known limit. Writing the T-1 test caught a second defect in the test itself: it recorded only MQTT topics, and object_id lives in the payload, so "the age sensor is not announced" had been passing for the wrong reason. test_p1.py: 99 -> 122 checks. 14 mutations run, all 14 red, files restored byte-identical - including one per fix above. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
146 lines
6.6 KiB
Python
146 lines
6.6 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),
|
|
("phase", "goodwe_maintenance_phase", "Maintenance phase", None, None, None, "mdi:battery-sync"),
|
|
("status", "goodwe_controller_status", "Controller status", None, None, None, "mdi:heart-pulse"),
|
|
# ⚠️ This one deliberately breaks the goodwe_ prefix above: the entity id
|
|
# must be exactly `sensor.p1_sample_age_s`, because SAFETY-01's firmware
|
|
# watchdog subscribes to that literal id and the ENV-01 simulation rig
|
|
# asserts on it. Renaming it silently disarms a safety layer. It is seconds
|
|
# since the newest accepted P1 telegram, republished every second so that a
|
|
# meter frozen at a constant value still shows a climbing age - which is the
|
|
# false-trip that this entity exists to remove.
|
|
("p1_age", "p1_sample_age_s", "P1 sample age", "s", "duration", "measurement", None),
|
|
]
|
|
|
|
BASE = "goodwe_ctl"
|
|
AVAILABILITY = f"{BASE}/availability"
|
|
|
|
|
|
class MqttPublisher:
|
|
def __init__(self, host, port, username=None, password=None, omit=()):
|
|
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
|
|
# sensor that nothing will ever publish to is not harmless here:
|
|
# p1_sample_age_s is a watchdog input, and an entity that exists but is
|
|
# never fed is a worse signal than one that does not exist at all.
|
|
self.omit = set(omit)
|
|
self.enabled = mqtt is not None and bool(host)
|
|
self.client = None
|
|
if not self.enabled:
|
|
_LOG.info("MQTT not configured - status entities will not be published")
|
|
return
|
|
# paho-mqtt 2.x requires a callback API version; 1.x has no such
|
|
# argument and Alpine ships 1.x. Support both rather than pinning, so
|
|
# the container can use the distro package instead of compiling.
|
|
try:
|
|
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
|
|
client_id="goodwe_rs485_controller")
|
|
except AttributeError:
|
|
self.client = mqtt.Client(client_id="goodwe_rs485_controller")
|
|
if username:
|
|
self.client.username_pw_set(username, password or "")
|
|
self.client.will_set(AVAILABILITY, "offline", retain=True)
|
|
# ⚠️ Announce from on_connect, never straight after connect(). paho
|
|
# processes the CONNACK on its network thread, so a publish issued
|
|
# immediately after connect() is made while still disconnected - and
|
|
# paho DROPS QoS-0 publishes when disconnected, silently. The result is
|
|
# an add-on that logs "MQTT connected" and creates no entities at all.
|
|
# As a bonus, this also re-announces after every reconnect.
|
|
self.client.on_connect = lambda *_args, **_kw: self._announce()
|
|
try:
|
|
self.client.connect(host, int(port), keepalive=60)
|
|
self.client.loop_start()
|
|
_LOG.info("MQTT connected to %s:%s", host, port)
|
|
except OSError as err:
|
|
_LOG.warning("MQTT connect failed (%s) - continuing without it", err)
|
|
self.enabled = False
|
|
|
|
def _announce(self) -> None:
|
|
for key, object_id, name, unit, dev_class, state_class, icon in SENSORS:
|
|
if key in self.omit:
|
|
continue
|
|
cfg = {
|
|
"name": name,
|
|
"object_id": object_id,
|
|
"unique_id": f"{BASE}_{key}",
|
|
"state_topic": f"{BASE}/{key}",
|
|
"availability_topic": AVAILABILITY,
|
|
"device": DEVICE,
|
|
}
|
|
if unit:
|
|
cfg["unit_of_measurement"] = unit
|
|
if dev_class:
|
|
cfg["device_class"] = dev_class
|
|
if state_class:
|
|
cfg["state_class"] = state_class
|
|
if icon:
|
|
cfg["icon"] = icon
|
|
self.client.publish(
|
|
f"homeassistant/sensor/{BASE}_{key}/config",
|
|
json.dumps(cfg), retain=True,
|
|
)
|
|
self.client.publish(AVAILABILITY, "online", retain=True)
|
|
|
|
def publish(self, values: dict) -> None:
|
|
if not self.enabled or self.client is None:
|
|
return
|
|
try:
|
|
for key, value in values.items():
|
|
if value is None:
|
|
continue
|
|
self.client.publish(f"{BASE}/{key}", str(value))
|
|
except OSError as err: # pragma: no cover
|
|
_LOG.debug("MQTT publish failed: %s", err)
|
|
|
|
def close(self) -> None:
|
|
if not self.enabled or self.client is None:
|
|
return
|
|
try:
|
|
self.client.publish(AVAILABILITY, "offline", retain=True)
|
|
self.client.loop_stop()
|
|
self.client.disconnect()
|
|
except OSError: # pragma: no cover
|
|
pass
|