Files
goodwe-addon/goodwe_controller/translations/en.yaml
T
glenn schrooyenandClaude Opus 5 2bb07ccdde
checks / unit (pull_request) Successful in 6m9s
UX-02: the panel was weaker than the startup gate for one field
Applying review finding N1, which was non-blocking because it fails closed, and
N2, which is a fabricated number.

N1. Clearing the `max_w` box answered HTTP 500 on a non-JSON body, which the
page rendered as "cannot reach the add-on" - untrue, and for the most prominent
limit field's most likely transient state. The cause was a disagreement between
two files that `inverter.py`'s own docstring says to keep in step:

  validate.py `_number()`  reads "" as "use the default", so rule 9 saw 2000
                           and was content
  inverter.py line 515     did a bare `float(opts.get("max_w", 2000))`, which
                           raises ValueError on ""

So a shape rule 9 TOLERATED could not be composed at all - the docstring's rule
inverted. The roster path never had this: `_entry_max_w` already guards. Only
the flat single-inverter path did. `_flat_max_w()` now reads empty as the
default exactly as `_number()` does, and unparseable as 0.0 exactly as
`_entry_max_w` does, where rule 9 refuses it by name at line 790.

⚠️ THE FIRST ATTEMPT AT THIS MADE IT WORSE, AND THE PROBE CAUGHT IT. Guarding
`build_roster` in `selfconfig` with `except: return validate(opts), []` turned
the 500 into ZERO PROBLEMS for `max_w: ""` - the validator has nothing to say
about it, so an empty list came back and the page would have ENABLED SAVE. A
crash became a silent acceptance. The guard is kept as a backstop for shapes
neither file anticipates, but it can no longer return an empty list: if the
roster cannot be composed and the validator names no reason, that disagreement
is itself reported.

MEASURED after the fix, `problems_and_ranges` with no HA:
  max_w 5000, slew_w 400   0 problems      (the live shape)
  max_w "",   slew_w 400   1 problem       rule 10, "against a site rail of
                                           2000 W" - clearing the box FAILS
                                           CLOSED on the live install rather
                                           than silently halving the rail
  max_w "",   slew_w 0     0 problems      accepted at the 2000 default
  max_w "abc"              1 problem       "max_w 'abc' is not a number"
Before the fix, both "" and "abc" raised ValueError instead.

N2. `translations/en.yaml:7` said "all 71 keys ... so ALL SEVENTY are rendered".
The 70->71 correction had been made in one half of the sentence and not the
other. 59-of-71 required is correct and was re-measured.

Suite: 24 files, all pass.

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

279 lines
32 KiB
YAML

# Home Assistant add-on translations - ENGLISH (UX-01)
#
# ⚠️ WHAT THIS FILE IS FOR, AND WHAT HOME ASSISTANT ACTUALLY DOES WITH IT.
# Without a `translations/` directory the Configuration tab renders the raw
# option KEYS - `unit_sign_disagree_w`, `saturation_cycles`, `integrator_max_w`.
# The frontend keeps a field when `e.name in options || e.required`, and all 71
# keys are in `options:` (59 of them are `required` as well), so ALL SEVENTY-ONE are
# rendered, always, in `schema:` order, with no description. This file is what
# turns that into sentences.
#
# HOW IT IS READ, measured on the Supervisor image this site runs
# (ghcr.io/home-assistant/amd64-hassio-supervisor:2026.08.0):
#
# * `supervisor/store/data.py:_read_app_translations()` reads every
# `.yaml`/`.yml`/`.json` in this directory, keyed by FILE STEM (so this file
# is the `en` translation), and validates each through
# `SCHEMA_APP_TRANSLATIONS`: `configuration: {str: {name (required),
# description, fields}}` plus `network: {str: str}`. ⚠️ A FILE THAT FAILS
# THAT SCHEMA IS DROPPED WITH A LOG WARNING AND NOTHING ELSE - the add-on
# goes on rendering raw keys and nobody is told twice. tools/
# supervisor_shape_probe.py section F is what says it loaded.
# * The frontend resolves each label as
# `translations[<user's language>]?...name || translations["en"]?...name ||
# schema.name`, and the helper text the same way with `description`. ⚠️ THE
# FALLBACK IS PER KEY, so a key missing from nl.yaml degrades to this file,
# while a key present but wrong does not degrade at all.
#
# ⚠️ ORDER IS NOT SET HERE. `App.schema_ui` is `UiOptions(...)(data["schema"])`,
# so the Configuration tab renders in the order of config.yaml's `schema:`
# block. The order below only mirrors it so the two files read alike.
#
# ⚠️ ADD-ON OPTIONS ARE A FLAT LIST. There is no section-header primitive, no
# per-option "advanced" flag, no units on the input and no sliders, so grouping
# is approximated by ORDER plus the `Group · ` prefix in each label. Do not
# promise the operator visual sections; there are none.
#
# ⚠️ EVERY DERIVED CONSTANT SAYS WHERE IT CAME FROM. The Configuration tab
# presents a number read off 1890 closed-loop test runs and the operator's own
# inverter rating as the same kind of field. The descriptions are the only place
# that difference can be stated, so a derived value names its campaign and says
# "do not change without re-deriving", and a value that is genuinely the
# operator's says so too.
configuration:
setpoint_entity:
name: "Hardware · Inverter setpoint entity"
description: "The ESPHome number entity this add-on writes the power setpoint to, normally number.*_goodwe_setpoint_w. This is the only thing the add-on commands. Leave it empty if you fill in the inverter list below: a site with a roster must not also name one loose actuator, and the add-on refuses to start when both are set."
soc_entity:
name: "Hardware · Battery charge level entity (%)"
description: "The sensor that reports the pack's state of charge in percent. Use the ESP32's own reading, not a figure computed somewhere else. The maintenance cycle and every charge-level floor read this."
batt_entity:
name: "Hardware · Battery power entity (W)"
description: "The sensor that reports this inverter's own battery power, positive while discharging. On this installation that is holding register 0x0518, read by the ESP32. The sign watchdog and the circulating-power alarm both grade the inverter against this reading."
batt_invert:
name: "Hardware · Battery power sign is reversed"
description: "Only switch this on if your battery power sensor reports discharging as a negative number. The add-on expects positive to mean discharging."
inverters:
name: "Hardware · Inverter list (leave empty if you have one inverter)"
description: "Leave this empty unless the site really has more than one inverter. EMPTY DOES NOT MEAN 'not configured yet': it means the four entity settings above, together with the inverter rating and the battery cut-out under Limits, describe the one inverter you have. That is what every existing installation reads, and nobody has to touch anything. Filling this in REPLACES those settings instead of extending them, and nothing is inherited: each entry has to name its own actuator, its own rating and its own battery cut-out, so that no inverter is ever judged against another unit's numbers. The flat setpoint entity above must then be left empty."
fields:
name:
name: "Name"
description: "A short identifier for this inverter, for example inverter-a. It gives this unit its own namespace for its safety latch, heartbeat and write bookkeeping in the add-on's state file, so two units may not share one."
setpoint_entity:
name: "Setpoint entity"
description: "This inverter's own ESPHome number.*_goodwe_setpoint_w. Two entries may not name the same one - two writers on one actuator is refused at startup."
max_w:
name: "Inverter rating (W)"
description: "The supervised ceiling on what may be written to THIS inverter; the site's ceiling is the sum over the list. Required of every entry: an entry without one would have to borrow a rating, and judging one inverter against another unit's numbers is exactly the fault this per-entry list exists to remove."
bms_soc_floor:
name: "Battery cut-out level (%)"
description: "The percent at which THIS pack's own battery management system cuts out. Read it off the pack that is actually installed (register 0x0560) before that pack is ever commanded; do not copy another unit's figure. Required of every entry, deliberately: an optional one would be a defaulted one, and one default for two batteries of different models protects the shallower pack with the deeper pack's number."
soc_entity:
name: "Battery charge level entity (%) (optional)"
description: "This pack's own state of charge. Leave it empty if this unit reports none - that is what it means, and it never falls back to another unit's sensor."
batt_entity:
name: "Battery power entity (W) (optional)"
description: "This unit's own battery power, positive while discharging. Leave it empty if this unit reports none; nothing is inherited from another entry."
batt_invert:
name: "Battery power sign is reversed (optional)"
description: "Only for this entry: switch it on if this unit's battery power sensor reports discharging as a negative number."
estop_fitted:
name: "Hardware · RS485 emergency stop is fitted"
description: "Whether the RS485 emergency stop is physically installed. It only drives the warning banner on the status page; it changes no control behaviour."
meter_source:
name: "Meter · Where the meter reading comes from"
description: "'off' keeps the single Home Assistant entity below, which is what an existing installation runs on until you choose otherwise. ha_dsmr subscribes to the DSMR integration; mqtt_p1 reads an MQTT topic; ha_signed reads one signed entity, which is what a HomeWizard P1 publishes; homewizard_local polls the meter's own local API and skips Home Assistant entirely. ⚠️ Only homewizard_local can tell 'the meter has not answered' apart from 'the value has not changed', so it is the only source on which the frozen-meter alarm means anything."
meter_entity:
name: "Meter · Grid power entity (W)"
description: "The net grid power sensor, used when the meter source is 'off'. POSITIVE MUST MEAN DRAWING FROM THE GRID. Get this the wrong way round and the loop drives the battery in the wrong direction; use the sign switch below rather than a template sensor."
meter_invert:
name: "Meter · Grid power sign is reversed"
description: "Switch this on if your grid sensor reports drawing from the grid as a negative number."
meter_phases:
name: "Meter · Number of phases (1 or 3)"
description: "Must match your connection. A telegram whose phase count disagrees is rejected as it arrives and logged, so a wrong value shows up straight away as 'no telegrams accepted' rather than as a quietly wrong number. The range accepts 2, but that is not a real Belgian connection."
p1_host:
name: "Meter · HomeWizard P1 address"
description: "For meter source homewizard_local only: the meter's own address on your network, as host or host:port, for example 192.168.2.250."
meter_poll_s:
name: "Meter · Seconds between meter polls"
description: "For meter source homewizard_local only. 5 s is the meter's own update rate, so there is nothing to gain below it. It must stay well under the staleness limit below - the reading is never fresher than this interval - and the add-on refuses to start if it is at or above that limit."
meter_max_age_s:
name: "Meter · A reading counts as stale after (s)"
description: "Beyond this the grid figure counts as missing. On its own it does not command 0 W: the 'inputs missing' timer under Advanced starts only after this one has expired, and the two stack. 30 s is six polls of headroom at the default poll interval."
meter_mqtt_topic:
name: "Meter · MQTT topic"
description: "For meter source mqtt_p1 only: the topic the P1 readings are published on."
p1_import_entity:
name: "Meter · Import register entity (W)"
description: "The UNSIGNED consumption register. Do not point this at a signed template sensor: the add-on works out net power itself as import minus export, so the sign convention lives in tested code rather than in unreviewed YAML."
p1_export_entity:
name: "Meter · Export register entity (W)"
description: "The UNSIGNED injection register, the counterpart of the import register above. Same rule: no signed template sensor."
p1_net_entity:
name: "Meter · Signed net power entity (W)"
description: "For meter source ha_signed only. ONE signed sensor: positive is drawn from the grid, negative is fed back into it. Do not split it into two template sensors - the add-on does that split itself, so that it is tested."
p1_phase_import_entities:
name: "Meter · Per-phase import entities (L1, L2, L3)"
description: "Optional, in L1..L3 order. Needed for the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases above."
p1_phase_export_entities:
name: "Meter · Per-phase export entities (L1, L2, L3)"
description: "Optional, in L1..L3 order, alongside the per-phase import entities above. The number of entries must equal the number of phases."
p1_phase_net_entities:
name: "Meter · Per-phase signed net entities (L1, L2, L3)"
description: "For meter source ha_signed only. Optional, in L1..L3 order, each signed the same way as the net entity above. Same job as the per-phase import and export entities: the capacity-tariff quarter-hour peak on a three-phase connection. The number of entries must equal the number of phases."
max_w:
name: "Limits · Inverter rating (W)"
description: "THIS IS YOUR NUMBER, not a derived one: the hard ceiling on what may ever be commanded, in watts. Set it to what your inverter is actually rated for, start low and raise it once the site has been commissioned. It is not read at all when the inverter list is filled in - each entry then carries its own, and the site ceiling is their sum. Several safety thresholds must stay below it, and the add-on refuses to start if one does not."
bms_soc_floor:
name: "Limits · Battery cut-out level (%)"
description: "MEASURED, not chosen: the percent at which the pack's OWN battery management system stops discharging, whatever this add-on is set to. Register 0x0560 reads 10 on the reference GW5000S-BP. Read it off the pack you actually have rather than copying this figure. It is here so the add-on can refuse a capacity-guard floor at or below it: such a floor could never bind, the BMS would win first, and the status page would report an energy backstop that does not exist. Not read when the inverter list is filled in - each entry then states its own pack's cut-out."
peak_cap_w:
name: "Limits · Capacity-tariff target (W)"
description: "THIS IS YOUR NUMBER: the quarter-hour average demand the peak guard tries to stay under. Take it from your grid contract - in Belgium it is the capacity-tariff peak your distribution charge is billed on."
peak_forecast_entity:
name: "Limits · Quarter-hour demand forecast entity (W)"
description: "Optional. A forecast of the current billing quarter's average demand. Leave it empty and there is no capacity cap at all."
peak_soc_floor:
name: "Limits · Peak guard will not discharge below (%)"
description: "DERIVED, not picked (SAFETY-24): the reference pack holds 13.8 kWh usable and the worst modelled peak-shaving day asks for 878 Wh, which is 6.4 %, so 20 % sits ten points above the pack's own cut-out and still leaves the guard about 1.6 of its worst observed days of room. ⚠️ IT BOUNDS THE PEAK GUARD ONLY. It is not a reserve for the whole pack: ordinary self-consumption discharges straight past it and goes on down to the battery cut-out, and raising this number does not change that by a tenth of a percent. It must stay strictly above the battery cut-out or the add-on refuses to start. Do not change it without re-deriving it."
price_provider:
name: "Tariff · Dynamic price source"
description: "Where day-ahead or real-time prices come from. 'off' is what ships, and means this site runs on the static tariff below with no trading. ⚠️ Switching it on today fetches, checks and publishes prices and changes no energy decision - nothing consumes the price series yet. It may not be combined with the two legacy Home Assistant price entities further down: two price sources on one site, one of which silently wins, is refused at startup rather than left looking armed."
price_markup_eur_kwh:
name: "Tariff · Supplier markup (EUR/kWh)"
description: "Your supplier's margin. The add-on adds it ONLY when the chosen price source does not already include it - under engie_dynamic, totalenergies_dynamic, eneco_eddy and manual_csv it is ignored, and logged as ignored, never applied twice."
price_taxes_eur_kwh:
name: "Tariff · Levies and VAT (EUR/kWh)"
description: "Taxes and levies per kWh, added under the same rule as the supplier markup: only when the chosen price source does not already carry them."
export_price_eur_kwh:
name: "Tariff · Injection price (EUR/kWh)"
description: "What you are paid for feeding a kWh back into the grid, all in. One number rather than a series: no supplier here publishes a dynamic injection curve. In Belgium it is often close to zero, which on its own strongly favours using your own production over exporting it. It may be negative - at midday in summer you can be paying to export."
static_profile:
name: "Tariff · Fixed or day/night tariff (one line of JSON)"
description: "Your fixed or day/night contract, written as a single line of JSON. RECOMMENDED EVEN ON A DYNAMIC CONTRACT: this is what the add-on falls back to when the price feed dies, and with it empty a dead feed leaves the site with no tariff at all. The periods must cover every day type exactly once, with no gap and no overlap - a gap is refused at startup rather than filled in, because an invented price is one a strategy would trade on. A fully fixed tariff is one period covering 00:00-24:00 on all days. Prices are ALL IN; the markup and levies above are not added to them. Note that a window written 22:00-07:00 for mon-fri does NOT cover Saturday morning, so a weekend row is required rather than decorative."
price_csv_path:
name: "Tariff · Price CSV file"
description: "For price source manual_csv only: where in /share the add-on reads your own price file from."
price_feed_url:
name: "Tariff · Price feed URL override"
description: "Leave empty to use the chosen source's own feed. Only a host that source itself declares is accepted, so this cannot be pointed anywhere."
price_horizon_h:
name: "Tariff · How far ahead to fetch prices (hours)"
description: "How many hours of prices to ask the source for."
price_timeout_s:
name: "Tariff · Price request timeout (s)"
description: "How long a single request to the price source may take before it is given up on."
price_now_entity:
name: "Tariff · Current price entity (older price path)"
description: "The older way of getting a price, kept because retiring it would take the maintenance cycle's only price signal away from a live installation. The add-on reads this and the average below and derives exactly one thing from them - whether now is cheaper than average - which only the maintenance cycle's cheap-window charging uses. It may not be combined with a dynamic price source."
price_avg_entity:
name: "Tariff · Average price entity (older price path)"
description: "The average the current price is compared against. Only meaningful together with the current-price entity above, and like it, may not be combined with a dynamic price source."
arbitrage_enabled:
name: "Tariff · Trade on the price spread"
description: "Charge in the cheapest published hour and discharge in the dearest, but only when the spread covers what the round trip costs. OFF in the shipped configuration, deliberately: this is the first strategy in the add-on that moves energy against the meter on purpose, so it is armed on purpose or not at all. It needs both a price source and a P1 meter, and the add-on refuses the combinations that cannot work rather than letting them look armed."
cycle_cost_threshold_eur:
name: "Tariff · Cost of one full charge/discharge cycle (EUR/kWh)"
description: "What one full round trip costs per kWh: wear on the pack plus conversion losses. A price spread smaller than this loses money and battery life, so it is not traded. ⚠️ THIS ONE IS NOT DERIVED FROM A MEASUREMENT. 0.10 is a product decision and it is still an open question with the owner (OPT-08). At this value a fixed day/night site does not trade at all - the realistic Belgian day/night spread is about 0.08 EUR/kWh - and that is the correct verdict at this number, not a fault."
arbitrage_soc_floor:
name: "Tariff · Trading may not discharge below (%)"
description: "The reserve price trading may not spend. Deliberately not the same key as the maintenance floor: pointing a trading strategy at the monthly balancing cycle's numbers would couple two unrelated policies through one setting."
arbitrage_soc_ceiling:
name: "Tariff · Trading may not charge above (%)"
description: "The headroom price trading may not fill. The counterpart of the trading floor above, and kept separate from the maintenance target for the same reason."
maintenance_enabled:
name: "Maintenance · Run the monthly battery cycle"
description: "A deliberate deep discharge followed by a full recharge and a hold, so the battery management system can recalibrate its charge estimate and balance the cells. Off by default."
maintenance_interval_days:
name: "Maintenance · Minimum days between cycles"
description: "The shortest gap between two maintenance cycles."
maintenance_start_hour:
name: "Maintenance · Hour of day a due cycle starts"
description: "Local hour, 0 to 23, at which a cycle that has become due begins."
maintenance_discharge_w:
name: "Maintenance · Discharge rate (W)"
description: "How fast the cycle drains the pack; the surplus is exported. Above the inverter rating it is CLAMPED to the rating rather than refused - the cycle still completes, just more slowly, and the status page says so. It ships at 2000 to match the shipped rating; raise the rating first, then this."
maintenance_charge_w:
name: "Maintenance · Recharge rate (W)"
description: "⚠️ MUST STAY AT OR BELOW the inverter rating, and the add-on refuses to start otherwise. Above it, the cheap-window charge shaping asks for more than the supervised limit allows, the arbiter resolves that contradiction to 0 W, and maintenance would silently never charge in a cheap window on a default installation. It is also capped again by whatever headroom the peak guard leaves."
maintenance_soc_floor:
name: "Maintenance · Discharge down to (%)"
description: "The depth the monthly recalibration deliberately goes to, once a month, on purpose. ⚠️ THIS IS NOT THE PEAK GUARD'S FLOOR: that one is the depth a money-saving discharge may never reach on any day. The right values genuinely differ - 11 here against 20 there - which is why they are two separate settings and must not be merged."
maintenance_soc_target:
name: "Maintenance · Recharge up to (%)"
description: "The level the recharge phase aims for before the hold begins."
maintenance_hold_min:
name: "Maintenance · Hold at full for (minutes)"
description: "How long to sit at the target so the battery management system can finish balancing the cells."
sign_disagree_w:
name: "Safety · Sign watchdog threshold (W) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). Both the setpoint being emitted and the grid reading must exceed this before their opposite signs count as a disagreement at all. On the captured healthy day (2026-08-23) sign agreement over the gated samples is 93.4 %; on the captured fault day (2026-08-20) the clusters run 0.0-10.5 % with opposed runs up to 1752 s. There is no reason to move this without a new capture that says so. It must stay below the inverter rating or the watchdog can never fire, and the add-on refuses that configuration."
sign_disagree_s:
name: "Safety · Sign watchdog hold (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-02). How long the setpoint and the meter must stay opposed, continuously, before the add-on commands 0 W and latches until an operator starts control again. 180 s sits in the middle of a very wide measured gap: the longest legitimate opposed run on the healthy day is 30 s, while the fault day still trips 13 times at 300 s. Do not change it without a new capture."
unit_sign_disagree_w:
name: "Safety · Per-inverter sign threshold (W) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this setting does nothing at all. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS on the two-master rig (SAFETY-30), in which nothing was faulty, swept across the whole range this add-on accepts for loop gain and slew rate. 250 W is exactly half the saturation threshold, so the smallest divergence this can see is precisely the gap at which the control law itself already says the inverter is not following. Do not change it without re-deriving it. It must stay below the inverter rating or it can never fire."
unit_sign_disagree_s:
name: "Safety · Per-inverter sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30): 85 s is 8.5 times the worst legitimate opposition those runs produce (10.0 s), 2.8 times the firmware's own 30 s link veto, and under half the 180 s the site-wide check takes. Do not change it without re-deriving it."
site_sign_disagree_w:
name: "Safety · Site-wide sign threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY. The same 300 W as the single-inverter threshold, CARRIED ACROSS DELIBERATELY (SAFETY-30): the argument behind it does not depend on how many inverters there are - near zero the sign is noise, the deadband is 15 W, and there is one meter whatever is behind it. Do not change it without a capture that says so."
site_sign_disagree_s:
name: "Safety · Site-wide sign hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED FROM 1890 CLOSED-LOOP TEST RUNS (SAFETY-30), and deliberately NOT carried across from the single-inverter 180 s. On those runs the worst legitimate opposition at 300 W is 110 s with one inverter and 215 s with two, because the command has to travel the whole site rail at a slew rate that is not the site's - so carrying 180 s across would have shipped a false trip reachable at the lowest gain and slew with an ordinary 2.2 kW load reversal. 325 s is 1.51 times that, still inside the 900 s billing quarter, and still inside every episode of the captured fault day. Do not change it without re-deriving it."
meter_freeze_s:
name: "Safety · Frozen-meter alarm after (s) — measured, do not change"
description: "MEASURED, NOT CHOSEN (SAFETY-18). How long the meter may keep answering with an UNCHANGED reading, while the command stands at the rail, before the add-on raises an alarm. Over 2399 s of this house at its settled operating point - 1194 polls, 735 of them repeating the previous reading - the longest run without a distinct reading is 15.4 s; 300 s is 19.5 times that and still inside the 900 s billing quarter. ALARM ONLY: it does not command 0 W and does not latch. ⚠️ Only the homewizard_local meter source can see this at all; on every other source the status page says NOT COVERED rather than armed. Do not change it without re-deriving it."
circulation_w:
name: "Safety · Circulating-power threshold (W) — measured, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter the quantity is identically zero. Two inverters pushing against each other net out to zero at the meter and are invisible to every other guard in this add-on. MEASURED ON 924 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-31): the worst legitimate opposition collapses from 505 s at a 100 W threshold to 5 s at 400 W, and 400 W is the sharp knee this add-on will not let you go below - under it, ordinary operation holds the sensor over the threshold for minutes and nothing shorter than the billing quarter could tell that from a fault. 700 W is 1.75 times the knee, and the extra margin is there because the second inverter's ramp and lag are still copied from the first one rather than measured. Do not change it without re-deriving it."
circulation_hold_s:
name: "Safety · Circulating-power hold (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY. DERIVED (SAFETY-31): 75 s is fifteen control cycles - 15 times the worst legitimate episode the rig can produce, 2.5 times the firmware's own 30 s link veto, and twelve times inside the 900 s billing quarter. It is deliberately not a round 60 or 120, which would be a guess wearing a measurement's clothes. Do not change it without re-deriving it."
delivery_hold_s:
name: "Safety · Not-delivering report after (s) — derived, do not change"
description: "TWO OR MORE INVERTERS ONLY; with one inverter this check cannot fire at all, and that is deliberate rather than a limitation. How long ONE inverter may stand at a discharge share it is not producing - short by more than the saturation threshold, with nothing this add-on can read to explain it - while at least one sibling is NOT in that state, before it is named. REPORTS ONLY (SAFETY-42): nothing is commanded differently, the loop is untouched, and the site goes on running short by that unit's share until you look. DERIVED FROM 2640 CLOSED-LOOP TEST RUNS in which nothing was faulty (SAFETY-42, plus 1890 runs at one inverter as the control, which report nothing at any threshold): the worst legitimate episode those runs produce is 30.0 s, the floor of 30 s is the firmware's own link veto below which a unit is already being dropped from the split, and the ceiling is the 900 s quarter that bills the capacity tariff. 185 s is 6.2 times that, and the margin is sized on a measurement rather than a feeling: the second inverter in those tests is a copy of the first, and a second unit four times slower produces 90.0 s. Do not change it without re-deriving it. A pack at or below its own bms_soc_floor is never reported: an empty pack and a wedged one are identical watt for watt, and that setting is the only thing that tells them apart - so if this fires on a pack you believe is empty, that setting does not describe your battery."
auto_start:
name: "Advanced · Start controlling automatically on boot"
description: "Off by default. Leave it off until the site has been commissioned: with it off, somebody has to press Start after every restart of the add-on, which is what you want while you are still checking signs and entities."
gain:
name: "Advanced · Loop gain"
description: "How much of the remaining error the loop corrects each cycle. MEASURED ON REAL HARDWARE and already at the limit - do not raise it. Lower is slower and safer."
slew_w:
name: "Advanced · Largest change per cycle (W)"
description: "How far the setpoint may move in one control cycle. ⚠️ 0 MEANS FOLLOW THE INVERTER RATING and is the recommended setting: the limit becomes about a ninth of the rating, so the loop takes the plant's own MEASURED settling time to cross its whole output range and the number cannot be left behind when the rating changes. DERIVED (SAFETY-39) from an ~18 s measured step response against a 2 s meter tick. A fixed number is accepted and is never silently reduced, but a number faster than the rating allows is refused at startup - at 1000 W against a 5000 W rating this loop drove the meter from 3.4 kW of export to 2.8 kW of import on 2026-09-05. Lower is always accepted."
deadband_w:
name: "Advanced · Deadband (W)"
description: "Errors smaller than this are ignored. ⚠️ The deadband is a one-way ratchet: any resting point inside it holds forever, and the meter's import register counts every positive watt with no export to cancel it - which is exactly why the resting point below is slightly negative."
target_grid_w:
name: "Advanced · Resting point at the meter (W)"
description: "What the meter should sit at when nothing is happening; negative means a slight export. -10 W is deliberate: resting at 0 W leaks about half the deadband in billed import all day - roughly 0.2 to 0.35 kWh at a 15 W deadband - and biasing the rest point below zero moves that leak into the export register, which is not billed. It costs about this many watts of given-away export, so keep it small."
step_w:
name: "Advanced · Setpoint quantisation (W)"
description: "The setpoint is rounded to a multiple of this before it is written, so the inverter is not handed a new number for every watt of noise."
saturation_w:
name: "Advanced · Saturation threshold (W)"
description: "The gap between what was commanded and what the inverter delivers that counts as 'this inverter is at a limit'. ⚠️ The per-inverter sign threshold under Safety is derived as exactly half of this, so moving this moves that measurement's argument with it."
saturation_cycles:
name: "Advanced · Saturated cycles before freezing (count)"
description: "How many consecutive saturated cycles before the loop stops winding up. ⚠️ Do not set this to 1: a single noisy cycle would then freeze the loop."
integrator_max_w:
name: "Advanced · Integrator bound (W, 0 means the same as the rating)"
description: "Caps how much stale error can be waiting to unwind when the sign flips. MEASURED on the closed-loop simulator with a 4000 W load dropped to zero: at a bound equal to the rating the command is 1000 W two cycles later, at 1.5 times the rating it is 1800 W. ⚠️ Do not raise it above the inverter rating - the output clamp already bounds what reaches the wire, so extra headroom buys nothing but wrong-direction watts after every saturation event. Lowering it below the rating is the useful direction, and the only reason this is a separate setting."
heartbeat_s:
name: "Advanced · Setpoint refresh interval (s)"
description: "How often the setpoint is rewritten even when it has not changed. It must stay well under the firmware's own watchdog, which vetoes a link that has been quiet for 30 s."
stale_input_s:
name: "Advanced · Command 0 W after inputs missing for (s)"
description: "How long the inputs may be missing before the add-on commands 0 W. With a P1 meter this clock starts only AFTER the meter staleness limit has already expired, so the two stack: the total is longer than this number on its own."
cycle_floor_s:
name: "Advanced · Longest gap between control cycles (s)"
description: "A deadline on the gap between cycles, reset by every cycle. On a healthy meter it never elapses and changes nothing. Raise it only if your meter is slower than about 10 s, and keep it under the meter staleness limit - past that there is no reading left to evaluate anyway."
log_level:
name: "Advanced · Log level"
description: "How much detail the add-on writes to its log. 'info' is the shipped setting; 'debug' and 'trace' are for diagnosing a problem and are noisy."