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
139 lines
6.2 KiB
Python
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
|