Files
adminandClaude Opus 5 594f8f9fc9 Six silent failures found by installing this on a live system
Migrated the reference site off the YAML packages and onto the add-on. Every
bug below presented identically: the add-on starts, logs "started", serves its
UI, and cannot do its job.

- run.sh needs #!/usr/bin/with-contenv sh. s6-overlay sanitises the environment
  for services, so a plain shebang means SUPERVISOR_TOKEN is absent and every
  Core API call is 401 - while homeassistant_api: true makes permissions look
  granted. Startup now prints the token length and probes the API.
- Supervisor keys the image by config.yaml `version`, so rebuilding without a
  bump reuses the old image. Two fixes appeared not to work because of it.
- Alpine is musl and has no aiohttp wheel on PyPI; deps now come from apk so
  nothing compiles on a client's Pi.
- Alpine ships paho-mqtt 1.x, which has no CallbackAPIVersion. That raised at
  construction and took the control loop down with it - so MQTT setup is now
  wrapped too. Observability must never be able to stop the controller.
- MQTT discovery is published from on_connect: paho silently drops QoS-0
  publishes issued before the CONNACK, so the previous code announced nothing
  while logging "MQTT connected".
- Repeated failures now log once a minute. Six warnings a second rolled the log
  buffer and destroyed the startup diagnostics needed to find the 401.
- auto_start could never fire, because the store's defaults always supplied
  auto: False for the fallback to find.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NckgXecasQb2eSsPYNSW6
2026-08-23 02:25:58 +02:00

131 lines
5.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"),
]
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