Files
goodwe-addon/goodwe_controller/config.yaml
T
glenn schrooyenandClaude Opus 5 eb88646899
checks / unit (pull_request) Successful in 8m0s
PKG-16: cut 0.10.1, so SAFETY-57's fix can reach an installed 0.10.0
The Supervisor decides an update exists by comparing config.yaml's version
string with the one the box reports. master and release/1.0 both said 0.10.0,
so SAFETY-57 - a deadlock that parked the reference battery for ~13 min on
2026-09-21 while the house exported 2.4 kW - had no way of reaching the box
that has it. A patch bump is the delivery mechanism, not bookkeeping.

- config.yaml: 0.10.0 -> 0.10.1. Nothing else in the option surface moves:
  no key added or removed, no default changed, no enum widened, no rule added
  to validate.py, so a blob that starts on 0.10.0 starts on 0.10.1.

- CHANGELOG.md: a new `## 0.10.1` section, holding SAFETY-57's entry moved out
  of `## 0.10.0`. The entry opened by saying it was misfiled and that whoever
  cut the next version owned moving it; this is that cut, so that paragraph is
  replaced by what an operator actually needs in order to decide - what the
  deadlock does (command parked at 0 W, batt reading gated to MISSING, escape
  condition unreachable from inside the state), how to recognise it (setpoint
  stuck at exactly 0 W with the battery register flat, a STALE line and no
  clearing line) and that a restart of the add-on clears it.

- PKG-16 proper: `## 0.10.0`'s preamble still claimed 0.10.0 "has never been
  built or pulled anywhere" and that the reference install was on 0.7.0. Both
  stopped being true on 2026-09-20 ~22:18 CEST, when 0.10.0 was installed as a
  repository add-on (slug 68285ac7_goodwe_controller) and began regulating a
  real battery. The section's own bump argument was explicitly conditional on
  that; the condition has fired, so the argument is retold as history and the
  section is closed. This text is what the Supervisor shows beside Update.

Also fixes, in this file only, the three non-blocking documentation defects
raised on PR #104 - issuecomments 1445, 1446 and 1447:

- 1446: the live episode is 790 s, not 710 s. Re-derived rather than rescaled:
  at >= 790 s the committed 16-day replay gives 16 of 93, not 17.
- 1447: the BMS keep-alive pulse was an inference stated as a measurement. No
  committed capture identifies one, and on this episode 0x0518 never moved at
  all. The measured claim replaces the firmware story.
- 1445: "neither consumer can reach a verdict inside one window" is universal
  as written and graded on a fixture that puts the obligation back on every
  release. It now carries the qualifier the reviewer asked for, and names the
  unbounded case and why the shipped one-inverter roster cannot reach it.

The same three defects also sit in app/freshness.py, test_freshness.py,
DOCS.md and the DECISIONS note. They are deliberately NOT touched here: this
PR is config.yaml and CHANGELOG.md only, and none of them is worth delaying a
live safety fix. SAFETY-56 and SAFETY-58 are likewise untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-22 15:22:03 +02:00

1134 lines
62 KiB
YAML

name: GoodWe RS485 Controller
version: "0.10.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:
# ==========================================================================
# 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 rule 9 enforces 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
# ==========================================================================
# 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.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_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
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_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
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
# ==========================================================================
# 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.
#
# ⚠️ THREE 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. The optional pair is the sensors, whose absence
# honestly means "this unit reports none".
#
# ⚠️ 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
# ==========================================================================
# 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_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|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_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)