name: GoodWe RS485 Controller version: "0.5.1" slug: goodwe_controller description: >- Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart meter, holding net grid exchange at zero and running a monthly battery maintenance cycle. url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon arch: - aarch64 - amd64 - armv7 init: false startup: application boot: auto # Needed to read the meter and to write the ESPHome setpoint number. homeassistant_api: true hassio_api: true # Own diagnostics/operations UI inside HA. This is what a field tech and a # remote supporter both look at, so it is not optional. ingress: true ingress_port: 8099 panel_icon: mdi:battery-sync panel_title: GoodWe # Optional: publish status entities by MQTT discovery. `want` rather than # `need` - the add-on runs fine with no broker, it just publishes nothing. services: - mqtt:want options: # --- sources (required) --------------------------------------------------- meter_entity: sensor.p1_meter_active_power meter_invert: false soc_entity: "" batt_entity: "" batt_invert: false setpoint_entity: "" # --- P1 meter ingestion (specs §5.2 / §14 `meter:`) ------------------------- # `off` keeps the original single meter_entity path above, so an existing # install is untouched until it opts in. ha_dsmr subscribes to the DSMR # integration's entities over the HA WebSocket; mqtt_p1 reads the topic below; # ha_signed reads ONE signed HA entity (+ import / - export), which is what a # HomeWizard P1 publishes and what ha_dsmr cannot consume. # homewizard_local skips Home Assistant entirely and polls the meter's own # local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when # the meter REPORTED rather than when the value last CHANGED - HA emits # nothing at all for a repeated reading - so it is the only mode a firmware # watchdog may threshold. See DOCS.md. meter_source: "off" meter_phases: 1 meter_max_age_s: 30 meter_mqtt_topic: "" # homewizard_local only. The meter's own address, `host` or `host:port`. p1_host: "" # homewizard_local only. Seconds between polls. Must be well under # meter_max_age_s - the age is never fresher than this interval - and there is # nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640). meter_poll_s: 5 # The two UNSIGNED Belgian registers. The EMS derives net power from them # (import - export); do NOT point these at a signed template sensor. p1_import_entity: "" p1_export_entity: "" # Optional, in L1..L3 order. Required for the capacity-tariff peak on a # three-phase connection; the list length must equal meter_phases. p1_phase_import_entities: [] p1_phase_export_entities: [] # ha_signed only. ONE signed net-power sensor: positive = import from the # grid, negative = export to it. Do NOT split it into two template sensors - # the split is done in the add-on (p1.split_signed) precisely so the sign # convention is tested rather than living in unreviewed YAML. p1_net_entity: "" # Optional, in L1..L3 order, each one signed the same way. Same role as # p1_phase_import_entities: the capacity-tariff peak on a three-phase # connection. The list length must equal meter_phases. p1_phase_net_entities: [] # --- control --------------------------------------------------------------- max_w: 2000 gain: 0.6 slew_w: 1000 deadband_w: 15 target_grid_w: -10 step_w: 10 saturation_w: 500 saturation_cycles: 3 integrator_max_w: 0 heartbeat_s: 10 stale_input_s: 15 cycle_floor_s: 15 auto_start: false # --- SAFETY-02 sign-disagreement watchdog ---------------------------------- # Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways, # both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and # a failsafe that latches until an operator starts control again. # # Both defaults are measured, not chosen. On the captured healthy day # (2026-08-23) the longest legitimate opposed run - a load step the loop has # not caught up with - is 30 s, and sign agreement over the gated samples is # 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 % # agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of # a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the # fault day still trips 13 times. There is no reason to move either number # without a capture that says so. They are options because the AC says # thresholds are defaults rather than constants - not because they are knobs # anyone is expected to turn. # # ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the # arbiter bounds the command to +-max_w). validate.py refuses that config. sign_disagree_w: 300 sign_disagree_s: 180 # --- maintenance ----------------------------------------------------------- maintenance_enabled: false maintenance_interval_days: 28 maintenance_start_hour: 10 # ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather # than the 2500 they shipped as. The cheap-window charge shaping asks the # arbiter for `hi = -maintenance_charge_w` against the supervised `lo = # -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to # 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a # default install. validate.py refuses that config (rule 6), so these # defaults had to become self-consistent with the max_w above rather than # ship a configuration the validator rejects. # # The discharge side is clamped rather than contradicted - it drains at max_w # and the arbiter says so - so it is only warned about; it is 2000 here for # symmetry and to keep the shipped config warning-free. # # Raising max_w (the live reference install runs 5000) is what unlocks a # faster maintenance cycle. Raise max_w FIRST, then these. maintenance_discharge_w: 2000 maintenance_charge_w: 2000 maintenance_soc_floor: 11 maintenance_soc_target: 99 maintenance_hold_min: 120 # --- tariff / capacity tariff (all optional) ------------------------------ peak_forecast_entity: "" peak_cap_w: 3500 price_now_entity: "" price_avg_entity: "" # --- site ------------------------------------------------------------------ estop_fitted: false log_level: info schema: meter_entity: str meter_invert: bool soc_entity: str batt_entity: str batt_invert: bool setpoint_entity: str meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local) # ⚠️ 2 is accepted by this range but is not a real Belgian connection. A # telegram whose phase count disagrees is rejected at ingest and logged, so a # mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a # quietly wrong number. meter_phases: int(1,3) meter_max_age_s: int(5,300) meter_poll_s: int(1,60) meter_mqtt_topic: str? p1_host: str? p1_import_entity: str? p1_export_entity: str? p1_phase_import_entities: - str p1_phase_export_entities: - str p1_net_entity: str? p1_phase_net_entities: - str max_w: int(100,5000) gain: float(0.05,1.0) slew_w: int(50,5000) deadband_w: int(0,500) target_grid_w: float(-200,200) step_w: int(1,100) saturation_w: int(100,2000) saturation_cycles: int(1,10) integrator_max_w: int(0,15000) heartbeat_s: int(2,25) stale_input_s: int(5,120) cycle_floor_s: int(5,60) auto_start: bool # No "off" value, deliberately: this is a P0 safety check and the schema is # not the place to make disarming it a supported configuration. sign_disagree_w: int(50,5000) sign_disagree_s: int(30,1800) maintenance_enabled: bool maintenance_interval_days: int(1,90) maintenance_start_hour: int(0,23) maintenance_discharge_w: int(500,5000) maintenance_charge_w: int(500,5000) maintenance_soc_floor: int(5,30) maintenance_soc_target: int(50,100) maintenance_hold_min: int(5,480) peak_forecast_entity: str? peak_cap_w: int(500,15000) price_now_entity: str? price_avg_entity: str? estop_fitted: bool log_level: list(trace|debug|info|warning|error)