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:
@@ -0,0 +1,298 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user