Files
goodwe-addon/goodwe_controller/app/mqtt.py
T
glenn schrooyenandClaude Opus 5 2097b7aaf6 TEL-01: P1 ingestion, with the derivation and the age the EMS owns
A Belgian P1 meter publishes two UNSIGNED registers, not one signed figure.
Until now the add-on asked the installer to bridge that gap with a template
sensor, which put the sign convention of the whole control loop in a text box.
This moves it into the EMS: net = import - export, derived once, in one place,
with a test that fails if anyone inverts it.

Two transports behind one contract, chosen by `meter_source`: the HA WebSocket
subscribing to the DSMR integration's entities, and MQTT on a configurable
topic. Everything downstream reads P1Ingest, so switching is a config edit.
`meter_source: off` is the default and keeps the existing meter_entity path,
so no installed system changes until it opts in.

The other half is the timestamp. Every accepted sample is stamped at ingest
with a monotonic clock, `meter_max_age_s` is applied to it, and the age is
published as sensor.p1_sample_age_s for the ESP32's stale-input watchdog. That
entity is recomputed against the clock every second rather than only when a
telegram lands, because HA pushes state only on change: a meter frozen at a
constant reading emits nothing and looks, to anything watching the value,
exactly like a meter that has died. The age tells them apart.

Deliberately absent: any fallback to an inverter-side power figure. The
inverter's own AC power correlates 0.998 with battery power and 0.09 with the
real meter, so failing over to it means regulating against your own output.
A gap stays a gap - a reconnect emits no synthetic sample, and a rejected
telegram never resolves to 0 W or refreshes the timestamp.

Quarter-hour averages are time-weighted over clock-aligned blocks rather than
a mean of samples, so a cadence change cannot bias the capacity-tariff figure,
and only offtake is accumulated so a quarter of pure export averages to 0 kW.
Per-phase import is kept separately: on an unbalanced three-phase load the
phase sum and the connection net are different numbers, and only one of them
is billed.

test_p1.py: 99 checks, runnable with a bare interpreter and no meter. Includes
an end-to-end run of the HA transport against a fake Home Assistant websocket.

Stacked on SAFETY-04; nothing here touches control.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Du77usMj8XNKNFZGmUiWDa
2026-08-24 22:16:34 +02:00

139 lines
6.2 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):
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:
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