name: GoodWe RS485 Controller version: "0.18.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: # ========================================================================== # HARDWARE - the inverter, its actuator and its own sensors # ========================================================================== setpoint_entity: "" soc_entity: "" batt_entity: "" batt_invert: false # ⚠️ EMPTY IS NOT "UNCONFIGURED", IT IS THE SINGLE-INVERTER CONFIGURATION, and # it is what every install that upgrades into this version lands on. An empty # list means THE SIX FLAT KEYS - `setpoint_entity`, `soc_entity`, # `batt_entity`, `batt_invert`, `max_w` and `bms_soc_floor` - DESCRIBE THE # ONE INVERTER: same entities, same rating, same behaviour, no operator # action, which is the only migration story this key was allowed to have. # `build_roster()` reads this and nothing else decides the shape. # # Declared, it REPLACES those six keys rather than extending them. Each entry # names its own actuator, its own rating and its own sensors: # # inverters: # - name: inverter-a # setpoint_entity: number.goodwe_a_setpoint_w # soc_entity: sensor.goodwe_a_soc # batt_entity: sensor.goodwe_a_battery_power # max_w: 5000 # bms_soc_floor: 10 # - name: inverter-b # setpoint_entity: number.goodwe_b_setpoint_w # soc_entity: sensor.goodwe_b_soc # batt_entity: sensor.goodwe_b_battery_power # max_w: 4600 # bms_soc_floor: 10 # # ⚠️ THE 4600 IS INVERTER B'S DATASHEET FIGURE, NOT A MEASUREMENT AND NOT A # PLACEHOLDER. SPIKE-04a (2026-09-04) identified B as a GoodWe GW5048D-ES, # serial `55048ESU22AW0992`, whose datasheet gives Max. Charging / Discharging # Power as 4600 W; nothing will be connected to its MPPTs, so the battery path # is the ceiling and the 5000 VA AC nameplate is not. The 3000 W this example # used to carry was a guess made before the unit was known. ⚠️ It is STILL not # measured: the datasheet prints 4600 W under BATTERY INPUT DATA, so it may be # DC, and `max_w` is graded against 0x0518 - the inverter's own AC power. If it # is DC the AC ceiling is nearer 4324 W. SAFETY-20's `min(max_w, dev_max)` is # what settles that once the device range is read; see goodwe/NOTES.md §1.1b, # which records both readings and picks neither. # # ⚠️ AND THE `bms_soc_floor: 10` BESIDE IT IS FROM THE SAME PLACE - THE # DATASHEET AND THE SERIAL, NOT A PACK ANYONE HAS READ. No second inverter # exists on this site (SPIKE-04, ≈ 2026-09-12 for B); the cut-out must be read # off the pack that is actually installed (register 0x0560 on the reference # unit) before that pack is commanded. It reads 10 here because pack A reads # 10, which makes this example DEGENERATE ON THE FLOOR AXIS: a site whose two # packs floor at the SAME percent cannot separate `min`, `max`, first or last # over the roster, and every SAFETY-32 fixture that DIFFERS the floors differs # it for exactly that reason. Do not "simplify" those fixtures to match this # example. # # ⚠️ NOTHING IS INHERITED FROM THE FLAT KEYS BY AN ENTRY THAT OMITS IT, and # that is the whole of DEV-02 restated as a config rule. `soc`, `batt` and the # rating became per-instance precisely so that no inverter is ever graded # against a sibling's numbers; an entry silently inheriting `soc_entity` would # rebuild that defect in YAML. `name`, `setpoint_entity`, `max_w` and # `bms_soc_floor` are therefore REQUIRED of every entry (the schema below enforces it, and # validate.py rules 8 and 9 enforce it again for the callers the Supervisor never # sees). An omitted `soc_entity`/`batt_entity` means THIS UNIT HAS NO SUCH # SENSOR - `inputs_ok` already stops the site for that - and never means # "use the other one's". # # ⚠️ SAFETY-32: `bms_soc_floor` IS PER ENTRY AND IS REQUIRED, NOT DEFAULTED. # It is the percent THAT pack's own BMS cuts out at, and two batteries of # different models do not share one. A default would be this add-on inventing # a cut-out for a battery it has not been told about, which is the same defect # as inheriting one: the shallower pack gets protected by a floor that belongs # to the deeper pack, and validate.py rule 8 - which refuses a peak_soc_floor # the BMS would win against - would be grading the wrong number. Rule 8 grades # EVERY entry and names the one it refuses. The flat key below is unchanged and # is the single-inverter shape's own cut-out. # # ⚠️ OPT-09: `capacity_kwh` IS PER ENTRY AND IS OPTIONAL, WHICH IS THE # OPPOSITE CHOICE TO `bms_soc_floor` ABOVE AND IS MADE FOR THE SAME REASON. # It is THAT pack's usable capacity in kWh, and it is read for exactly one # purpose: weighting the SPLIT of the site target between packs by ENERGY # headroom rather than by power rating. The two packs here are asymmetric in # OPPOSITE directions - 5000 W / 15.36 kWh against 4600 W / 20.48 kWh, from # the datasheet and the serials - so a split by rating and a split by energy # hand the work to DIFFERENT packs, and the energy one is the question ("which # pack can sustain its share for the hour"). A missing cut-out would have to be # DEFAULTED, and one default for two batteries protects the shallower pack with # the deeper pack's number; a missing capacity has an honest answer instead - # this add-on then has NO OPINION about the split and every unit weighs 1.0, # which is exactly what shipped before this card. ⚠️ IT IS ALL OR NOTHING # ACROSS THE ROSTER: state it on every entry or on none, because a pack # weighted in kWh against a sibling weighing a dimensionless 1.0 is not a # comparison. There is deliberately NO flat `capacity_kwh` - at one inverter # there is no split to weight, so the key would be one an operator can set and # nothing reads. # # ⚠️ AND THE FLAT `setpoint_entity` MUST THEN BE BLANK. Leaving it set while a # roster is declared is two config sites aimed at actuators, one of which # nothing writes; if it names an entity an entry also names, SAFETY-28's # writer census refuses it as two writers on one actuator, and if it names a # different one the add-on would run a site quietly smaller than the config # reads. Rule 9 refuses both rather than picking one. # # ⚠️ THE SHAPE WAS MEASURED, NOT ASSUMED. A list of objects is not used # anywhere else in this file, and whether Supervisor's options validator # accepts one was an open question - `static_profile` carries its structure as # a JSON string in a `str?` for exactly that doubt. It was settled by running # Supervisor 2026.08.0's OWN `AppOptions` validator (the version this site # runs) against both candidates: tools/supervisor_shape_probe.py is the # script, DECISIONS/CFG-04-inverter-roster.md is what it printed and why the # list won. The default `[]` is load-bearing in that record: WITHOUT it the # validator answers `Missing option 'inverters' in root` for every existing # options blob, and the upgrade leaves an add-on that cannot start. inverters: [] estop_fitted: false # OPT-11: battery modes. Off by default, so an install that has not turned # them on behaves exactly as before. When on, each battery gets an on/off # switch and a mode on the Batteries screen, kept in the add-on's store. # DECISIONS/OPT-11-battery-modes.md. battery_modes_enabled: false # OPT-11 P4: a Home Assistant solar forecast (kWh left today; tomorrow's is # optional) and how much of it to count on. Read only with battery modes # on; empty means no day plan (P3 option A). app/solarfc.py lists the # integrations whose sensors fit. pv_forecast_entity: "" pv_forecast_tomorrow_entity: "" # OPT-17: several forecast sensors per day (one per inverter or string), # summed. Use the list OR the single key above, not both. `[]` is # load-bearing, as for `pv_entities`. pv_forecast_entities: [] pv_forecast_tomorrow_entities: [] solar_trust_pct: 70 # TEL-11 - THE SITE'S PV PRODUCTION, IN WATTS, AND IT IS READ BY NOTHING THAT # DECIDES ANYTHING. Optional, empty on every install that does not set it, and # when it is empty the add-on reads no entity, announces no entity, publishes # no topic and shows no status row. # # ⚠️ AN HA ENTITY AND NOT A REGISTER, BECAUSE MOST SOLAR INVERTERS ARE NOT ON # THIS BUS. The RS485 bus this add-on speaks carries the BATTERY inverter it # drives; a household's PV inverter is normally a different box from a # different vendor, reporting into Home Assistant through its own integration. # So this takes whatever `sensor.*` the site's PV integration provides, any # brand Home Assistant can read. # # POSITIVE IS PRODUCTION. An entity that reads the other way round goes in # `pv_invert_entities` below - see DECISIONS/TEL-14-pv-sign.md. # # ⚠️ THE ENTITY'S OWN `unit_of_measurement` IS WHAT IS BELIEVED - W, kW, MW # and the rest of Home Assistant's power units. A sensor with no unit, or with # a unit that is not power, is REFUSED and the status page says which: there # is no magnitude heuristic that can tell 30 W of dusk from 30 kW of noon, and # a factor of 1000 in the one channel that exists to be believed a week later # is worse than a refusal an operator can read. pv_entity: "" # TEL-12 - THE SAME CHANNEL WHEN THE ROOF HAS SEVERAL INVERTERS, WHICH IS THE # ORDINARY CASE AND NOT AN EDGE ONE. A multi-string or multi-orientation roof # routinely carries two or three PV inverters, each publishing its own power # sensor; 0.11.0 shipped `pv_entity` as a single string and the reference # site's owner reported within minutes that they could configure one of # theirs. List them all here and the row carries the SITE total. # # ⚠️ DECLARED, THIS REPLACES `pv_entity` - it does not extend it. Set both and # ONLY this list is read, and the status page says so. That is CFG-04's rule # for `inverters:` applied to the upgrade path this key exists for: the # install being upgraded HAS `pv_entity` set, and an operator who adds all # their inverters to this list while the old key still names one of them would # DOUBLE that inverter under a union rule. Clear `pv_entity` when you fill # this in. # # ⚠️ AND IF ANY ONE OF THEM IS NOT REPORTING, THERE IS NO SITE TOTAL - the row # publishes `unknown` and the recorder records a GAP. It does NOT publish the # sum of the ones that answered: two of three inverters add up to a smaller # number that is indistinguishable from a real one, and a week later that # reads as "there was less sun than there was" - the exact conclusion this # channel exists to TEST rather than to manufacture. The status page names # which entity is missing and why, and lists what the others reported, because # a human reading it live wants the diagnostic; the recorded number does not, # because nobody re-derives a week of history. See # DECISIONS/TEL-12-several-pv-inverters.md. # # ⚠️ THE FIX IS NOT A TEMPLATE SENSOR THAT SUMS THEM. You may legitimately # prefer one, but a template sensor summing an `unavailable` source yields the # partial total silently - the same defect one layer down, in a place this # add-on cannot see or report. # # pv_entities: # - sensor.pv_east_power # - sensor.pv_west_power # - sensor.pv_garage_power # # Each entity's OWN `unit_of_measurement` is read, so a kW inverter beside a W # one sums correctly. Empty is the shipped default and means "use `pv_entity`, # which is also empty" - i.e. no PV channel at all, which is every install. pv_entities: [] # TEL-14. Every PV entity listed here (in `pv_entity` or `pv_entities`) has # its sign flipped, for a kWh meter or power clamp that reads production as # negative. Per entity, because one roster can mix both conventions. `[]` is # load-bearing like `pv_entities: []`; see DECISIONS/TEL-14-pv-sign.md. pv_invert_entities: [] # ========================================================================== # METER - where the grid reading comes from # ========================================================================== # `off` keeps the original single `meter_entity` path, 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.goodwe_p1_sample_age 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_entity: sensor.p1_meter_active_power meter_invert: false meter_phases: 1 # 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 meter_max_age_s: 30 meter_mqtt_topic: "" # 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: "" # 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. 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: [] # 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: [] # ========================================================================== # RATINGS AND LIMITS - what this site is allowed to do # ========================================================================== max_w: 2000 # The pack's OWN cut-out percent - what the BMS enforces whatever # `peak_soc_floor` is set to. 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. # # ⚠️ SAFETY-32 - THIS KEY DESCRIBES ONE PACK, so it is the SINGLE-INVERTER # shape's number and nothing more. Declare an `inverters:` roster and each # entry states its own `bms_soc_floor`; this one is not read at all and is # not inherited by any entry. Rule 8 then grades every unit separately, which # is the whole of that card: one cut-out for two packs of different models # protects the shallower pack with the deeper pack's number. bms_soc_floor: 10 peak_cap_w: 3500 # SAFETY-79: the peak guard aims this many watts under peak_cap_w. peak_margin_w: 50 peak_forecast_entity: "" # 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. # # ⚠️⚠️ SAFETY-25 - WHAT THIS DOES NOT BOUND, AND IT IS THE HALF THE NAME HIDES. # peak_soc_floor BOUNDS THE PEAK GUARD ONLY. IT IS NOT A PACK-WIDE RESERVE. # Ordinary self-consumption - the grid-following loop that holds the meter at # zero - has no SoC floor of any kind, so on a sustained-load evening it will # discharge STRAIGHT PAST this number and go on until the pack's own BMS # cut-out (bms_soc_floor) stops it. Replayed on the committed 2026-08-20 # capture, 19:00-22:00 UTC: a 13.8 kWh pack starting at 21 % ends the evening # at 10.0 %, and 343 of 587 control cycles deliver discharge with the pack # already below this floor. Setting this key higher does not change that by # one tenth of a percent - the same replay at every value the schema accepts # (5..60) ends at the same 10.0 %. # # The reason is structural, not an oversight: the peak guard files a `lo` # limit (a LOWER bound on the setpoint), which is what makes it provably # unable to cap a discharge or contradict the arbiter - and therefore also # unable to stop one the loop itself wants. See peakguard.py's module note and # DECISIONS/SAFETY-25-loop-soc-floor.md, which records the options for a # loop-side reserve and is waiting on the product owner. Until that is # answered, the BMS is the only floor under self-consumption. peak_soc_floor: 20 # ========================================================================== # TARIFF - what energy costs, and whether to trade on it # ========================================================================== # `off` is the shipped default and means the §11.4 no-price-data row applies: # static profile, plan provisional, NO ARBITRAGE. # # ⚠️ SELECTING A PROVIDER IS WHAT STARTS THE FETCH, AND IT IS NOT # `arbitrage_enabled` (TARIFF-10). With a provider set and trading off - the # shipped default - the add-on fetches the feed every 30 minutes, validates # it, records the day in /data/price_history.json and reports what it holds # on the *Price history* status row. It changes NO energy decision: the cheap # window the maintenance cycle reads still comes from `static_profile`, and # §10.1's arbitrage is gated on `arbitrage_enabled` alone. Until TARIFF-10 # the fetch was gated on `arbitrage_enabled` too, so this option did nothing # whatsoever on a shipped install - the store was never even created. # # ⚠️ 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. manual_csv carries an all-in figure, so setting # either under it is ignored - and logged as ignored, never applied twice. # `custom_formula` (TARIFF-15) carries energy AND markup - your contract's # formula IS the markup - so it takes price_taxes_eur_kwh only, and your # price_markup_eur_kwh is ignored under it, with a line in the log saying so. price_provider: "off" price_markup_eur_kwh: 0.03 # TARIFF-18. The all-in Flemish non-energy figure, EUR/kWh, incl. 6 % VAT. # Its components are named in app/price.py's FLEMISH_TAXES_COMPONENTS_EUR_KWH # and test_price.py section 1b re-adds them: # # Fluvius distribution (Antwerpen) 5,35 c EUR/kWh # Bijzondere accijns, "Alle verbruik" 4,876 # Bijdrage groene stroom en WKK Vlaanderen 1,51 # Energiebijdrage (0 EUR/MWh since 2026-08-01) 0 # ------ # 11,736 c EUR/kWh = 0.11736 # # ⚠️ THIS IS PER-DSO AND YOURS IS PROBABLY DIFFERENT. Only the distribution # row moves: across the eight Fluvius DSOs it runs 5,28 c EUR/kWh # (Midden-Vlaanderen) to 6,70 (West), so the Flemish total runs about 0.117 # to 0.131. Antwerpen is near the LOW end, which is the direction this # default should err in - it is inert on which hour is cheapest, but §17's # negative-price guard reads the ABSOLUTE all-in price, and a figure set too # high would carry a genuinely negative hour above zero. # # ⚠️ WALLONIA AND BRUSSELS DO NOT BELONG IN THIS SCALAR AT ALL. Their # distribution tariff has a day and a night band; use price_taxes_profile. price_taxes_eur_kwh: 0.11736 # §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero - # which by itself strongly favours self-consumption over export. This is the # FLAT injection price, for a site paid the same for every exported kWh. # # ⚠️ THE REASON THIS COMMENT USED TO GIVE - "no shipped supplier publishes a # dynamic injection curve" - WAS FALSE, and TARIFF-07 struck the same # sentence from app/price.py and DOCS.md without reaching this file. Eneco # publishes `0,1 x BELPEX-H - 1,188` c EUR/kWh on the same card as its # consumption formula, ENGIE publishes `(B x eSpot_15) - A`, and Aspiravi # `0,07 x Belpex - 2`. A site on one of those sets price_export_formula # below; this scalar stays the fixed-product default and is what every site # uses today. export_price_eur_kwh: 0.04 # TARIFF-16. The INJECTION leg as a formula, in price_formula's own grammar # and with its OWN coefficients: Eneco uses 0,102 on consumption and 0,100 on # injection, and injection is VAT-EXEMPT where consumption is at 6%, so # `vat_pct` here is normally 0. A negative `offset` is expected - it is what # the published lines carry - and the resulting price legitimately goes below # zero in the midday hours a PV household exports in. # # ⚠️ NO STRATEGY IN THIS ADD-ON READS IT. arbitrage.py prices the sell side # as displaced IMPORT and compares two all-in CONSUMPTION prices; it does not # read the injection price at all. This option is what the operator is shown # and the shape a future export-aware strategy would rest on. Empty by # default, which leaves every existing site on the scalar above. price_export_formula: "" # §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: "" # TARIFF-17. THE TAXES COMPONENT AS A DAY/NIGHT TABLE, for the sites whose # network tariff actually has one. "" means the flat price_taxes_eur_kwh # above decides, which is what every install has had until now and is CORRECT # IN FLANDERS: Fluvius distribution is a single rate, and its row on the # tariff card offers only `Normaal | Uitsl. nacht` - exclusive night being a # separate physical circuit, not the night register of a dual-rate meter. # # The Walloon and Brussels DSOs are on the same card with FOUR columns - # `Enkelvoudig | Dag | Nacht | Uitsl. nacht` - and the gap is real money # (c EUR/kWh): ORES dag 13,27 / nacht 7,39; AIESH 15,17 / 8,21; Tecteo RESA # 12,20 / 7,01; Sibelga 9,39 / 7,10; ORES IMPACT 2026 16,578 / 5,090. # # ⚠️ WHY THIS IS NOT COSMETIC. The optimiser reads the SPREAD (max - min) and # which hour is cheapest. A flat number is additive and identical every hour, # so it cancels out of both - it cannot distort anything, and it also cannot # express this. These bands are ANTI-CORRELATED with what the battery wants # to do (cheap network hours are cheap spot hours), so flattening them # UNDERSTATES the true spread and declines cycles that would have paid. See # DOCS.md, "The three buckets". # # Same JSON grammar as static_profile above - the same tiling rule, the same # day selectors, the same DST handling, because it is literally the same # parser. ⚠️ THE VALUE IS THE TAXES COMPONENT ALONE, NOT AN ALL-IN PRICE: # # {"periods": [ # {"name":"dag", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.1327}, # {"name":"nacht","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.0739}, # {"name":"nacht","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.0739}]} # # ⚠️ SET THIS *OR* price_taxes_eur_kwh, NOT BOTH. Two writers of one quantity, # one of which silently wins, is refused at startup: leave the scalar at its # shipped default when you fill this in. A scalar still sitting on the # SUPERSEDED 0.06 is accepted too (TARIFF-18 moved that default, and your # stored options carry the old number whether you typed it or not) - the # refusal is about a figure you chose, not about one this add-on wrote. price_taxes_profile: "" # TARIFF-15. YOUR OWN TARIFF CARD, as two fixed coefficients. Used only when # `price_provider: custom_formula`; "" is unset. # # A Belgian dynamic contract prices energy as `B x index + A`, and the two # coefficients exist in exactly ONE place - the tariff card in your # klantenzone. There is no machine-readable source for them anywhere (CREG's # tariff-card pages 404, CREG Scan is a JS wizard, VREG was unreachable), so # no plugin and no scraper can ever know YOUR numbers. You are the only party # who can see them. # # A JSON object, on one line. You type what your card prints, IN THE CARD'S # OWN UNITS; every unit conversion and the VAT multiplication are the # add-on's job, because those are exactly the two steps that silently cost a # factor of 10, 100 or 1.06. # # {"index":"epex_be","resolution":"hourly","factor":0.102,"offset":1.00, # "units":"c_eur_per_kwh","vat_pct":6, # "card":{"month":"2026-09","index_eur_per_mwh":133.9905, # "expect_incl_vat":15.55}} # # price = (factor x index_eur_per_mwh + offset) x (1 + vat_pct/100), in # `units`, converted to EUR/kWh by the add-on. # # ⚠️ THE `card` BLOCK IS REQUIRED AND IT IS THE SAFETY MECHANISM. Your card # prints the formula, the reference index it assumed AND the resulting price. # At startup the add-on recomputes that printed price from the numbers you # typed and REFUSES TO START unless the two agree to within 0.01 - naming the # discrepancy. That catches VAT left out or applied twice, a transposed # digit, a unit MISMATCH between the coefficients and the printed price # (10x/100x/1000x), and the expensive one: NETWORK CHARGES FOLDED INTO # `offset`. A unit error applied to BOTH sides at once cancels out of that # comparison; the add-on refuses that separately, by checking the card's own # point against the band a Belgian retail tariff can occupy. Your card's formula line is the ENERGY component # alone; the add-on adds price_taxes_eur_kwh on top, so folding ~12 c/kWh of # network charge into `offset` would bill you for it twice - and it cannot # pass, because it moves the computed 15.55 to about 27. # # ⚠️ AND THE LIMIT OF THAT CHECK, IN THE SAME BREATH: it grades your # COEFFICIENTS AGAINST YOUR CARD, not your card against reality. Two numbers # copied off the wrong product agree with each other perfectly and pass. # # ⚠️ FIXED COEFFICIENTS ONLY - the add-on evaluates NO expression of any kind # and there is no field that takes one. An unknown key is refused rather than # ignored. See DOCS.md for why (it is not only security: two coefficients can # be graded against your card and an arbitrary expression cannot be graded # against anything). price_formula: "" price_csv_path: "/share/goodwe_prices.csv" # ⚠️ AN OVERRIDE IS THE EXACT URL (TARIFF-09). The plugin sends it verbatim # and appends nothing - including the `start`/`end` date range it derives from # the horizon for its own feed - so an override gets whatever range that URL # answers, which for a bare day-ahead endpoint is ONE Brussels day. Every hour # past it is then carried from yesterday and flagged provisional, and §8.3 # keeps provisional hours out of the trade set. The plugin logs a WARNING # naming that consequence on every fetch that uses an override; clearing this # option is what restores the full horizon. price_feed_url: "" # PRICE-01. The bearer token for the owner's price server # (`price_provider: price_service`). A password field. Empty with any other # source. Never printed: not in the log, the status page or a notification. price_service_token: "" price_horizon_h: 48 price_timeout_s: 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`. 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: "" # 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 IS NOT WHAT TURNS THE PRICE FEED ON (TARIFF-10). `price_provider` # above decides whether prices are FETCHED; this decides whether they move # energy. Leaving this off on a site with a provider configured is a # supported, documented state: prices are fetched, validated and recorded, # and nothing trades. # # ⚠️ 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 # ========================================================================== # MAINTENANCE - the monthly battery recalibration cycle # ========================================================================== # ⚠️ CFG-03. THESE EIGHT LINES ARE THE SOURCE OF TRUTH FOR THE MAINTENANCE # DEFAULTS. The Supervisor reads this file and the operator edits this file, # so nothing else in the add-on gets an opinion about what a missing # `maintenance_*` key means. There is exactly ONE in-code copy - # `app/maintenance.py`'s `MAINT_OPTION_DEFAULTS` - because the Dockerfile does # not put this file in the image and the running add-on therefore cannot read # it; test_maintenance.py pins that copy by READING these lines, so a drift # goes red in CI rather than shipping. main.py and validate.py both read the # mirror and type no number of their own. # # Before this card the same defaults were written down three times and had # already disagreed: this block said 2000 while main.py and MaintConfig said # 2500, so an options dict with the key ABSENT passed validation at 2000 and # then ran at 2500 against a 2000 W ceiling - a silent 0 W arbiter # contradiction. Changing one of these numbers now moves every consumer. maintenance_enabled: false maintenance_interval_days: 28 # MAINT-10: a LOCAL hour, in Home Assistant's time zone. maintenance_start_hour: 19 # ⚠️ 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 # ========================================================================== # SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change # one without re-deriving it # ========================================================================== # 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 # ⚠️ A DIFFERENT COMPARISON, NOT A SECOND COPY OF THE ONE ABOVE, AND IT IS # ARMED ONLY WHEN THIS SITE HAS MORE THAN ONE INVERTER. The pair above grades # the SITE's total command against P1 - one meter, one grid connection. This # pair grades EACH inverter's own share against THAT inverter's own measured # power (holding register 0x0518), and it catches the fault the meter cannot # answer for once there are two units: a unit doing the opposite of what it # was told, while its sibling covers for it and P1 reads exactly what a # healthy site reads. # # At ONE inverter these two keys do nothing at all. §6.2's identity makes the # site's total command that inverter's command, so the pair above is already # asking this question of the same actuator, and arming a second detector # would be two latches and two banners for one fault. # # Both defaults are measured on admin/goodwe's two-master rig, over 1890 # closed-loop runs in which nothing is faulty, swept across the whole range # this file accepts for `gain` and `slew_w`. 250 W is `saturation_w / 2`, so # the smallest divergence this can see is exactly the gap at which the control # law itself already says the inverter is not following; 85 s is 8.5x the # worst legitimate opposition those runs produce (10.0 s), 2.8x the firmware's # own 30 s link veto, and under half the 180 s the site check takes. See # app/watchdog.py's SIGN_OPTION_DEFAULTS and # DECISIONS/SAFETY-30-per-unit-sign.md. # # ⚠️ unit_sign_disagree_w must stay BELOW max_w, for sign_disagree_w's reason: # a share is bounded to +-max_w, so a threshold at or above it can never fire. # validate.py refuses that config. unit_sign_disagree_w: 250 unit_sign_disagree_s: 85 # ⚠️ THE SAME COMPARISON `sign_disagree_*` MAKES, AT THE ROSTER WHERE IT STOPS # BEING A STATEMENT ABOUT ONE INVERTER. At two or more units the SUM of the # standing commands is graded against P1, and the trip stops the whole site - # what it catches is the site being asked for the wrong thing, which every # per-inverter check is blind to because each unit is faithfully executing its # share. At ONE inverter these two keys do nothing: `sign_disagree_*` above is # already asking this of the same actuator. # # The threshold is the shipped 300 W, CARRIED, and the reason is that the # argument behind it does not depend on the roster: near zero the sign is # noise, the deadband is 15 W, and there is one P1 whatever is behind it. # # ⚠️ THE WINDOW IS NOT CARRIED, AND THAT IS A MEASUREMENT. The transient 180 s # exists to reject is the loop crossing zero after a load reversal, and the # command has to travel the whole SITE rail at `slew_w` per cycle - a rail # that is the roster sum while `slew_w` is not. On 1890 two-inverter runs in # which nothing is faulty, the worst continuous legitimate opposition at 300 W # is 110 s at one inverter and 215 s at two: carrying 180 s across would have # shipped a false trip reachable at `gain: 0.05`, `slew_w: 50` and an ordinary # 2.2 kW load reversal. 325 s is 1.51x that, inside the 900 s billing quarter, # and still inside every episode of the 2026-08-20 fault day. site_sign_disagree_w: 300 site_sign_disagree_s: 325 # 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 # ⚠️ CFG-03. THESE TWO LINES ARE THE SOURCE OF TRUTH FOR THESE DEFAULTS, and # `app/circulation.py`'s `CIRC_OPTION_DEFAULTS` is the ONE in-code copy - the # Dockerfile does not put this file in the image, so the running add-on cannot # read it. test_watchdog.py pins the copy by READING these lines, so a drift # goes red in CI rather than shipping. Add a third option in three places - # options:, schema:, CIRC_OPTION_DEFAULTS - and in no fourth. # # Circulating power is sum(|Bi|) - |sum(Bi)| over MEASURED per-inverter power: # two inverters pushing against each other, which NETS TO ZERO AT THE METER # and is therefore invisible to every other guard in this add-on. Both numbers # below were measured on the TEST-19 two-master rig over 924 closed-loop runs # in which nothing was faulty; circulation.py carries the curve they were read # off and the argument for each. They are NOT sensitivity preferences. # # Both are ignored at one inverter, where the quantity is identically zero. circulation_w: 700 circulation_hold_s: 75 # ⚠️ CFG-03. THIS LINE IS THE SOURCE OF TRUTH FOR THIS DEFAULT, and # `app/delivery.py`'s `DELIVERY_OPTION_DEFAULTS` is the ONE in-code copy - # the Dockerfile does not put this file in the image, so the running add-on # cannot read it. test_delivery.py pins the copy by READING this line, so a # drift goes red in CI rather than shipping. Two places - options:, schema: - # plus that one dict, and no fourth. # # ⚠️ THERE IS NO `delivery_w` TO GO WITH IT, AND THAT IS DELIBERATE. The watts # half of this check is `saturation_w`, the option two blocks down, read # directly: it is already the gap at which the control law itself concludes # the inverter is not following, and a second key would be a fourth place a # threshold has to be kept in step and a way for the two to disagree. So # RAISING `saturation_w` raises this check with it, and test_delivery.py part # 4b holds that promise by running the same fault at two different # `saturation_w` values and requiring two different answers - a check that # only ever ran at 500 could not tell this wiring from a hard-coded number. # # How long ONE inverter may stand at a discharge share it is not producing - # short by more than `saturation_w`, OR short by more than a fixed FRACTION # of its own share (SAFETY-43; the fraction is derived and is not an option, # see app/delivery.py) - while at least one sibling is NOT in that state, # before the add-on names it. It REPORTS: nothing is commanded # differently, the loop is untouched, and the site goes on running short by # that unit's share until somebody looks. app/delivery.py carries why, with # the rig measurements on both sides of the decision. # # Ignored at one inverter, where the check is inert by construction: with no # sibling, "this box cannot deliver" and "this plant cannot deliver" are the # same observation. # ⚠️ MEASURED ON THE TEST-19 TWO-MASTER RIG, NOT CHOSEN. 2640 closed-loop runs # at a roster of two in which nothing was faulty, over 6 gains x 5 slews - the # whole range this schema accepts - across load reversals, a flapping load and # ASYMMETRIC SPLITS, plus a roster-of-ONE arm as the control. At the shipped # threshold the worst CONTINUOUS legitimate episode is 35.0 s (the weighted # family; 30.0 s on reversals, 0.0 s on flapping, and 0.0 s at every threshold # in every one-inverter run). 185 s is 5.29x that. # # ⚠️ AND THE CAMPAIGN IS RE-RUNNABLE NOW, WHICH IT WAS NOT WHEN THIS NUMBER # SHIPPED. TEST-21 (2026-09-07) committed the harness to the OTHER repository, # `admin/goodwe:sim/delivery_window.py` - it imports plantsim and frame_check, # which that repo owns. From clean checkouts of both, side by side: # # cd goodwe/sim && GOODWE_ADDON_REPO=/path/to/goodwe-addon \ # python3 -B delivery_window.py --selftest --campaign --blag # # ⚠️ THE ORIGINAL RECORD SAID 30.0 s AND IT DID NOT REPRODUCE - the re-run # says 35.0 s. 185 s still clears all four bounds and is unchanged; what # changed is the claim. DECISIONS/SAFETY-42-unit-delivery.md §3.3 carries both # tables and what did and did not reproduce. # # ⚠️ AND THE MARGIN IS A SENSITIVITY STUDY ON A HYPOTHESIS, NOT A MEASUREMENT, # BECAUSE INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). The rig's B is A # COPIED. Sweeping a DIFFERENT second unit through that campaign: the worst # legitimate episode barely moves with B's ramp (35.0 s at half A's ramp) and # scales with its LAG - 45.0 s at twice A's measured 10 s lag, 75.0 s at four # times it. 185 s is 2.47x even the four-times-slower case. NO NUMBER HERE IS # A MEASUREMENT OF INVERTER B. # # DELIBERATELY NOT 180 - that is SAFETY-02's site hold and this is not it - # and not 150 or 200. 37 control cycles. delivery_hold_s: 185 # SAFETY-10. Run the whole decision path and command NOTHING - strategies, # arbiter, clamps and guards all run, the decision and its reason are # recorded, and every inverter is held at 0 W. `app/shadow.py` carries the # argument; the three things to know before changing this line are: # # auto shadow WHILE CONTROL IS STOPPED, live once it is started. This is # the shipped value and it is what "default-on" means here. # on always shadow, whatever the Start / stop button says. This is the # one an operator picks to watch a day of decisions on an armed site # before letting it trade. # off ordinary control. # # ⚠️ IT IS NOT A BOOLEAN DEFAULTING TO `true`, AND THAT IS THE WHOLE CARD. # The Supervisor composes an add-on's options as `{**these defaults, **the # stored user options}`, so a NEW key with a new default reaches every # EXISTING install on the next update - including one regulating a real # battery. `shadow_mode: true` would silently stop it, and the only symptom # would be a battery that has stopped doing anything. There is no mechanism # in this options system that lets one key default differently for a new # install and an existing one, so "default-on for new" has to be a value the # add-on RESOLVES. `auto` is that value: a fresh install ships # `auto_start: false` and therefore comes up in shadow mode; an install that # is controlling is started, so `auto` resolves to live and NOTHING about it # changes. # # ⚠️ AND UNDER `auto` THE START BUTTON LEAVES SHADOW MODE. Pressing *Start / # stop control* is the operator saying "control the battery". Somebody who # wants the decisions recorded WHILE the loop runs sets `on` explicitly - # which is the configuration this option exists for, and the one that cannot # be reached by accident. shadow_mode: "auto" # ========================================================================== # ADVANCED - the control law and diagnostics # ========================================================================== auto_start: false gain: 0.6 # ⚠️ SAFETY-39. 0 IS NOT "UNSET" AND IT IS NOT "NO LIMIT" - IT IS "FOLLOW THE # RAIL", and it is the shipped value. `control.slew_limit_w()` resolves it to # `SLEW_RAIL_FRAC * max_w`, which is 222 W against the 2000 W default here, # 556 W against the live install's 5000 W, and 1067 W against the ~9600 W a # second inverter makes of the site. `integrator_max_w: 0` two lines down has # meant exactly this since it was added; this key now means it too. # # ⚠️ WHY IT IS A RATIO AND NOT A NUMBER, WHICH IS THE WHOLE OF THIS CARD. It # WAS a number: 1000, derived 2026-08-22 against a clamp of 1500 W # (goodwe/NOTES.md, "Simulated 2 kW load step (gain 0.6, clamp 1500)") on the # grounds that it caps "how far the loop can jump ahead of the inverter". The # rail then went to 5000 on the live install and the constant did not move, so # 1000 W per 2 s tick crossed the whole output range in 10 s against a plant # NOTES.md §5.10 measured at ~18 s up / ~23 s down. Measured on the owner's # system 2026-09-05 11:43-11:46: a 3.4 kW export drove the setpoint 380 -> 5000 # in five ticks, the battery to -5154 W past a 5000 VA rating, and the grid # from 3.4 kW of EXPORT to 2.8 kW of IMPORT. Large (>= 900 W) steps per 30 min # went 2 -> 1 -> 13 -> 43 that morning. Nothing was broken; the rail moved. # # An explicit number is still accepted and is not clamped - the live install # has run 400 since 2026-09-05 12:00, which is slower than the rail implies # and is fine. What validate.py rule 10 refuses is a number FASTER than the # rail implies, because at that setting the stated limit is not the limit and # the layer that overrules it is the plant, which has no status row. # # ⚠️ AND THE COST IS REAL AND IS NOT HIDDEN: following the rail is slower to # converge than 1000 was. Measured over 51 export steps on the in-repo model # (test_control.py) the largest tick step falls from 1000 W to 556 W and the # >= 900 W step disappears from every run - but the OVER-ABSORPTION does not # move, because that is a `gain` term and not a slew term. See # DECISIONS/SAFETY-39-slew-against-the-rail.md, which says so at length rather # than letting this card be read as more than it is. slew_w: 0 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 log_level: info schema: # ========================================================================== # HARDWARE - the inverter, its actuator and its own sensors # ========================================================================== setpoint_entity: str soc_entity: str batt_entity: str batt_invert: bool # CFG-04. THE ONE LIST-OF-OBJECTS IN THIS FILE, and see the options block for # the measurement that says Supervisor 2026.08.0 accepts it: nested types are # enforced per entry, a missing required key names the key, a scalar where the # list belongs is refused, and the UI renders it as a repeatable section. # # ⚠️ AND OPT-09's `capacity_kwh` IS DECLARED OPTIONAL (`?`), UNLIKE # `bms_soc_floor`, WHICH IS THE ARGUMENT IN THE OPTIONS BLOCK ABOVE: an # optional cut-out is a defaulted one and a default is one number for two # batteries, whereas an absent capacity has an honest answer - no opinion # about the split, every unit weighs 1.0, which is what shipped. # # ⚠️ FOUR KEYS ARE REQUIRED PER ENTRY AND THAT IS DELIBERATE. An entry with # no `name` has no Store namespace and would share another unit's SAFETY-02 # latch; one with no `setpoint_entity` is a unit nothing can command; one with # no `max_w` would have to borrow a rating, which is the sibling-grading # defect DEV-02 removed; `bms_soc_floor` is SAFETY-32's (options block above). # The optional keys are the sensors, whose absence honestly means "this unit # reports none", and `capacity_kwh`. # # ⚠️ THIS BLOCK IS DECLARED BEFORE `max_w:` ON PURPOSE. `max_w` now appears # TWICE under `schema:` - once nested here, once flat below - and a reader # that matches a stripped line takes the nested one FIRST. That is the # first-match trap this card had to fix in three checks; leaving the nesting # first keeps the trap baited, so the fix stays graded rather than assumed. # test_inverter.py part 12 asserts the ordering is still hostile. inverters: - name: str setpoint_entity: str max_w: int(100,5000) bms_soc_floor: int(0,50) soc_entity: str? batt_entity: str? batt_invert: bool? capacity_kwh: float(0.1,200)? estop_fitted: bool battery_modes_enabled: bool pv_forecast_entity: str? pv_forecast_tomorrow_entity: str? # OPT-17: new list keys beside the single ones. A list schema on the old # keys refuses the string an install stores (tools/supervisor_shape_probe.py # section J); DECISIONS/OPT-17-several-forecast-entities.md. pv_forecast_entities: - str pv_forecast_tomorrow_entities: - str solar_trust_pct: int(30,100) # TEL-11. Optional, and OPTIONAL WITHOUT A COMPANION RULE - unlike # `price_now_entity`/`price_avg_entity`, which are legal only while # `price_provider` is `off` and make the add-on REFUSE TO START otherwise. # Nothing in validate.py mentions this key: it cannot conflict with anything, # because nothing else reads it. pv_entity: str? # TEL-12. ⚠️ A SECOND KEY RATHER THAN A LIST SHAPE ON `pv_entity`, AND THAT IS # A MEASUREMENT AND NOT A PREFERENCE. `pv_entity` shipped in 0.11.0 as `str?` # and the reference site has a string persisted in it. Declared as a list, # Supervisor 2026.08.0 answers that stored string with # # Invalid list for option 'pv_entity' in GoodWe RS485 Controller # # and `deepmerge`'s list strategy is `override`, so a shipped `[]` default does # NOT rescue it - the operator's string stands and is refused. That is CFG-04 # §B's trap in the other direction: an add-on that will not start, and the # schema needed to fix it pinned to the version that cannot be installed. The # only single-key spelling Supervisor accepts for BOTH shapes is `match(.*)?`, # which validates nothing at all (it admits a number, a dict, any list of # anything) and renders as a plain text box. Measured, both of them, by # `tools/supervisor_shape_probe.py` §G - the transcript is in # DECISIONS/TEL-12-several-pv-inverters.md §1. # # ⚠️ AND `pv_entities: []` IN THE `options:` BLOCK IS LOAD-BEARING, exactly as # `inverters: []` is. §G measures the same isolated counterfactual: the # shipped defaults MINUS only this key are refused with `Missing option # 'pv_entities' in root`, which is every options blob written before 0.12. # `p1_phase_import_entities` is the shipped precedent for the pair. pv_entities: - str # TEL-14. A parallel list, not a prefix or a dict: measured by # tools/supervisor_shape_probe.py section H, DECISIONS/TEL-14-pv-sign.md. pv_invert_entities: - str # ========================================================================== # METER - where the grid reading comes from # ========================================================================== meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local) meter_entity: str meter_invert: bool # ⚠️ 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) p1_host: str? meter_poll_s: int(1,60) meter_max_age_s: int(5,300) meter_mqtt_topic: str? p1_import_entity: str? p1_export_entity: str? p1_net_entity: str? p1_phase_import_entities: - str p1_phase_export_entities: - str p1_phase_net_entities: - str # ========================================================================== # RATINGS AND LIMITS - what this site is allowed to do # ========================================================================== max_w: int(100,5000) bms_soc_floor: int(0,50) peak_cap_w: int(500,15000) peak_margin_w: int(0,500) peak_forecast_entity: str? # 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) # ========================================================================== # TARIFF - what energy costs, and whether to trade on it # ========================================================================== # ⚠️ 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 plugin means adding it here and to PROVIDERS - and nowhere else, # which is AC2. # # ⚠️ THREE OF THESE FIVE IDS NO LONGER RESOLVE TO A PLUGIN, AND LEAVING THEM # HERE IS A DECISION (TARIFF-07). engie_dynamic, totalenergies_dynamic and # eneco_eddy were withdrawn - their endpoints do not exist and never did - and # the obvious edit is to delete them from this line too. DO NOT. The Supervisor # validates the options file against this schema BEFORE the add-on starts, so # an id that is not listed here makes an EXISTING config schema-invalid: the # operator gets an opaque "expected one of ..." from the Supervisor, the add-on # never runs, and validate.py's retired-id refusal - the message that tells # them their prices never worked and what to do instead - IS NEVER REACHED, on # exactly the sites that need it. Schema tightness would cost the only thing # this withdrawal is FOR. # # The cost of keeping them is that the dropdown still offers three dead ids to # a NEW operator. That is the cheaper failure: choosing one is refused at the # next startup by name, with the endpoint, the observation and the alternative # - which is strictly more than a typo-shaped schema rejection would say. Both # translations label them withdrawn in the option description as well. # # app/price.py's RETIRED_PROVIDERS is the list, test_price.py section 1 asserts # this line offers exactly PROVIDERS + RETIRED_PROVIDERS + 'off' - derived, not # typed - and register() refuses to re-register a retired id, so the two # cannot both claim one. price_provider: list(off|generic_dayahead|price_service|custom_formula|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv) price_markup_eur_kwh: float(0,1) price_taxes_eur_kwh: float(0,1) # ⚠️ 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) # TARIFF-16. A JSON string in `price_formula`'s grammar, parsed by # `custom_formula.parse_export_formula()` - the SAME parser with its option # name as an argument, so a second unit table and a second card rule do not # exist to diverge. The `card` block is OPTIONAL on this leg and required on # the other: Eneco prints its injection formula with no price beside it, so # on that card there is nothing to grade against, and the add-on says at # startup that the leg is ungraded rather than inventing an estimate. price_export_formula: str? # 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? # TARIFF-17. A JSON string for `static_profile`'s reason, and parsed by # `static_tariff.parse_taxes_profile()` - the SAME period grammar, so a # second tiling rule and a second set of DST bugs do not exist to diverge. price_taxes_profile: str? # TARIFF-15. A JSON string for the same reason `static_profile` is one: it is # ONE option key, it survives config.yaml's flat options block, and # parse_price_formula() accepts an already-parsed dict too, so a future # nested schema is a config.yaml change and no code change at all. price_formula: str? price_csv_path: str? price_feed_url: str? price_service_token: password? price_horizon_h: int(1,72) price_timeout_s: int(2,60) price_now_entity: str? price_avg_entity: str? # 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) # ========================================================================== # MAINTENANCE - the monthly battery recalibration cycle # ========================================================================== # ⚠️ CFG-03 - THIS IS WHERE A RANGE LIVES, AND THE ONLY PLACE IT LIVES. The # `options:` block above holds the DEFAULT; this block holds the BOUNDS, and # `MAINT_OPTION_DEFAULTS` deliberately mirrors the former and not the latter - # the Supervisor is what enforces a range and the Supervisor reads only this # file, so a second copy of `int(500,5000)` in Python would be a place to # disagree and nothing else. What is NOT expressible here is a relation # BETWEEN keys: `maintenance_charge_w` must also stay at or below `max_w`, the # schema grammar has no way to say so, and that is why validate.py's rule 6 is # written in code instead. Add a ninth maintenance option in three places - # options:, schema:, MAINT_OPTION_DEFAULTS - and in no fourth. 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) # ========================================================================== # SAFETY - DERIVED CONSTANTS. Every number below was measured. Do not change # one without re-deriving it # ========================================================================== # 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) # SAFETY-30. No "off" value, for SAFETY-02's reason. # # ⚠️ THE THRESHOLD'S RANGE IS THE SAME AS THE SITE CHECK'S ON PURPOSE, AND THE # WINDOW'S FLOOR IS NOT. On the two-inverter rig the per-unit comparison's # worst continuous legitimate opposition is nearly FLAT in the threshold - # 25.0 s at 50 W, 15.0 s at 150 W, 10.0 s at 250 W, 5.0 s at 500 W - because a # unit's own power chases its own command rather than the load. So no value in # this range puts the guard outside the regime it can be answered for, and the # floor does not have to be a knee the way `circulation_w`'s does. unit_sign_disagree_w: int(50,5000) # The floor of 30 is `inverter.LINK_STALE_S`, the firmware's own veto window: # below it, a unit not executing its share is a LINK fault the add-on already # answers by dropping the unit from the split, and firing inside that window # would be a second, louder alarm about a state that is already being handled. unit_sign_disagree_s: int(30,1800) # SAFETY-30's SITE pair. Same ranges as `sign_disagree_*`, because it is the # same comparison asked of the site total - and no "off" value, for the same # reason. ⚠️ THERE IS DELIBERATELY NO validate.py RULE PAIRING # site_sign_disagree_w WITH max_w, unlike the other two thresholds: the bound # on the SITE command is `Controller.site_max_w`, the ROSTER SUM, and # `validate_startup_config()` is handed options rather than a roster. A rule # written against max_w would refuse a perfectly reachable two-inverter # configuration the day CFG-04 lands. The schema range is the bound here. site_sign_disagree_w: int(50,5000) site_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) # SAFETY-31. No "off" value, for SAFETY-02's and meter_freeze_s's reason: # disarming a safety alarm is not a supported configuration. # # ⚠️ THE FLOOR OF 400 IS NOT A RECOMMENDATION, IT IS THE MEASURED EDGE OF THE # REGIME IN WHICH THIS GUARD CAN BE ANSWERED FOR. On the TEST-19 rig, the # worst CONTINUOUS opposition produced by a site where nothing is faulty - a # flapping load reversing every control cycle, swept over the whole range this # schema accepts for `gain` and `slew_w` - is 5.0 s at a 400 W threshold and # above, 10.0 s at 300 W, 40.0 s at 250 W and 505.0 s at 100 W. Below the # knee, legitimate operation holds the sensor over the threshold for MINUTES # and no window shorter than the 900 s billing quarter could tell that from a # fault. A configurable value under 400 W would be a false-alarm generator # that this project's own doctrine (SAFETY-15, SAFETY-19) forbids shipping. circulation_w: int(400,5000) # The floor of 30 is `inverter.LINK_STALE_S`: below the firmware's own veto # window a unit still executing a stale share is a LINK fault the add-on # already handles by dropping it from the split, and firing inside that window # would raise a second, louder alarm about a state already being managed. The # ceiling of 900 is the quarter that bills the capacity tariff - a circulating # loop must be caught and acted on well inside one. circulation_hold_s: int(30,900) # SAFETY-42. The floor of 30 is `inverter.LINK_STALE_S`, for # `circulation_hold_s`'s reason and the same one SAFETY-30's per-unit window # takes it from: below the firmware's own veto window a unit not executing its # share is a LINK fault the add-on already answers by dropping it from the # split and saying so in the action log, and reporting inside that window # would be a second, louder alarm about a state already being handled. The # ceiling of 900 is the quarter that bills the capacity tariff - a site # running short of one unit's share must be reported well inside one. delivery_hold_s: int(30,900) # SAFETY-10. ⚠️ A THREE-VALUED LIST AND NOT A `bool`, for the reason written # against the default in the options block above: a boolean has one default # and the Supervisor gives it to every install, new and existing alike. # `app/shadow.MODES` is this tuple and `validate.py` rule 11 refuses anything # else BY NAME, because `shadow.mode()` resolves an unreadable value to # SHADOW - safe, but a silent reinterpretation that commands nothing for as # long as nobody notices. shadow_mode: list(auto|on|off) # ========================================================================== # ADVANCED - the control law and diagnostics # ========================================================================== auto_start: bool gain: float(0.05,1.0) # ⚠️ THE FLOOR IS 0, NOT 50, AND 0 IS THE SHIPPED VALUE - see the options block # above. It means "follow max_w", so the range starts at the setting that # cannot go stale rather than at the smallest number somebody could type. # ⚠️ THE CEILING IS STILL 5000 AND IS STILL NOT THE BOUND THAT MATTERS. The # binding one is `SLEW_RAIL_FRAC * max_w`, a relation BETWEEN keys, and this # grammar cannot express a relation - which is the same reason # `maintenance_charge_w`'s ceiling is not its real bound either (see the note # above it). validate.py rule 10 is where it is written. slew_w: int(0,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) log_level: list(trace|debug|info|warning|error)