# 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