Files
goodwe-addon/goodwe_controller/config.yaml
T
glenn schrooyenandClaude Opus 5 ae21b71824
checks / unit (pull_request) Successful in 4m39s
Merge release/1.0 into SAFETY-30
CFG-04 (#45) merged first and moved the base. Two conflicts, both resolved
as the union of the two sides:

- app/main.py: an import-block collision. CFG-04 added validate_unit_ranges
  to the .validate import; SAFETY-30 added AGAINST_METER, AGAINST_OWN_POWER,
  SIGN_OPTION_DEFAULTS and SignWatchdog to the .watchdog import. Neither
  side dropped anything, so the resolution keeps both lists whole.
- CHANGELOG.md: two adjacent new entries under ## Unreleased. Both kept.

Both sides also independently rewrote large parts of test_peakguard.py
(60 and 47 insertions against the merge base) and git merged them
textually, so the full suite was run on the MERGE RESULT rather than
inferred from the two parents: 19 passed, 0 failed, twice.

One caveat recorded rather than hidden: test_peakguard.py failed once
during the first sequential full-suite run and then passed 10/10 in
isolation and in two further full runs on this same tree. It passes on
both parents too, so this is the pre-existing flakiness CFG-04 already
disclosed, not a merge defect - it is timing- or load-sensitive. It sits
on the required CI context and can therefore block an unrelated merge at
random, which wants a card of its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
2026-09-04 12:23:50 +02:00

669 lines
34 KiB
YAML

name: GoodWe RS485 Controller
version: "0.6.0"
slug: goodwe_controller
description: >-
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
meter, holding net grid exchange at zero and running a monthly battery
maintenance cycle.
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
arch:
- aarch64
- amd64
- armv7
init: false
startup: application
boot: auto
# Needed to read the meter and to write the ESPHome setpoint number.
homeassistant_api: true
hassio_api: true
# Own diagnostics/operations UI inside HA. This is what a field tech and a
# remote supporter both look at, so it is not optional.
ingress: true
ingress_port: 8099
panel_icon: mdi:battery-sync
panel_title: GoodWe
# Optional: publish status entities by MQTT discovery. `want` rather than
# `need` - the add-on runs fine with no broker, it just publishes nothing.
services:
- mqtt:want
options:
# --- sources (required) ---------------------------------------------------
meter_entity: sensor.p1_meter_active_power
meter_invert: false
soc_entity: ""
batt_entity: ""
batt_invert: false
setpoint_entity: ""
# --- ARCH-01 / CFG-04: the inverter roster ---------------------------------
# ⚠️ 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 FIVE FLAT KEYS ABOVE 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 five 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
# - 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: 3000
#
# ⚠️ 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` and `max_w` 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".
#
# ⚠️ 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: []
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
# `off` keeps the original single meter_entity path above, so an existing
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
# homewizard_local skips Home Assistant entirely and polls the meter's own
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
# the meter REPORTED rather than when the value last CHANGED - HA emits
# nothing at all for a repeated reading - so it is the only mode a firmware
# watchdog may threshold. See DOCS.md.
meter_source: "off"
meter_phases: 1
meter_max_age_s: 30
meter_mqtt_topic: ""
# homewizard_local only. The meter's own address, `host` or `host:port`.
p1_host: ""
# homewizard_local only. Seconds between polls. Must be well under
# meter_max_age_s - the age is never fresher than this interval - and there is
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
meter_poll_s: 5
# The two UNSIGNED Belgian registers. The EMS derives net power from them
# (import - export); do NOT point these at a signed template sensor.
p1_import_entity: ""
p1_export_entity: ""
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
# three-phase connection; the list length must equal meter_phases.
p1_phase_import_entities: []
p1_phase_export_entities: []
# ha_signed only. ONE signed net-power sensor: positive = import from the
# grid, negative = export to it. Do NOT split it into two template sensors -
# the split is done in the add-on (p1.split_signed) precisely so the sign
# convention is tested rather than living in unreviewed YAML.
p1_net_entity: ""
# Optional, in L1..L3 order, each one signed the same way. Same role as
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
# connection. The list length must equal meter_phases.
p1_phase_net_entities: []
# --- control ---------------------------------------------------------------
max_w: 2000
gain: 0.6
slew_w: 1000
deadband_w: 15
target_grid_w: -10
step_w: 10
saturation_w: 500
saturation_cycles: 3
integrator_max_w: 0
heartbeat_s: 10
stale_input_s: 15
cycle_floor_s: 15
auto_start: false
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
# a failsafe that latches until an operator starts control again.
#
# Both defaults are measured, not chosen. On the captured healthy day
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
# not caught up with - is 30 s, and sign agreement over the gated samples is
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
# fault day still trips 13 times. There is no reason to move either number
# without a capture that says so. They are options because the AC says
# thresholds are defaults rather than constants - not because they are knobs
# anyone is expected to turn.
#
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
# arbiter bounds the command to +-max_w). validate.py refuses that config.
sign_disagree_w: 300
sign_disagree_s: 180
# --- SAFETY-30 per-inverter sign-disagreement watchdog ---------------------
# ⚠️ 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
# --- SAFETY-30 SITE sign-disagreement watchdog -----------------------------
# ⚠️ 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
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
# Seconds the meter may keep answering with an UNCHANGED measurement while the
# command stands at the rail before the add-on raises an alarm. Both halves
# are required: neither one alone is safe, and app/freeze.py carries why.
#
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
# the battery; a false notification costs an alert.
#
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
# only transport that reads the meter's own document. On every other source
# the status page says NOT COVERED rather than "armed".
#
# 300 s is measured, not chosen. Over 2399 s of the live house at the
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
# still inside the 900 s quarter that bills the capacity tariff.
meter_freeze_s: 300
# --- SAFETY-31: circulating power ------------------------------------------
# ⚠️ 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
# --- maintenance -----------------------------------------------------------
# ⚠️ 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
# --- tariff / capacity tariff (all optional) ------------------------------
peak_forecast_entity: ""
peak_cap_w: 3500
# --- SAFETY-24: the energy backstop under SAFETY-07's forced discharge -----
# The peak guard can COMMAND a discharge to hold a billed quarter under
# peak_cap_w. Until this card it was bounded from below by nothing but the
# pack's own BMS cut-out - a real floor, but the PACK's protection rather than
# the OPERATOR's. On the 2026-08-20 replay the guard asks for 878 Wh in a day.
#
# peak_soc_floor is the percent below which that discharge is not sized. When
# it binds the guard reports FLOOR-LIMITED, keeps charging blocked (a 0 W
# floor), and the add-on raises ONE notification saying the quarter will be
# exceeded - failing to shave is never silent, because the euro figure is the
# whole point of the guard.
#
# ⚠️ THIS IS NOT maintenance_soc_floor AND MUST NOT BE MERGED WITH IT.
# maintenance_soc_floor is the DRAIN TARGET of the monthly recalibration: the
# depth the cycle deliberately goes to, once a month, on purpose. This is the
# depth a money-saving discharge may never reach on any day. The right values
# differ, and sharing one key would couple two features that want to move
# independently.
#
# ⚠️ 20 IS DERIVED, NOT PICKED. The reference pack is 13.8 kWh usable and the
# worst modelled guard day is 878 Wh, i.e. 6.4 % - so 10 points above the
# pack's own cut-out leaves the guard ~1.6 of its worst observed days of room
# and still never reaches the BMS.
#
# ⚠️⚠️ 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
# The pack's OWN cut-out percent - what the BMS enforces whatever is set
# above. Register 0x0560 reads 10 on the reference GW5000S-BP, consistent with
# DoD 90. It is here so validate.py can REFUSE a peak_soc_floor at or below
# it: such a floor can never bind, the BMS wins, and the status page would
# report an energy backstop that does not exist.
bms_soc_floor: 10
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
# add-on delegating the whole price question to Home Assistant: main.py reads
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
# nothing except the maintenance cycle's cheap-window charge floor. That is
# all they have ever done. They are kept, because retiring them would take
# the maintenance cycle's only price signal away on a live install before
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
# are the cards that replace it, and neither has landed.
#
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
# one site, one of which silently wins, is exactly the "double up" the PM
# named; validate.py rule 7 refuses that configuration at startup rather than
# letting an operator believe the provider they configured is driving
# something. See app/validate.py.
price_now_entity: ""
price_avg_entity: ""
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
# `off` is the shipped default and means the §11.4 no-price-data row applies:
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
# validates and publishes prices and changes no energy decision.
#
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
# because that has already happened three times.
#
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
# the FRAMEWORK, only for the components the chosen plugin declares its feed
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
# and manual_csv publish a figure that already carries the markup, so setting
# it under those is ignored - and logged as ignored, never applied twice.
price_provider: "off"
price_markup_eur_kwh: 0.03
price_taxes_eur_kwh: 0.06
price_csv_path: "/share/goodwe_prices.csv"
price_feed_url: ""
price_horizon_h: 48
price_timeout_s: 10
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
# of the installed base is on [a static profile]" - this is what makes those
# sites first-class rather than "price_provider: off, good luck".
#
# A JSON object, on one line, whose periods must tile every day type exactly
# once - no gap and no overlap. A gap is REFUSED at startup rather than
# defaulted, because an invented price is one an optimiser would trade on.
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
# add-on derives "fixed" from there being one distinct price, so there is no
# box to tick and none to tick wrongly.
#
# {"periods": [
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
#
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
# a period applies on the day the CLOCK reads, so the weekend row above is
# required, not decorative. See DOCS.md.
#
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
# dynamic providers are assembled onto. price_markup_eur_kwh and
# price_taxes_eur_kwh are NOT added to it.
#
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
# data -> fall back to the static profile"; with this empty there is nothing
# to fall back to and a dead feed leaves the site with no tariff at all.
# validate.py warns about exactly that.
static_profile: ""
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
# which by itself strongly favours self-consumption over export. A scalar, not
# a second series: no shipped supplier publishes a dynamic injection curve.
export_price_eur_kwh: 0.04
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
# when the spread covers what the round trip costs. Off in the shipped
# configuration: this is the first strategy in the add-on that moves energy
# against the meter on purpose, so it is armed deliberately or not at all.
#
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
# combinations that cannot work rather than letting them look armed. With
# `price_provider: off` and no `static_profile` there is no series to trade
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
# exact failure that guard exists to prevent.
#
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
# below is 0.10, so the verdict is "the firm spread does not cover this
# cycle's cost" and nothing happens - correctly. That is a product decision
# (this number, and app/price.py's widening factors) and it is open with the
# owner; the code is correct at any value and no number was picked to make a
# trade appear.
arbitrage_enabled: false
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
# - the wear on the pack plus the conversion losses. A spread below this is
# a cycle that loses money and pack life, so it is not taken.
cycle_cost_threshold_eur: 0.10
# The reserve arbitrage may not spend, and the headroom it may not overfill.
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
# balancing cycle, and pointing a trading strategy at them would couple two
# unrelated policies through one number.
arbitrage_soc_floor: 20
arbitrage_soc_ceiling: 95
# --- site ------------------------------------------------------------------
estop_fitted: false
log_level: info
schema:
meter_entity: str
meter_invert: bool
soc_entity: str
batt_entity: str
batt_invert: bool
setpoint_entity: str
# 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.
#
# ⚠️ 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)
soc_entity: str?
batt_entity: str?
batt_invert: bool?
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
# telegram whose phase count disagrees is rejected at ingest and logged, so a
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
# quietly wrong number.
meter_phases: int(1,3)
meter_max_age_s: int(5,300)
meter_poll_s: int(1,60)
meter_mqtt_topic: str?
p1_host: str?
p1_import_entity: str?
p1_export_entity: str?
p1_phase_import_entities:
- str
p1_phase_export_entities:
- str
p1_net_entity: str?
p1_phase_net_entities:
- str
max_w: int(100,5000)
gain: float(0.05,1.0)
slew_w: int(50,5000)
deadband_w: int(0,500)
target_grid_w: float(-200,200)
step_w: int(1,100)
saturation_w: int(100,2000)
saturation_cycles: int(1,10)
integrator_max_w: int(0,15000)
heartbeat_s: int(2,25)
stale_input_s: int(5,120)
cycle_floor_s: int(5,60)
auto_start: bool
# No "off" value, deliberately: this is a P0 safety check and the schema is
# not the place to make disarming it a supported configuration.
sign_disagree_w: int(50,5000)
sign_disagree_s: int(30,1800)
# 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)
# ⚠️ 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)
peak_forecast_entity: str?
peak_cap_w: int(500,15000)
# SAFETY-24. No "off" value: a guard that can command a discharge does not get
# a supported way to have no energy bound at all. The ranges OVERLAP on
# purpose - the schema cannot express "above bms_soc_floor", so validate.py
# rule 8 is what refuses the pair, with a message naming both numbers.
peak_soc_floor: int(5,60)
bms_soc_floor: int(0,50)
price_now_entity: str?
price_avg_entity: str?
# ⚠️ The provider list is written out rather than left as `str`, so the
# Supervisor rejects a typo in the UI. validate.py ALSO refuses an unknown id
# against app/price.py's PROVIDERS registry, because sim/inject.py and the
# checks reach validate_startup_config() with a dict the Supervisor never saw.
# Adding a sixth plugin means adding it here and to PROVIDERS - and nowhere
# else, which is AC2.
price_provider: list(off|generic_dayahead|engie_dynamic|totalenergies_dynamic|eneco_eddy|manual_csv)
price_markup_eur_kwh: float(0,1)
price_taxes_eur_kwh: float(0,1)
price_csv_path: str?
price_feed_url: str?
price_horizon_h: int(1,72)
price_timeout_s: int(2,60)
# A JSON string rather than a nested schema, deliberately: it is ONE option
# key (§14 has one), it survives config.yaml's flat options block, and
# parse_static_profile() accepts an already-parsed dict too, so a future
# nested schema is a config.yaml change and no code change at all.
static_profile: str?
# ⚠️ The lower bound is NEGATIVE on purpose. Belgian injection tariffs have
# gone below zero (you pay to export at midday in summer), and a schema that
# bottoms out at 0 would make that site inexpressible.
export_price_eur_kwh: float(-1,1)
# OPT-04. ⚠️ The threshold's lower bound is 0 and not negative: it is a COST,
# and a negative one is a threshold every spread clears - arbitrage with the
# brake removed. app/arbitrage.py refuses it in code too, because
# validate_startup_config() is also sim/inject.py's entry point and can be
# handed any dict at all.
arbitrage_enabled: bool
cycle_cost_threshold_eur: float(0,1)
arbitrage_soc_floor: int(5,95)
arbitrage_soc_ceiling: int(10,100)
estop_fitted: bool
log_level: list(trace|debug|info|warning|error)