checks / unit (pull_request) Successful in 3m16s
Four cards built the price stack and nothing traded on it. app/arbitrage.py is §10.1's priority 3, and it consumes all four rather than re-deciding anything: whether a trade is permitted at all, on which two hours, against which widened threshold is TariffPlan.arbitrage()'s answer, and that answer cannot be obtained without first saying whether the peak guard is holding. §9.1's "peak shaving outranks arbitrage" is therefore honoured structurally - there is no path to a spread that skips the question - and again in the arbiter, where P_PEAK (50) clamps a claim filed at P_ARBITRAGE (30). A guard that never ran is not a guard that is clear. On meter_source off the peak guard is never evaluated; that third state is carried as None and refuses, because charging at the rail creates the billed quantity while nothing measures the quarter. validate.py refuses that configuration too, along with arbitrage armed with no price source and a SoC floor at or above the ceiling. §17: never export into a negative price. The guard is on the hour being sold into, not on the spread - a day priced -0.30 .. -0.05 clears any threshold and its dearest hour is still negative. Buying in a negative hour stays allowed. resolve_tariff() may open an HTTPS connection and the tick feeds the ESP32 heartbeat, so the fetch runs in its own task at a 30 min cadence and the tick reads an attribute and an age bound. test_arbitrage.py section 8 asserts that structurally on the shipped AST: a fetch put back on cycle() is red. AC4 is NOT met and is not restated - this repository holds no price history. Section 7 replays a synthetic year and asserts the property that does not need real prices: every executed round trip cleared its own widened requirement, and zero discharges landed in the 522 negative-priced hours. At the shipped 0.10 EUR/kWh threshold a realistic Belgian day/night site does not trade at all. Reported, not tuned: that number and FCST-03's widening factors are one product decision and it is the owner's. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015VXjA3LWccN1rJsk3okyPC
384 lines
17 KiB
YAML
384 lines
17 KiB
YAML
name: GoodWe RS485 Controller
|
|
version: "0.5.2"
|
|
slug: goodwe_controller
|
|
description: >-
|
|
Drives a GoodWe ES/BP battery inverter over RS485 by emulating its smart
|
|
meter, holding net grid exchange at zero and running a monthly battery
|
|
maintenance cycle.
|
|
url: https://gittea.kammenstraatha.duckdns.org/admin/goodwe-addon
|
|
arch:
|
|
- aarch64
|
|
- amd64
|
|
- armv7
|
|
init: false
|
|
startup: application
|
|
boot: auto
|
|
|
|
# Needed to read the meter and to write the ESPHome setpoint number.
|
|
homeassistant_api: true
|
|
hassio_api: true
|
|
|
|
# Own diagnostics/operations UI inside HA. This is what a field tech and a
|
|
# remote supporter both look at, so it is not optional.
|
|
ingress: true
|
|
ingress_port: 8099
|
|
panel_icon: mdi:battery-sync
|
|
panel_title: GoodWe
|
|
|
|
# Optional: publish status entities by MQTT discovery. `want` rather than
|
|
# `need` - the add-on runs fine with no broker, it just publishes nothing.
|
|
services:
|
|
- mqtt:want
|
|
|
|
options:
|
|
# --- sources (required) ---------------------------------------------------
|
|
meter_entity: sensor.p1_meter_active_power
|
|
meter_invert: false
|
|
soc_entity: ""
|
|
batt_entity: ""
|
|
batt_invert: false
|
|
setpoint_entity: ""
|
|
|
|
# --- P1 meter ingestion (specs §5.2 / §14 `meter:`) -------------------------
|
|
# `off` keeps the original single meter_entity path above, so an existing
|
|
# install is untouched until it opts in. ha_dsmr subscribes to the DSMR
|
|
# integration's entities over the HA WebSocket; mqtt_p1 reads the topic below;
|
|
# ha_signed reads ONE signed HA entity (+ import / - export), which is what a
|
|
# HomeWizard P1 publishes and what ha_dsmr cannot consume.
|
|
# homewizard_local skips Home Assistant entirely and polls the meter's own
|
|
# local API. ⚠️ It is the ONLY mode whose sensor.p1_sample_age_s measures when
|
|
# the meter REPORTED rather than when the value last CHANGED - HA emits
|
|
# nothing at all for a repeated reading - so it is the only mode a firmware
|
|
# watchdog may threshold. See DOCS.md.
|
|
meter_source: "off"
|
|
meter_phases: 1
|
|
meter_max_age_s: 30
|
|
meter_mqtt_topic: ""
|
|
# homewizard_local only. The meter's own address, `host` or `host:port`.
|
|
p1_host: ""
|
|
# homewizard_local only. Seconds between polls. Must be well under
|
|
# meter_max_age_s - the age is never fresher than this interval - and there is
|
|
# nothing to gain below the meter's own ~5.0 s update rate (NOTES.md:640).
|
|
meter_poll_s: 5
|
|
# The two UNSIGNED Belgian registers. The EMS derives net power from them
|
|
# (import - export); do NOT point these at a signed template sensor.
|
|
p1_import_entity: ""
|
|
p1_export_entity: ""
|
|
# Optional, in L1..L3 order. Required for the capacity-tariff peak on a
|
|
# three-phase connection; the list length must equal meter_phases.
|
|
p1_phase_import_entities: []
|
|
p1_phase_export_entities: []
|
|
# ha_signed only. ONE signed net-power sensor: positive = import from the
|
|
# grid, negative = export to it. Do NOT split it into two template sensors -
|
|
# the split is done in the add-on (p1.split_signed) precisely so the sign
|
|
# convention is tested rather than living in unreviewed YAML.
|
|
p1_net_entity: ""
|
|
# Optional, in L1..L3 order, each one signed the same way. Same role as
|
|
# p1_phase_import_entities: the capacity-tariff peak on a three-phase
|
|
# connection. The list length must equal meter_phases.
|
|
p1_phase_net_entities: []
|
|
|
|
# --- control ---------------------------------------------------------------
|
|
max_w: 2000
|
|
gain: 0.6
|
|
slew_w: 1000
|
|
deadband_w: 15
|
|
target_grid_w: -10
|
|
step_w: 10
|
|
saturation_w: 500
|
|
saturation_cycles: 3
|
|
integrator_max_w: 0
|
|
heartbeat_s: 10
|
|
stale_input_s: 15
|
|
cycle_floor_s: 15
|
|
auto_start: false
|
|
|
|
# --- SAFETY-02 sign-disagreement watchdog ----------------------------------
|
|
# Trips when the emitted setpoint and the P1 net figure point OPPOSITE ways,
|
|
# both over sign_disagree_w, continuously for sign_disagree_s. Action: 0 W and
|
|
# a failsafe that latches until an operator starts control again.
|
|
#
|
|
# Both defaults are measured, not chosen. On the captured healthy day
|
|
# (2026-08-23) the longest legitimate opposed run - a load step the loop has
|
|
# not caught up with - is 30 s, and sign agreement over the gated samples is
|
|
# 93.4 %. On the captured fault day (2026-08-20) the clusters run 0.0-10.5 %
|
|
# agreement with opposed runs up to 1752 s. 300 W / 180 s sits in the middle of
|
|
# a very wide gap: at 60 s the healthy day still trips nothing, at 300 s the
|
|
# fault day still trips 13 times. There is no reason to move either number
|
|
# without a capture that says so. They are options because the AC says
|
|
# thresholds are defaults rather than constants - not because they are knobs
|
|
# anyone is expected to turn.
|
|
#
|
|
# ⚠️ sign_disagree_w must stay BELOW max_w or the watchdog can never fire (the
|
|
# arbiter bounds the command to +-max_w). validate.py refuses that config.
|
|
sign_disagree_w: 300
|
|
sign_disagree_s: 180
|
|
|
|
# --- SAFETY-18 frozen-but-fresh meter detector -----------------------------
|
|
# Seconds the meter may keep answering with an UNCHANGED measurement while the
|
|
# command stands at the rail before the add-on raises an alarm. Both halves
|
|
# are required: neither one alone is safe, and app/freeze.py carries why.
|
|
#
|
|
# ALARM ONLY. It does not command 0 W and does not latch. A false trip parks
|
|
# the battery; a false notification costs an alert.
|
|
#
|
|
# ⚠️ Only `meter_source: homewizard_local` can see this at all - it is the
|
|
# only transport that reads the meter's own document. On every other source
|
|
# the status page says NOT COVERED rather than "armed".
|
|
#
|
|
# 300 s is measured, not chosen. Over 2399 s of the live house at the
|
|
# converged operating point (sim/scenarios/p1-age-2026-08-26.jsonl.gz, mean
|
|
# grid -14.2 W, 1194 polls, 735 of them repeating the previous reading) the
|
|
# LONGEST run without a distinct reading is 15.4 s (the largest unchanged_s any
|
|
# sample observes is 8.0 s). 300 s is 19.5x the pessimistic figure, and
|
|
# still inside the 900 s quarter that bills the capacity tariff.
|
|
meter_freeze_s: 300
|
|
|
|
# --- maintenance -----------------------------------------------------------
|
|
maintenance_enabled: false
|
|
maintenance_interval_days: 28
|
|
maintenance_start_hour: 10
|
|
# ⚠️ BOTH MUST STAY AT OR BELOW max_w, and that is why they are 2000 rather
|
|
# than the 2500 they shipped as. The cheap-window charge shaping asks the
|
|
# arbiter for `hi = -maintenance_charge_w` against the supervised `lo =
|
|
# -max_w`: above the ceiling that is `lo > hi`, a contradiction resolved to
|
|
# 0 W, so maintenance would SILENTLY NEVER CHARGE in a cheap window on a
|
|
# default install. validate.py refuses that config (rule 6), so these
|
|
# defaults had to become self-consistent with the max_w above rather than
|
|
# ship a configuration the validator rejects.
|
|
#
|
|
# The discharge side is clamped rather than contradicted - it drains at max_w
|
|
# and the arbiter says so - so it is only warned about; it is 2000 here for
|
|
# symmetry and to keep the shipped config warning-free.
|
|
#
|
|
# Raising max_w (the live reference install runs 5000) is what unlocks a
|
|
# faster maintenance cycle. Raise max_w FIRST, then these.
|
|
maintenance_discharge_w: 2000
|
|
maintenance_charge_w: 2000
|
|
maintenance_soc_floor: 11
|
|
maintenance_soc_target: 99
|
|
maintenance_hold_min: 120
|
|
|
|
# --- tariff / capacity tariff (all optional) ------------------------------
|
|
peak_forecast_entity: ""
|
|
peak_cap_w: 3500
|
|
|
|
# ⚠️ THE OLD PRICE PATH, AND IT IS NOT DEAD YET. These two entities are the
|
|
# add-on delegating the whole price question to Home Assistant: main.py reads
|
|
# both and derives ONE BOOLEAN, `cheap = now <= avg`, which is consumed by
|
|
# nothing except the maintenance cycle's cheap-window charge floor. That is
|
|
# all they have ever done. They are kept, because retiring them would take
|
|
# the maintenance cycle's only price signal away on a live install before
|
|
# anything replaces it - TARIFF-03 (static profile) and OPT-04 (arbitrage)
|
|
# are the cards that replace it, and neither has landed.
|
|
#
|
|
# ⚠️ They may NOT be combined with price_provider below. Two price sources on
|
|
# one site, one of which silently wins, is exactly the "double up" the PM
|
|
# named; validate.py rule 7 refuses that configuration at startup rather than
|
|
# letting an operator believe the provider they configured is driving
|
|
# something. See app/validate.py.
|
|
price_now_entity: ""
|
|
price_avg_entity: ""
|
|
|
|
# --- price providers (TARIFF-02, specs §9.3 / §14 `tariffs:`) --------------
|
|
# `off` is the shipped default and means the §11.4 no-price-data row applies:
|
|
# static profile, plan provisional, NO ARBITRAGE. Nothing in 0.5.x consumes
|
|
# the series yet - OPT-04 is the consumer - so turning this on today fetches,
|
|
# validates and publishes prices and changes no energy decision.
|
|
#
|
|
# ⚠️ THESE DEFAULTS ARE MIRRORED ONCE, IN app/price.py's
|
|
# PRICE_OPTION_DEFAULTS, and test_price.py reads THIS FILE and asserts the two
|
|
# agree key for key. Do not add a third copy in main.py - CFG-03 is open
|
|
# because that has already happened three times.
|
|
#
|
|
# markup/taxes are the §14 `tariffs.energy_price.*` figures and are added by
|
|
# the FRAMEWORK, only for the components the chosen plugin declares its feed
|
|
# does not already contain. engie_dynamic, totalenergies_dynamic, eneco_eddy
|
|
# and manual_csv publish a figure that already carries the markup, so setting
|
|
# it under those is ignored - and logged as ignored, never applied twice.
|
|
price_provider: "off"
|
|
price_markup_eur_kwh: 0.03
|
|
price_taxes_eur_kwh: 0.06
|
|
price_csv_path: "/share/goodwe_prices.csv"
|
|
price_feed_url: ""
|
|
price_horizon_h: 48
|
|
price_timeout_s: 10
|
|
|
|
# --- static / fully-fixed tariffs (TARIFF-03, specs §9.2 / §11.4) ----------
|
|
# §14's `tariffs.static_profile`, and "" is its `null`. §9.2: "a large share
|
|
# of the installed base is on [a static profile]" - this is what makes those
|
|
# sites first-class rather than "price_provider: off, good luck".
|
|
#
|
|
# A JSON object, on one line, whose periods must tile every day type exactly
|
|
# once - no gap and no overlap. A gap is REFUSED at startup rather than
|
|
# defaulted, because an invented price is one an optimiser would trade on.
|
|
# A fully-fixed tariff is ONE period covering 00:00-24:00, all days; the
|
|
# add-on derives "fixed" from there being one distinct price, so there is no
|
|
# box to tick and none to tick wrongly.
|
|
#
|
|
# {"periods": [
|
|
# {"name":"day", "from":"07:00","to":"22:00","days":"mon-fri","eur_per_kwh":0.34},
|
|
# {"name":"night","from":"22:00","to":"07:00","days":"mon-fri","eur_per_kwh":0.26},
|
|
# {"name":"night","from":"00:00","to":"24:00","days":"sat,sun", "eur_per_kwh":0.26}]}
|
|
#
|
|
# ⚠️ A window written "22:00-07:00 mon-fri" does NOT cover Saturday morning -
|
|
# a period applies on the day the CLOCK reads, so the weekend row above is
|
|
# required, not decorative. See DOCS.md.
|
|
#
|
|
# eur_per_kwh is ALL-IN (energy + markup + taxes, §9.3), the same basis the
|
|
# dynamic providers are assembled onto. price_markup_eur_kwh and
|
|
# price_taxes_eur_kwh are NOT added to it.
|
|
#
|
|
# ⚠️ RECOMMENDED EVEN ON A DYNAMIC SITE. §11.4's degradation row is "no price
|
|
# data -> fall back to the static profile"; with this empty there is nothing
|
|
# to fall back to and a dead feed leaves the site with no tariff at all.
|
|
# validate.py warns about exactly that.
|
|
static_profile: ""
|
|
|
|
# §9.3: injection is priced SEPARATELY, and in Belgium it is often near zero -
|
|
# which by itself strongly favours self-consumption over export. A scalar, not
|
|
# a second series: no shipped supplier publishes a dynamic injection curve.
|
|
export_price_eur_kwh: 0.04
|
|
|
|
# --- price arbitrage (OPT-04, specs §10.1 priority 3 / §17) ---------------
|
|
# Charge in the cheapest published hour, discharge in the dearest, and ONLY
|
|
# when the spread covers what the round trip costs. Off in the shipped
|
|
# configuration: this is the first strategy in the add-on that moves energy
|
|
# against the meter on purpose, so it is armed deliberately or not at all.
|
|
#
|
|
# ⚠️ IT NEEDS A PRICE SOURCE AND A P1 METER, and validate.py refuses the
|
|
# combinations that cannot work rather than letting them look armed. With
|
|
# `price_provider: off` and no `static_profile` there is no series to trade
|
|
# on; with `meter_source: off` the capacity-tariff peak guard never runs, and
|
|
# §9.1 puts peak shaving above arbitrage - a charge nothing measures is the
|
|
# exact failure that guard exists to prevent.
|
|
#
|
|
# ⚠️ AT THE SHIPPED THRESHOLD A STATIC DAY/NIGHT SITE DOES NOT TRADE. The
|
|
# realistic Belgian day/night spread is about 0.08 EUR/kWh and the threshold
|
|
# below is 0.10, so the verdict is "the firm spread does not cover this
|
|
# cycle's cost" and nothing happens - correctly. That is a product decision
|
|
# (this number, and app/price.py's widening factors) and it is open with the
|
|
# owner; the code is correct at any value and no number was picked to make a
|
|
# trade appear.
|
|
arbitrage_enabled: false
|
|
# §10.1's `cycle_cost_threshold_eur`: what one full round trip costs per kWh
|
|
# - the wear on the pack plus the conversion losses. A spread below this is
|
|
# a cycle that loses money and pack life, so it is not taken.
|
|
cycle_cost_threshold_eur: 0.10
|
|
# The reserve arbitrage may not spend, and the headroom it may not overfill.
|
|
# Deliberately NOT the maintenance_soc_* keys: those describe a monthly
|
|
# balancing cycle, and pointing a trading strategy at them would couple two
|
|
# unrelated policies through one number.
|
|
arbitrage_soc_floor: 20
|
|
arbitrage_soc_ceiling: 95
|
|
|
|
# --- site ------------------------------------------------------------------
|
|
estop_fitted: false
|
|
log_level: info
|
|
|
|
schema:
|
|
meter_entity: str
|
|
meter_invert: bool
|
|
soc_entity: str
|
|
batt_entity: str
|
|
batt_invert: bool
|
|
setpoint_entity: str
|
|
|
|
meter_source: list(off|ha_dsmr|mqtt_p1|ha_signed|homewizard_local)
|
|
# ⚠️ 2 is accepted by this range but is not a real Belgian connection. A
|
|
# telegram whose phase count disagrees is rejected at ingest and logged, so a
|
|
# mis-set 2 shows up immediately as "0 telegrams accepted" rather than as a
|
|
# quietly wrong number.
|
|
meter_phases: int(1,3)
|
|
meter_max_age_s: int(5,300)
|
|
meter_poll_s: int(1,60)
|
|
meter_mqtt_topic: str?
|
|
p1_host: str?
|
|
p1_import_entity: str?
|
|
p1_export_entity: str?
|
|
p1_phase_import_entities:
|
|
- str
|
|
p1_phase_export_entities:
|
|
- str
|
|
p1_net_entity: str?
|
|
p1_phase_net_entities:
|
|
- str
|
|
|
|
max_w: int(100,5000)
|
|
gain: float(0.05,1.0)
|
|
slew_w: int(50,5000)
|
|
deadband_w: int(0,500)
|
|
target_grid_w: float(-200,200)
|
|
step_w: int(1,100)
|
|
saturation_w: int(100,2000)
|
|
saturation_cycles: int(1,10)
|
|
integrator_max_w: int(0,15000)
|
|
heartbeat_s: int(2,25)
|
|
stale_input_s: int(5,120)
|
|
cycle_floor_s: int(5,60)
|
|
auto_start: bool
|
|
|
|
# No "off" value, deliberately: this is a P0 safety check and the schema is
|
|
# not the place to make disarming it a supported configuration.
|
|
sign_disagree_w: int(50,5000)
|
|
sign_disagree_s: int(30,1800)
|
|
|
|
# No "off" value, for SAFETY-02's reason: disarming a safety alarm is not a
|
|
# supported configuration. ⚠️ THE FLOOR OF 60 IS NOT A RECOMMENDATION. At 300 s
|
|
# the slowest a HEALTHY meter's 1 Wh register can tick and still look unchanged
|
|
# is 12 W, which is inside the 15 W deadband, so the two terms of the
|
|
# conjunction are mutually exclusive rather than merely far apart. At 60 s that
|
|
# figure is 60 W and the argument is gone - only the margin is left. See
|
|
# app/freeze.py.
|
|
meter_freeze_s: int(60,3600)
|
|
|
|
maintenance_enabled: bool
|
|
maintenance_interval_days: int(1,90)
|
|
maintenance_start_hour: int(0,23)
|
|
maintenance_discharge_w: int(500,5000)
|
|
maintenance_charge_w: int(500,5000)
|
|
maintenance_soc_floor: int(5,30)
|
|
maintenance_soc_target: int(50,100)
|
|
maintenance_hold_min: int(5,480)
|
|
|
|
peak_forecast_entity: str?
|
|
peak_cap_w: int(500,15000)
|
|
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)
|