Packaged as a Home Assistant add-on, with a field guide

Turns the reference RS485 controller into something a technician can install
at a client site: a typed config form instead of YAML, an ingress UI that
names misconfiguration in words, and persistent state that cannot be broken by
a timezone.

Why an add-on rather than YAML packages or blueprints:

- Blueprints cannot create helpers, and the maintenance cycle is a state
  machine whose phase and completion date must survive restarts.
- YAML packages need filesystem access, a configuration.yaml edit and a
  restart - none of which belong in a client install.
- Add-ons authenticate with SUPERVISOR_TOKEN, so there is no long-lived token
  to generate, store or leak on someone else's machine.
- Requires HA OS/Supervised. Container and Core installs cannot run add-ons at
  all, which is a market decision, not an oversight.

The control law and the maintenance machine are pure functions with no Home
Assistant imports, and both ship with runnable checks (22 and 22 assertions).
Every assertion corresponds to a rule whose absence caused an observed failure
on hardware - the saturation duration term, the clamp-before-slew ordering, the
deadband, the sign convention.

One behaviour deliberately differs from the implementation it replaces: when
its inputs go missing this commands 0 W rather than replaying the last
setpoint. The reference version kept replaying, which the hardware watchdog
cannot catch - from the ESP32's side, Home Assistant is still talking to it.

Includes the ESPHome firmware (now parameterised: node name, inverter rating,
watchdog timeout) and the optional RS485 e-stop. FIELD-GUIDE.md carries the
commissioning gates, all judged on the wire rather than on how Home Assistant
looks, plus the written statement a site without an e-stop needs signed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
This commit is contained in:
2026-08-23 01:15:25 +02:00
co-authored by Claude Opus 5
commit 0a1e61dbc9
24 changed files with 2737 additions and 0 deletions
+104
View File
@@ -0,0 +1,104 @@
"""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")
DEVICE = {
"identifiers": ["goodwe_rs485_controller"],
"name": "GoodWe RS485 Controller",
"manufacturer": "GoodWe (via RS485 meter emulation)",
"model": "ES/BP series",
}
# (key, name, unit, device_class, state_class, icon)
SENSORS = [
("setpoint", "GoodWe setpoint", "W", "power", "measurement", None),
("grid", "GoodWe grid power", "W", "power", "measurement", None),
("battery", "GoodWe battery power", "W", "power", "measurement", None),
("soc", "GoodWe battery SoC", "%", "battery", "measurement", None),
("phase", "GoodWe maintenance phase", None, None, None, "mdi:battery-sync"),
("status", "GoodWe controller status", None, None, None, "mdi:heart-pulse"),
]
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
self.client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
client_id="goodwe_rs485_controller")
if username:
self.client.username_pw_set(username, password or "")
self.client.will_set(AVAILABILITY, "offline", retain=True)
try:
self.client.connect(host, int(port), keepalive=60)
self.client.loop_start()
self._announce()
_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, name, unit, dev_class, state_class, icon in SENSORS:
cfg = {
"name": name,
"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