checks / unit (pull_request) Successful in 4m51s
The desk half of SPIKE-04. No control behaviour changes; this is documentation and the check that stops it drifting again. Three places stated inverter B's rating - config.yaml's commented roster example, DOCS.md's, and tools/supervisor_shape_probe.py's "a blob an operator would POST" - and all three said 3000 W, a number nobody had measured or read off anything. The owner supplied B's model and serial on 2026-09-04: a GoodWe GW5048D-ES, serial `55048ESU22AW0992`, max charging and discharging power 4600 W. Serial characters 6-8 are `ESU`, platform 105 / AA55, so SPIKE-01 does not gate this site. All three copies now read 4600. ⚠️ 4600 W IS FROM A DATASHEET AND IS NOT A MEASUREMENT, and the one ambiguity is recorded rather than guessed. The datasheet prints it under BATTERY INPUT DATA, so it is probably DC, while max_w is graded against 0x0518 - the inverter's own AC power. If it is DC the AC ceiling is nearer 4324 W. Neither reading is written in as fact; SAFETY-20's min(max_w, dev_max) already bounds it correctly under either, once dev_max is read off the unit, so `max_w: 4600` is what an operator should configure today. goodwe/NOTES.md §1.1b carries the full record with the date and the method on every figure. A NEW CHECK, BECAUSE A DOCUMENTATION FIX WITHOUT ONE DRIFTS AGAIN. test_validate.py now pins all three documented rosters: findable, in agreement, and equal to 4600 - with an explicit len(...) == 3 guard so that a regex which stopped matching cannot leave "they all agree" green on the two sites still read. It also grades the PROVENANCE beside the number (the datasheet, the 0x0518 it is graded against, and the 4324 W the DC reading would make it) WITHIN 2500 characters of the roster rather than file-wide. ⚠️ The first version of that provenance check searched whole files and was ALREADY VACUOUS on two of them: 0x0518 appeared once in config.yaml and once in DOCS.md before this card, for unrelated reasons, so `"0x0518" in src` was green no matter what happened next to B's rating. That is a check whose data cannot reach the failure it names, in the file that exists to complain about them. It is fixed, and the window is what fixes it - deleting the caveat beside the number now goes red even where the same token survives elsewhere in the file. The windowed version immediately found that the DOCS.md caveat did not name 0x0518 at all; DOCS.md now says it. Four mutations, bytecode purged and counted at 0 before and after each, all RED (two of them 0-byte deltas that change a printed answer): M1 probe's operator blob 4600 -> 3000 0 B RED (value + agreement) M2 DOCS.md's DC/AC caveat deleted -157 B RED (provenance) M3 config.yaml alone 4600 -> 4324 0 B RED (agreement) M4 probe entity renamed, regex finds nothing RED (the vacuity guard) Restored, green again. The probe itself was re-run against Supervisor 2026.08.0's own AppOptions validator with the 4600: all cases passed, exit 0. ⚠️ THE TEST FIXTURES KEEP 3000 W ON PURPOSE AND NOW SAY WHY AT THE LINE. test_inverter.py, test_maintenance.py, test_peakguard.py and test_watchdog.py build a second inverter at 3000 W against A's 5000 W. The only property those checks consume is that B's number is unmistakably not A's, and the real pair is a 400 W gap - narrower than slew_w, inside the range where an off-by-one-unit bug and ordinary control arithmetic produce indistinguishable numbers. Collapsing them onto the real values would buy realism those checks do not use and cost them their separating power, which is exactly what test_peakguard.py's own §10g PRECONDITION exists to prevent. Their comments no longer call 3000 W a hypothesis about B's rating; they call it illustrative, and not a placeholder either - the placeholder era ended on 2026-09-04. Also recorded in the changelog for the cards that own them: 94 % battery-to-AC => ~88 % round trip => ~360 W burned continuously to circulate 3 kW, which is the number SAFETY-31 argues its threshold from without having one; < 13 W night self-consumption per unit, which nothing accounts for; and the A/B asymmetry in opposite directions (~33 % more energy, ~8 % less power), which means MAINT-05's 12 h drain timeout was derived on A and should be re-checked against B. ⚠️ Honest survivors: - goodwe/NOTES.md §1.1b, where all of this is actually recorded, is graded by nothing here. Rewriting its 16 occurrences of 4600 to 3000 and turning every FROM DATASHEET label into MEASURED ON THE UNIT leaves all 19 files GREEN. This CI cannot fetch admin/goodwe - private, and GITHUB_TOKEN is minted for this repository only, as .gitea/workflows/checks.yml already states - so the cross-repo check is not buildable here. - No check can see whether 4600 is the RIGHT number. Everything above pins a datasheet reading against itself. Only the bench half of SPIKE-04 can say whether the unit agrees, and the DC-vs-AC ambiguity is the live example. - The constant is still named B_RATING_HYPOTHESIS in four test files, which reads as a hypothesis about B's rating when it is now an illustrative fixture. It is left alone deliberately: renaming ~20 sites across test_peakguard.py and test_inverter.py would collide hard with the open SAFETY-32 pull request, which rewrites 1255 lines of the first. The comment directly above each definition says what the value is and is not. Suite: 19/19 green in goodwe-addon-ci, mounted :ro, bytecode purged and verified 0 before and after. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
681 lines
35 KiB
YAML
681 lines
35 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: 4600
|
|
#
|
|
# ⚠️ 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.
|
|
#
|
|
# ⚠️ 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)
|