name: GoodWe RS485 Controller version: "0.5.2" 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 # --- SAFETY-18 frozen-but-fresh meter detector ----------------------------- # Seconds the meter may keep answering with an UNCHANGED measurement while the # command stands at the rail before the add-on raises an alarm. Both halves # are required: neither one alone is safe, and app/freeze.py carries why. # # ALARM ONLY. It does not command 0 W and does not latch. A false trip parks # the battery; a false notification costs an alert. # # ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the # only transport that reads the meter's own document. On every other source # the status page says NOT COVERED rather than "armed". # # 300 s is measured, not chosen. Over 2399 s of the live house at the # converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean # grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the # LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any # sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and # still inside the 900 s quarter that bills the capacity tariff. meter_freeze_s: 300 # --- 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 # --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge ----- # The peak guard can COMMAND a discharge to hold a billed quarter under # peak_cap_w. Until this card it was bounded from below by nothing but the # pack's own BMS cut-out - a real floor, but the PACK's protection rather than # the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day. # # peak_soc_floor is the percent below which that discharge is not sized. When # it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W # floor), and the add-on raises ONE notification saying the quarter will be # exceeded - failing to shave is never silent, because the euro figure is the # whole point of the guard. # # ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT. # maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the # depth the cycle deliberately goes to, once a month, on purpose. This is the # depth a money-saving discharge may never reach on any day. The right values # differ, and sharing one key would couple two features that want to move # independently. # # ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the # worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the # pack's own cut-out leaves the guard ~1.6 of its worst observed days of room # and still never reaches the BMS. peak_soc_floor: 20 # The pack's OWN cut-out percent - what the BMS enforces whatever is set # above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with # DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below # it: such a floor can never bind, the BMS wins, and the status page would # report an energy backstop that does not exist. bms_soc_floor: 10 # ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the # add-on delegating the whole price question to Home Assistant: main.py reads # both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by # nothing except the maintenance cycle's cheap-window charge floor. That is # all they have ever done. They are kept, because retiring them would take # the maintenance cycle's only price signal away on a live install before # anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage) # are the cards that replace it, and neither has landed. # # ⚠️ They may NOT be combined with price_provider below. Two price sources on # one site, one of which silently wins, is exactly the "double up" the PM # named; validate.py rule 7 refuses that configuration at startup rather than # letting an operator believe the provider they configured is driving # something. See app/validate.py. price_now_entity: "" price_avg_entity: "" # --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) -------------- # `off` is the shipped default and means the §11.4 no-price-data row applies: # static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes # the series yet - OPT-04 is the consumer - so turning this on today fetches, # validates and publishes prices and changes no energy decision. # # ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's # PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two # agree key for key. Do not add a third copy in main.py - CFG-03 is open # because that has already happened three times. # # markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by # the FRAMEWORK, only for the components the chosen plugin declares its feed # does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy # and manual_csv publish a figure that already carries the markup, so setting # it under those is ignored - and logged as ignored, never applied twice. price_provider: "off" price_markup_eur_kwh: 0.03 price_taxes_eur_kwh: 0.06 price_csv_path: "/share/goodwe_prices.csv" price_feed_url: "" price_horizon_h: 48 price_timeout_s: 10 # --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ---------- # §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share # of the installed base is on [a static profile]" - this is what makes those # sites first-class rather than "price_provider: off, good luck". # # A JSON object, on one line, whose periods must tile every day type exactly # once - no gap and no overlap. A gap is REFUSED at startup rather than # defaulted, because an invented price is one an optimiser would trade on. # A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the # add-on derives "fixed" from there being one distinct price, so there is no # box to tick and none to tick wrongly. # # {"periods": [ # {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34}, # {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26}, # {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]} # # ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning - # a period applies on the day the CLOCK reads, so the weekend row above is # required, not decorative. See DOCS.md. # # eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the # dynamic providers are assembled onto. price_markup_eur_kwh and # price_taxes_eur_kwh are NOT added to it. # # ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price # data -> fall back to the static profile"; with this empty there is nothing # to fall back to and a dead feed leaves the site with no tariff at all. # validate.py warns about exactly that. static_profile: "" # §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero - # which by itself strongly favours self-consumption over export. A scalar, not # a second series: no shipped supplier publishes a dynamic injection curve. export_price_eur_kwh: 0.04 # --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) --------------- # Charge in the cheapest published hour, discharge in the dearest, and ONLY # when the spread covers what the round trip costs. Off in the shipped # configuration: this is the first strategy in the add-on that moves energy # against the meter on purpose, so it is armed deliberately or not at all. # # ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the # combinations that cannot work rather than letting them look armed. With # `price_provider: off` and no `static_profile` there is no series to trade # on; with `meter_source: off` the capacity-tariff peak guard never runs, and # §9.1 puts peak shaving above arbitrage - a charge nothing measures is the # exact failure that guard exists to prevent. # # ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The # realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold # below is 0.10, so the verdict is "the firm spread does not cover this # cycle's cost" and nothing happens - correctly. That is a product decision # (this number, and app/price.py's widening factors) and it is open with the # owner; the code is correct at any value and no number was picked to make a # trade appear. arbitrage_enabled: false # §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh # - the wear on the pack plus the conversion losses. A spread below this is # a cycle that loses money and pack life, so it is not taken. cycle_cost_threshold_eur: 0.10 # The reserve arbitrage may not spend, and the headroom it may not overfill. # Deliberately NOT the maintenance_soc_* keys: those describe a monthly # balancing cycle, and pointing a trading strategy at them would couple two # unrelated policies through one number. arbitrage_soc_floor: 20 arbitrage_soc_ceiling: 95 # --- 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) # No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a # supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s # the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged # is 12 W, which is inside the 15 W deadband, so the two terms of the # conjunction are mutually exclusive rather than merely far apart. At 60 s that # figure is 60 W and the argument is gone - only the margin is left. See # app/freeze.py. meter_freeze_s: int(60,3600) 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) # SAFETY-24. No "off" value: a guard that can command a discharge does not get # a supported way to have no energy bound at all. The ranges OVERLAP on # purpose - the schema cannot express "above bms_soc_floor", so validate.py # rule 8 is what refuses the pair, with a message naming both numbers. peak_soc_floor: int(5,60) bms_soc_floor: int(0,50) price_now_entity: str? price_avg_entity: str? # ⚠️ The provider list is written out rather than left as `str`, so the # Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id # against app/price.py's PROVIDERS registry, because sim/inject.py and the # checks reach validate_startup_config() with a dict the Supervisor never saw. # Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere # else, which is AC2. price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv) price_markup_eur_kwh: float(0,1) price_taxes_eur_kwh: float(0,1) price_csv_path: str? price_feed_url: str? price_horizon_h: int(1,72) price_timeout_s: int(2,60) # A JSON string rather than a nested schema, deliberately: it is ONE option # key (§14 has one), it survives config.yaml's flat options block, and # parse_static_profile() accepts an already-parsed dict too, so a future # nested schema is a config.yaml change and no code change at all. static_profile: str? # ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have # gone below zero (you pay to export at midday in summer), and a schema that # bottoms out at 0 would make that site inexpressible. export_price_eur_kwh: float(-1,1) # OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST, # and a negative one is a threshold every spread clears - arbitrage with the # brake removed. app/arbitrage.py refuses it in code too, because # validate_startup_config() is also sim/inject.py's entry point and can be # handed any dict at all. arbitrage_enabled: bool cycle_cost_threshold_eur: float(0,1) arbitrage_soc_floor: int(5,95) arbitrage_soc_ceiling: int(10,100) estop_fitted: bool log_level: list(trace|debug|info|warning|error)