Files
goodwe-addon/firmware/goodwe-master.yaml
T
adminandClaude Opus 5 0a1e61dbc9 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
2026-08-23 01:15:25 +02:00

299 lines
13 KiB
YAML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# STAGE 4: the failsafe - NOTES.md §4.6, §4.4.
#
# Everything up to stage 3 was safe only because a human was watching. §3.6 is why
# that cannot continue: the inverter holds the last command it understood FOREVER,
# and nothing downstream notices the master died. Measured cost of exactly this:
# Illusmart went silent mid-command and the inverter held +5000 W for 113 s.
#
# Three layers, each covering what the previous cannot:
# 1. THIS FILE, stale-input watchdog: HA alive but silent -> command 0 W.
# 2. THIS FILE, wind-down on shutdown: planned reboots and OTA.
# 3. The Pi tap (`rs485_log.py --panic`): this whole board dead -> the tap
# writes 0 W after 30 s of total bus silence. Nothing running on this MCU
# can cover its own death.
#
# ⚠️ CONTRACT CHANGE: HA must now REWRITE the setpoint every ~10 s even when the
# value has not changed. Without a refresh, "stale" and "steady" are the same
# thing on the wire and the watchdog is meaningless. See ha/goodwe-stage4.yaml.
#
# ⚠️ TRANSMIT IS LIVE and this one MOVES POWER.
# ⚠️ NEVER connect this board while Illusmart is still attached.
substitutions:
# ⚠️ Set these THREE things per site and nothing else.
#
# name the ESPHome node name. Changing it after commissioning renames every
# entity in Home Assistant and silently breaks the add-on's config -
# pick it once, write it on the commissioning sheet, never touch it.
# max_w the inverter's continuous rating. This is a HARD limit in the
# firmware, deliberately independent of anything Home Assistant asks
# for, and it is the last line of defence against a controller bug.
# wd_ms how long without a fresh setpoint before the board commands 0 W and
# keeps commanding it. Must stay comfortably above the add-on's
# heartbeat (default 10 s) or a slow network will trip it constantly.
name: goodwe-master
max_w: "5000"
wd_ms: "30000"
esphome:
name: ${name}
on_shutdown:
# Deterministic wind-down. NOT queue_command: the modbus queue may not drain
# before the reset, which made the old version best-effort. These are the
# literal bytes of setpoint_atomic(0) - the frame the inverter has ACKed
# several hundred times - written straight at the UART.
# ⚠️ Do NOT go back to `uart.write:` + `- delay: 50ms`. That version put
# NOTHING on the wire - measured 2026-08-22, an OTA at 300 W left the inverter
# latched for the full 29.75 s reboot. uart.write only fills the driver's TX
# ring, and `- delay:` is an async action whose continuation never runs because
# the reset does not wait for it. flush() (uart_wait_tx_done) is what makes
# this deterministic; the delay() after it is the blocking kind.
- lambda: |-
static const uint8_t wind_down[13] = {
0xF7, 0x10, 0x05, 0x6E, 0x00, 0x02, 0x04, 0x00, 0x03, 0x00, 0x00, 0xA6, 0xD0
};
id(rs485).write_array(wind_down, sizeof(wind_down));
id(rs485).flush(); // blocks until the last bit is out - 13.5 ms @ 9600
delay(20); // blocking delay(), not the `- delay:` action
esp32:
board: esp32dev
globals:
# millis() at the last setpoint HA pushed. 0 means "HA has never spoken since
# boot" and is treated as stale explicitly - see the script, a bare subtraction
# gets this wrong for the first 30 s of uptime.
# uint32_t subtraction wraps correctly, so the 49-day millis() rollover is a
# non-event. Do not "fix" it with a signed type.
- id: last_ha_update
type: uint32_t
restore_value: false
initial_value: '0'
switch:
- platform: gpio
id: rs485_boost
pin: GPIO16
internal: true
restore_mode: ALWAYS_ON
# MAX13487E: AutoDirection, no DE pin. GPIO17 = SHDN must stay HIGH (handing it
# to modbus as flow_control_pin shuts the chip down between frames - verified
# 2026-08-19, silent bus with perfect TX logs). GPIO19 HIGH = AutoDirection RX.
- platform: gpio
id: rs485_shdn
pin: GPIO17
internal: true
restore_mode: ALWAYS_ON
- platform: gpio
id: rs485_re
pin: GPIO19
internal: true
restore_mode: ALWAYS_ON
- platform: template
name: "GoodWe atomic write"
id: atomic_write
optimistic: true
restore_mode: ALWAYS_ON # start atomic; turn OFF for the sequential fallback
number:
- platform: template
name: "GoodWe setpoint W"
id: setpoint
min_value: -${max_w} # the inverter's rating - see substitutions.
max_value: ${max_w} # There is no headroom above this.
# ⚠️ Must stay in step with input_number.goodwe_setpoint
# in ha/goodwe-stage4.yaml - HA clamps SILENTLY, so a
# mismatch shows up as a frame that is quietly wrong.
step: 10 # 10 W, not 50: the 50 W step was the visible
# staircase on the dashboard (§5.11). The register
# itself is 1 W - Illusmart wrote values like -1190.
initial_value: 0
optimistic: true
restore_value: false # every boot starts at 0 W. Deliberate.
on_value:
# Records freshness and NOTHING else. It is safe to have this back only
# because it does not touch the bus: on 2026-08-22 00:09 an on_value that
# *did* write fired before the atomic_write switch had restored and emitted
# the sequential pair (the §5.3 shape). The 5 s interval stays the only
# writer - one writer, no race.
- lambda: |-
id(last_ha_update) = millis();
// Send NOW rather than waiting up to 5 s for the interval - the stage-5
// latency budget cannot afford it (NOTES §4.4b). The interval stays as
// the heartbeat, so this is an extra path, not a replacement.
// has_state() is the guard for the 2026-08-22 00:09 boot race: on_value
// fires before a restored switch has its state, and the setpoint script
// then takes the sequential branch (§5.3 shape). If the switch has not
// settled, skip - the interval sends it a moment later anyway.
if (id(atomic_write).has_state()) id(send_setpoint)->execute();
wifi:
ssid: !secret wifi_ssid
password: !secret wifi_password
logger:
level: DEBUG
api:
reboot_timeout: 0s # never self-reboot on API loss - a reboot mid-test
# would latch whatever was last commanded (§3.6).
ota:
- platform: esphome
# ⚠️ THIS, not on_shutdown, is what actually covers a planned OTA. Measured
# 2026-08-22: OTA blocks the main loop for the whole ~25 s upload, so the bus
# is silent and the inverter stays latched at the old setpoint the entire
# time - a wind-down at shutdown fires ~25 s too late to matter. Both OTAs
# that night were rescued by the Pi tap's 30 s panic write instead.
# on_begin runs before the first flash write, with the UART still alive.
on_begin:
- lambda: |-
static const uint8_t wind_down[13] = {
0xF7, 0x10, 0x05, 0x6E, 0x00, 0x02, 0x04, 0x00, 0x03, 0x00, 0x00, 0xA6, 0xD0
};
id(rs485).write_array(wind_down, sizeof(wind_down));
id(rs485).flush();
delay(20);
uart:
id: rs485
rx_pin: GPIO21
tx_pin: GPIO22
baud_rate: 9600 # §3.1 - do not change
stop_bits: 1
debug:
direction: BOTH # the ACK is the whole result
after:
timeout: 5ms
bytes: 256
sequence:
- lambda: UARTDebug::log_hex(direction, bytes, ' ');
modbus:
id: mb
uart_id: rs485
# NO flow_control_pin - see the SHDN note above.
modbus_controller:
- id: inv
address: 0xF7
modbus_id: mb
update_interval: 10s # slower than the 5 s write cadence: writes keep priority.
# Briefly 1 s on 2026-08-22 for the step-response
# measurement (§5.9); 10 s is the operating value.
setup_priority: -10
sensor:
# §3.3: 0x518 is the inverter reporting ITSELF (r=0.998 with battery_power,
# r=0.09 with the real P1 meter). ×1, SIGNED - reading it unsigned is the
# 65237-for-299 trap that has already caused three separate errors.
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe inverter AC power"
id: inv_ac_power
register_type: holding
address: 0x0518
value_type: S_WORD
unit_of_measurement: W
device_class: power
state_class: measurement
accuracy_decimals: 0
# 3b - the 0x050B block. Offsets from §5: [3] SoC, [6] SoH, [7] mode. 0x0512 is
# independently named battery_mode_code in §3.3, which cross-checks the indexing.
# All three are unsigned; ESPHome coalesces 0x050E..0x0512 into one read.
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe battery SoC"
id: inv_soc
register_type: holding
address: 0x050E
value_type: U_WORD
unit_of_measurement: "%"
device_class: battery
state_class: measurement
accuracy_decimals: 0
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe battery SoH"
id: inv_soh
register_type: holding
address: 0x0511
value_type: U_WORD
unit_of_measurement: "%"
accuracy_decimals: 0
- platform: modbus_controller
modbus_controller_id: inv
name: "GoodWe battery mode code"
id: inv_bat_mode
register_type: holding
address: 0x0512
value_type: U_WORD # 0 no battery / 1 standby / 2 discharge / 3 charge
accuracy_decimals: 0
binary_sensor:
# Layer 3's hook: HA alarms when this goes unavailable. Nothing running on this
# MCU can report its own death, which is the whole reason the Pi tap exists.
- platform: status
name: "GoodWe master status"
- platform: template
name: "GoodWe watchdog tripped"
id: wd_tripped
device_class: problem
script:
- id: send_setpoint
then:
- lambda: |-
// §4.6 watchdog. Note what this does NOT do: stop writing. Stopping is
// the failure mode itself - a silent bus leaves the inverter latched.
// The ==0 arm matters: at boot millis() is only a few thousand, so a
// plain subtraction is NOT stale for the first 30 s of uptime - a
// window where the board would honour a setpoint HA never sent.
bool stale = id(last_ha_update) == 0 ||
(millis() - id(last_ha_update)) > ${wd_ms};
int w = stale ? 0 : (int) id(setpoint).state;
// has_state() matters: publishing only on CHANGE leaves the entity
// `unknown` in HA from boot until the first transition (seen 2026-08-22
// 02:30), so any automation keyed on `from: "off"` would never fire.
if (!id(wd_tripped).has_state() || stale != id(wd_tripped).state)
id(wd_tripped).publish_state(stale);
if (stale) ESP_LOGW("stage4", "HA setpoint stale >30s -> commanding 0 W");
// ⚠️ Clamp to the number's OWN configured range - never a literal. A
// hard-coded ±500 here (2026-08-22) survived raising min_value/max_value
// to ±2000 and silently truncated every frame: HA read 1100 W, the wire
// carried 500 W, and nothing reported a conflict. The loop then saw
// |1100-506| > 500, decided the INVERTER was saturated, and froze - the
// anti-windup firing correctly against a fault that was ours.
const int lo = (int) id(setpoint).traits.get_min_value();
const int hi = (int) id(setpoint).traits.get_max_value();
if (w > hi) w = hi;
if (w < lo) w = lo;
uint16_t dir = (w < 0) ? 2 : 3; // 2 = reported export -> charges; 3 = import -> discharges
uint16_t mag = (uint16_t) abs(w);
if (id(atomic_write).state) {
ESP_LOGI("stage2", "setpoint %d W -> atomic 0x56E={%u,%u}", w, dir, mag);
id(inv)->queue_command(
esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(
id(inv), 0x056E, 2, {dir, mag}));
} else {
// Fallback: direction first (§3.2). Still exposed to the §5.3 reorder -
// only use it if the inverter rejects count=2.
ESP_LOGI("stage2", "setpoint %d W -> sequential 0x56E=%u 0x56F=%u", w, dir, mag);
id(inv)->queue_command(
esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(
id(inv), 0x056E, 1, {dir}));
id(inv)->queue_command(
esphome::modbus_controller::ModbusCommandItem::create_write_multiple_command(
id(inv), 0x056F, 1, {mag}));
}
# Illusmart's own cadence was 4-8 s; degradation below that was its failure tell (§4.7).
interval:
- interval: 5s
then:
- script.execute: send_setpoint