checks / unit (pull_request) Successful in 4m58s
MQTT discovery announced ONE device and hung every entity off one unique_id prefix, so at two inverters B's per-unit telemetry had nowhere to go. It has somewhere now, and not one entity that already exists moved. ⚠️ THE ASYMMETRY IS DELIBERATE. The first roster entry's per-unit rows are announced on THE DEVICE IT ALREADY HAS; every later entry gets its own device, via_device the controller. "One device per inverter" is tidier and is the change that renames every entity on the live install - and because HA keeps the id it composed at FIRST discovery, a rename produces a NEW entity beside a DEAD one: the history stays behind and every dashboard, template and automation naming it goes on pointing at a sensor that quietly stopped updating, including the three live watchdog automations. The composed ids come out symmetric anyway (sensor.goodwe_inverter_a_battery_power / ..._inverter_b_...), so the tidy design's only real benefit is bought here without its cost. * four per-unit rows - setpoint, battery power, SoC, SAFETY-02 sign latch - announced only from a roster of two. At N=1 nothing is announced and nothing is published: no second device, no renamed entity, no added topic. * grid and p1_sample_age_s stay SITE-scoped and are announced ONCE, at every roster length 1-6. One P1, one grid connection, and p1_sample_age_s is a watchdog input - a second copy is a second answer to "is the meter fresh?". * inverter.roster_names() is the one answer to what the units are called, read by build_roster() and by the publisher, which must know the roster before a Controller exists because announcing happens on the CONNACK. * DECISIONS/TEL-07-second-mqtt-device.md records the two designs side by side. VERIFIED BY DIFFING THE ANNOUNCED PAYLOADS, not by reading the code. The release/1.0 baseline in test_mqtt.py was generated by running that commit's module, not transcribed. Roster of one: 9 topics, 0 missing, 0 changed, 0 added. Roster of two: 0 missing, 0 changed, 8 added. MEASURED, NOT ASSERTED. Sixteen mutations through tools/mutation_run.sh; twelve are in the file as a running table, each named with the property it breaks. Three of those properties exist because the table found their ABSENCE - dropping the unit from the state topic, moving `grid` into the per-unit set, and `units=()` in main()'s publisher call were all GREEN on the first run, as was a roster fallback naming two nameless entries `inverter-a`. Part 9 enumerates what the file still cannot see, with sizes. ⚠️ INVERTER B DOES NOT PHYSICALLY EXIST (~2026-09-12). Every roster of two here is SIMULATED, from the datasheet and serial in NOTES.md §1.1b. Nothing about B has been measured and no entity described here has been seen on a broker. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BwRLw7eKYj2byVdwB6kbjU
525 lines
27 KiB
Python
525 lines
27 KiB
Python
"""One inverter, as an OBJECT rather than as a set of Controller scalars.
|
|
|
|
WHAT THIS FIXES, in one sentence: every piece of per-device state in this
|
|
add-on used to be a single scalar on the Controller, and several of those
|
|
scalars are SAFETY INPUTS - so a second inverter would have been graded against
|
|
the first one's numbers.
|
|
|
|
The scalars, and what each one was silently asserting:
|
|
|
|
`dev_min` / `dev_max` ONE pair, read from ONE `setpoint_entity`. A second
|
|
inverter's rating was never read at all, so its clamp,
|
|
its freeze rail and the peak guard's `achievable_w`
|
|
were all inverter A's.
|
|
`soc` ONE state of charge into the maintenance FSM,
|
|
SAFETY-24's pack floor and arbitrage's bounds. An
|
|
average lets the emptier pack be driven below its own
|
|
floor; a maximum parks the fuller one.
|
|
`bms_soc_floor` SAFETY-32. ONE cut-out percent for every pack, and it
|
|
describes A BATTERY. Two packs of different models
|
|
have different cut-outs, so the flat key graded the
|
|
shallower pack against the deeper one's BMS - wrong
|
|
in the direction that empties a pack.
|
|
`batt` ONE battery power into the anti-windup saturation
|
|
detector - see the SITE/UNIT note at the bottom of
|
|
this docstring, because this one goes the OTHER WAY.
|
|
`sign_wd` ONE SignWatchdog, whose own docstring says "one
|
|
instance per actuator", latched under FLAT store keys.
|
|
A trip caused by B latched A across restarts and one
|
|
operator action cleared both.
|
|
`last_write` ONE write bookkeeping, and ONE serialised `hass.call()`
|
|
behind it: a hung write to A ate B's 30 s layer-1
|
|
budget.
|
|
|
|
⚠️ WHAT IS DELIBERATELY *NOT* HERE, AND IT IS THE HALF THAT IS EASY TO GET
|
|
BACKWARDS. THERE IS NO `Tuning` ON THIS OBJECT AND THERE IS NO INTEGRATOR.
|
|
§6.2 and ALLOC-01's stability argument are ONE integrator on P1, ABOVE the
|
|
split: the site target S is divided into per-unit shares, so the gain seen at
|
|
the meter is a CONVEX COMBINATION of the per-unit gains and a bad split cannot
|
|
destabilise the site. Give each inverter its own `Tuning` and somebody will
|
|
eventually call `compute()` with it, and the moment two integrators regulate
|
|
one meter the whole argument is gone - that is the two-writers failure the
|
|
arbiter exists to prevent, one level up.
|
|
|
|
The one field of `Tuning` that genuinely IS per-device is `max_w`, the
|
|
supervised cap on what may be WRITTEN to one setpoint entity (§11.3), and it is
|
|
here as `max_w`. The SITE `Tuning` the loop runs on takes its bound from the
|
|
roster - `sum(inv.max_w)` - so at N=1 it is byte-identical to the shipped
|
|
number and at N>=2 it is what the site can actually deliver. That is the whole
|
|
of "the rating stops being a scalar"; a per-unit Tuning would be three more
|
|
numbers nobody reads and one more place a limit has to be kept in step (CFG-03
|
|
already tracks three).
|
|
|
|
------------------------------------------------------------------------------
|
|
SITE OR UNIT? THE TWO HALVES OF THE N=1 IDENTITY, AND THEY MOVE IN OPPOSITE
|
|
DIRECTIONS
|
|
|
|
At N=1 §6.2's identity holds: the fabricated grid reading and the battery
|
|
setpoint are the same number, so "the site" and "the inverter" are the same
|
|
thing and every scalar above was RIGHT. Splitting them is not one change, it is
|
|
two, and they point opposite ways:
|
|
|
|
COMMANDING becomes per-unit. One setpoint entity per inverter, one rating,
|
|
one clamp, one watchdog, one heartbeat.
|
|
SENSING, for the loop, becomes SITE. `compute()` regulates the METER. Its
|
|
`actual_w` is the anti-windup input to
|
|
`abs(prev_w - actual_w) > saturation_w`, where
|
|
`prev_w` is the SITE target. Feed it ONE unit's
|
|
power at a roster of two and that test compares
|
|
a site number against a unit number, trips the
|
|
freeze permanently and leaves a standing grid
|
|
error.
|
|
|
|
That second half is MEASURED, not argued. On admin/goodwe's two-master rig
|
|
(`tools/alloc_sim.py --splits --feedback unit`), with the loop fed one
|
|
inverter's power the aggregate trace at the meter becomes a function of the
|
|
split - up to 510 W apart at gain 0.2, 250 W at gain 0.4, 50 W at gain 0.6 -
|
|
while the allocator's shares sum to S in every one of those runs. The
|
|
counterfactual is the proof: change that ONE input to the site total and every
|
|
split is bit-identical at every gain the schema accepts. ALLOC-01's review
|
|
isolated it and test_alloc.py part 8 graded it as a disclosed defect waiting on
|
|
this card.
|
|
|
|
So this object owns `batt` - THIS inverter's own power, which is what a
|
|
per-unit consumer needs - and the Controller sums them into the site figure the
|
|
loop is fed. Getting that backwards reintroduces the divergence.
|
|
|
|
------------------------------------------------------------------------------
|
|
LIVENESS, AND WHY IT IS A METHOD WITH A REASON
|
|
|
|
`Controller.live_units()` decides which inverters may be commanded this tick,
|
|
and `allocator.plan()` DROPS what it does not list - the survivors then carry
|
|
the whole site target. A wrong answer there is a real change in what the site
|
|
can deliver, so `is_live()` answers one question and says which: CAN THIS
|
|
CONTROLLER PUT A NUMBER ON THIS INVERTER RIGHT NOW.
|
|
|
|
ONE way it cannot, and exactly one: every write has been REJECTED for a whole
|
|
`LINK_STALE_S` window. Not one rejection - `hass.set_number()` returns False for
|
|
a transient 400 as readily as for a device that has gone - but a continuous run
|
|
of them with no acceptance in between.
|
|
|
|
⚠️ AND NOT "IT HAS NO `setpoint_entity`", THOUGH THAT IS ALSO A UNIT THIS
|
|
CONTROLLER CANNOT COMMAND. The two look the same and want opposite answers. A
|
|
link that WAS working and stopped is an event: the survivors carry its share,
|
|
the record says so, and the site goes on running at reduced strength, which is
|
|
the best available outcome. A roster entry with no writer configured at all is a
|
|
CONFIGURATION that cannot be delivered - it was never reachable, nothing has
|
|
gone wrong on the wire, and quietly running the site at part strength on a
|
|
roster somebody wrote down wrong is how a half-driven site reads as healthy on
|
|
every dashboard. That one belongs to main.py's refusal branch, which commands
|
|
0 W and NAMES the inverter; putting it here would absorb it into the ordinary
|
|
drop and delete the last trigger that branch has. See `Controller.cycle()`.
|
|
|
|
⚠️ AND NOT "ITS SENSORS ARE MISSING" EITHER. A pack whose SoC did not answer is
|
|
a third fault with a third remedy: `inputs_ok` already stops the whole site at
|
|
P_SAFETY for that, and dropping the unit from the SPLIT instead would hand its
|
|
share to a sibling while the site kept regulating - the opposite of what a
|
|
missing measurement should cause. Liveness is about the WRITE PATH, and only
|
|
about a write path that has stopped answering.
|
|
"""
|
|
|
|
import math
|
|
from datetime import datetime
|
|
|
|
from .clamp import ClampResult
|
|
# CFG-03's ONE in-code mirror of config.yaml's SAFETY-24 defaults. Imported
|
|
# rather than retyped: the flat-keys path below needs `bms_soc_floor`'s
|
|
# shipped default and a literal here would be the fourth defaults site that
|
|
# ticket exists to prevent. peakguard imports arbiter/control/tariff and none
|
|
# of those imports this module, so the direction is one-way.
|
|
from .peakguard import PEAK_OPTION_DEFAULTS
|
|
from .store import unit_key
|
|
from .watchdog import SignWatchdog, SignWatchdogConfig
|
|
|
|
# How long every write to one inverter must have been REJECTED before this
|
|
# controller stops claiming it can command it.
|
|
#
|
|
# ⚠️ IT IS LAYER 1'S OWN WINDOW, NOT A NEW OPINION ABOUT LINKS. The ESP32
|
|
# commands 0 W and keeps commanding it after ~30 s without a refresh
|
|
# (`validate.FIRMWARE_INGEST_VETO_S`), so a link this controller has not been
|
|
# able to write for a whole 30 s is a link the hardware is about to take away
|
|
# from it anyway. Claiming it as commandable past that point is the add-on
|
|
# telling the allocator it can deliver watts that the firmware has already
|
|
# decided it will not. No config key: CFG-03 tracks three places a default has
|
|
# to be kept in step and this does not earn a fourth - it is derived from a
|
|
# constant the firmware and validate.py already share.
|
|
LINK_STALE_S = 30.0
|
|
|
|
# The name of the inverter a SINGLE-INVERTER config describes. ⚠️ A NAME, NOT A
|
|
# CONFIGURATION - it exists so that a per-inverter claim has an actuator to be
|
|
# filed ON, which is what makes the site/unit split something a check can grade
|
|
# rather than a comment. It lives here rather than in main.py now that the
|
|
# roster does.
|
|
#
|
|
# ⚠️ CFG-04 DID NOT MAKE THIS "THE FIRST ONE". It is the name the flat-keys path
|
|
# gives its one unit, and nothing else; a declared `inverters:` roster names its
|
|
# own entries and this constant is not consulted. The store migration
|
|
# (`migrate_unit_keys`) still targets it, because the flat pre-DEV-02 keys can
|
|
# only ever have belonged to a single-inverter install.
|
|
UNIT_A = "inverter-a"
|
|
|
|
# The per-unit keys this object keeps in the Store, namespaced by unit name.
|
|
# ⚠️ THE VALUES ARE THE PRE-DEV-02 FLAT KEY NAMES, and that is what makes the
|
|
# migration mechanical rather than transcribed: `store.migrate_unit_keys()`
|
|
# reads this same mapping, so a key renamed here cannot silently stop being
|
|
# migrated. See store.py.
|
|
UNIT_STORE_KEYS = ("sign_tripped", "sign_reason")
|
|
|
|
|
|
class Inverter:
|
|
"""ONE inverter: its entities, its rating, its watchdogs, its store keys.
|
|
|
|
Constructed once per configured inverter and held in `Controller.inverters`.
|
|
Everything on it is about THIS device; nothing on it is about the site.
|
|
|
|
⚠️ `hass` IS NOT A CONSTRUCTOR ARGUMENT. This object is built in
|
|
`Controller.__init__`, which runs before any I/O and, in four existing
|
|
checks, with `hass=None` entirely. The device range is read LATER, by
|
|
`Controller.run_control()`, from THIS unit's own `setpoint_entity` - which
|
|
is the whole of "each instance's rating is read from its own entity".
|
|
"""
|
|
|
|
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
|
batt_entity="", batt_invert=False, max_w=2000.0,
|
|
bms_soc_floor=None, store=None, sign_cfg=None):
|
|
self.name = str(name)
|
|
self.setpoint_entity = str(setpoint_entity or "")
|
|
self.soc_entity = str(soc_entity or "")
|
|
self.batt_entity = str(batt_entity or "")
|
|
self.batt_invert = bool(batt_invert)
|
|
# The supervised cap on what may be written to THIS setpoint entity.
|
|
# See the module docstring on why this is a number and not a `Tuning`.
|
|
self.max_w = float(max_w)
|
|
# SAFETY-32. THIS PACK'S OWN BMS CUT-OUT, in percent, or None when the
|
|
# roster entry did not state one.
|
|
#
|
|
# ⚠️ NOT DEFAULTED HERE, AND None RATHER THAN A NUMBER. A cut-out is a
|
|
# property of the battery in the cupboard; inventing one for a pack that
|
|
# did not state it is precisely this card's defect - a pack graded
|
|
# against a floor that belongs to a different battery - rebuilt in the
|
|
# constructor. `peakguard.pack_floor_pct()` treats None as an unknown
|
|
# floor and `discharge_permitted()` treats an unknown floor as NO
|
|
# permission, so the absence costs a shave; validate.py rule 8 refuses
|
|
# the config by name long before that, at startup.
|
|
self.bms_soc_floor = (None if bms_soc_floor is None
|
|
else float(bms_soc_floor))
|
|
|
|
# --- the device's own reported range, read from its own entity -------
|
|
# None until `hass.limits()` has answered. NOT guessed at: ESPHome is
|
|
# often not up when the add-on boots, and (None, None) is the honest
|
|
# report that the range checks were skipped rather than invented.
|
|
self.dev_min = None
|
|
self.dev_max = None
|
|
|
|
# --- this unit's own measurements ------------------------------------
|
|
self.soc = None
|
|
self.batt = None
|
|
|
|
# --- what this unit was last told, and what it last accepted ---------
|
|
# `target` is THIS unit's share of the site target from the last
|
|
# resolution - the number its heartbeat re-emits and the number
|
|
# SAFETY-02 grades against the meter. At N=1 it IS the site target,
|
|
# because the allocator is a pass-through there.
|
|
self.target = 0.0
|
|
self.last_write = None
|
|
self.last_write_at = None
|
|
self.last_write_ok = None
|
|
# ⚠️ TWO TIMESTAMPS, NOT ONE FLAG. `last_write_ok` is the last write's
|
|
# verdict and it flips on a single transient 400; liveness needs a RUN
|
|
# of rejections, so `rejected_since` records when the current run
|
|
# started and is cleared by any acceptance.
|
|
self.rejected_since = None
|
|
# The heartbeat's own deadline, on the event loop's monotonic clock.
|
|
# ⚠️ PER UNIT, WHICH IS THE WHOLE OF AC 6. One shared `last_beat` and
|
|
# one serialised write meant a hung call to A pushed B's refresh past
|
|
# layer 1's ~30 s veto - i.e. one inverter's transport fault zeroing a
|
|
# healthy sibling. Starts at 0.0, which is what the single scalar did.
|
|
self.last_beat = 0.0
|
|
|
|
# --- SAFETY-03's per-write evidence ----------------------------------
|
|
# What THIS unit's last write clamped, or None before it has run. The
|
|
# ALARM (`ClampWatchdog`) stays one per controller - clamp.py says so in
|
|
# its own docstring, and it is an anti-flood counter over an event bus
|
|
# rather than a property of a device - but the EVIDENCE is per unit,
|
|
# because a clamp on B says nothing about A.
|
|
self.clamp_last: ClampResult | None = None
|
|
|
|
# --- SAFETY-02, one instance per actuator, under NAMESPACED keys -----
|
|
# ⚠️ THE WATCHDOG'S OWN DOCSTRING SAID "one instance per actuator" WHILE
|
|
# THE CONTROLLER HELD EXACTLY ONE. Restored from THIS unit's own keys,
|
|
# so a trip caused by B latches B and leaves A commanding - and an
|
|
# operator clearing one does not silently clear the other.
|
|
self.store = store
|
|
data = getattr(store, "data", {}) if store is not None else {}
|
|
self.sign_wd = SignWatchdog(
|
|
sign_cfg or SignWatchdogConfig(),
|
|
tripped=bool(data.get(self.key("sign_tripped"), False)),
|
|
reason=str(data.get(self.key("sign_reason"), "") or ""))
|
|
|
|
# -- store ---------------------------------------------------------------
|
|
def key(self, name: str) -> str:
|
|
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
|
return unit_key(self.name, name)
|
|
|
|
def store_set(self, name: str, value) -> None:
|
|
if self.store is not None:
|
|
self.store.set(self.key(name), value)
|
|
|
|
# -- the numbers other modules ask this object for -----------------------
|
|
def rating_w(self) -> float:
|
|
"""The largest magnitude this unit can be commanded, in W.
|
|
|
|
`min(max_w, |dev_min|, |dev_max|)` - the supervised cap and whatever the
|
|
device itself reports, whichever is smaller. It is the same conjunction
|
|
`freeze.rail_ceiling_w()` computes for the freeze rail and the same one
|
|
main.py hands the peak guard as `achievable_w`, which is why it is
|
|
written once here: three transcriptions of a rating is three chances for
|
|
inverter B to be graded against inverter A's number.
|
|
|
|
A bound that could not be read is SKIPPED, not treated as zero: (None,
|
|
None) means ESPHome was not up, and answering 0 W there would say this
|
|
inverter can do nothing.
|
|
"""
|
|
out = abs(float(self.max_w))
|
|
for bound in (self.dev_min, self.dev_max):
|
|
if bound is not None and math.isfinite(float(bound)):
|
|
out = min(out, abs(float(bound)))
|
|
return out
|
|
|
|
def has_writer(self) -> bool:
|
|
return bool(self.setpoint_entity)
|
|
|
|
# -- liveness ------------------------------------------------------------
|
|
def note_write(self, ok: bool, now: datetime) -> None:
|
|
"""Record one write's verdict. The only thing that moves liveness."""
|
|
self.last_write_ok = bool(ok)
|
|
if ok:
|
|
self.rejected_since = None
|
|
elif self.rejected_since is None:
|
|
self.rejected_since = now
|
|
|
|
def is_live(self, now: datetime, stale_s: float = LINK_STALE_S):
|
|
"""`(live, why_not)` - may this controller command this unit THIS TICK?
|
|
|
|
Read the module docstring before widening this - in particular on why a
|
|
unit with NO `setpoint_entity` is deliberately still "live" here and is
|
|
refused one layer up instead. The allocator DROPS what `live_units()`
|
|
does not list and the survivors carry the whole site target, so a false
|
|
"not live" is a site target divided among fewer inverters than exist -
|
|
and at a roster of one it is the allocator refusing outright and the
|
|
site commanding 0 W.
|
|
|
|
⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a
|
|
link goes stale between one cycle and the next.
|
|
"""
|
|
if self.rejected_since is not None:
|
|
held = (now - self.rejected_since).total_seconds()
|
|
if held >= float(stale_s):
|
|
return False, (f"every write rejected for {held:.0f} s "
|
|
f"(limit {float(stale_s):.0f} s)")
|
|
return True, ""
|
|
|
|
def link_note(self, now: datetime) -> str:
|
|
"""One line for the status page. Empty when there is nothing to say."""
|
|
if not self.has_writer():
|
|
return (f"{self.name}: NO SETPOINT ENTITY - this controller cannot "
|
|
f"command it, and the site refuses rather than running at "
|
|
f"part strength")
|
|
live, why = self.is_live(now)
|
|
if not live:
|
|
return f"{self.name}: NOT COMMANDABLE - {why}"
|
|
if self.rejected_since is not None:
|
|
held = (now - self.rejected_since).total_seconds()
|
|
return (f"{self.name}: writes rejected for {held:.0f} s - still "
|
|
f"commandable until {LINK_STALE_S:.0f} s")
|
|
return ""
|
|
|
|
def __repr__(self) -> str:
|
|
return (f"<Inverter {self.name} sp={self.setpoint_entity!r} "
|
|
f"max_w={self.max_w:.0f} dev=({self.dev_min}, {self.dev_max})>")
|
|
|
|
|
|
def roster_entries(opts: dict) -> list:
|
|
"""The `inverters:` option, normalised to a list of dicts. Never None.
|
|
|
|
Separate from `build_roster()` because validate.py needs to grade the SAME
|
|
list this builds from, and a second transcription of "what counts as an
|
|
entry" is a second answer to it. A non-list, or a list with non-dict
|
|
members, comes back as it was so the validator can name what it found -
|
|
dropping the junk here would hand the validator a clean list and a config
|
|
nobody refused.
|
|
"""
|
|
entries = opts.get("inverters")
|
|
if entries is None or entries == "":
|
|
return []
|
|
if not isinstance(entries, (list, tuple)):
|
|
return [entries]
|
|
return list(entries)
|
|
|
|
|
|
def roster_names(opts: dict) -> list:
|
|
"""The unit names, in roster order. THE one answer, for two callers.
|
|
|
|
⚠️ IT EXISTS BECAUSE MQTT DISCOVERY CANNOT WAIT FOR A ROSTER. TEL-07 gives
|
|
every unit past the first its own MQTT device, and `MqttPublisher` is
|
|
constructed in `main()` BEFORE `Controller.__init__` builds the roster -
|
|
announcing happens on the broker's CONNACK, which can land first. Building a
|
|
second roster there would construct a second set of `Inverter`s off the same
|
|
Store; transcribing the naming rule there would be a third spelling of it.
|
|
So the naming rule lives here, `build_roster()` reads it, and the two cannot
|
|
drift.
|
|
|
|
⚠️ THE FALLBACK NAME IS POSITIONAL, NOT `UNIT_A`. Two nameless entries both
|
|
called `inverter-a` would share one Store namespace, so one unit's SAFETY-02
|
|
latch would be the other's - a fault on B latching A is the exact failure
|
|
DEV-02's namespaced keys removed. Rule 9 refuses a nameless entry outright;
|
|
this only decides what the refusal is allowed to call it in the meantime.
|
|
|
|
An EMPTY `inverters:` is the flat-key shape and its one unit is `UNIT_A`,
|
|
which is what every install running today reads.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
return [UNIT_A]
|
|
names = []
|
|
for i, entry in enumerate(entries):
|
|
entry = entry if isinstance(entry, dict) else {}
|
|
names.append(str(entry.get("name") or "").strip() or f"inverter-{i + 1}")
|
|
return names
|
|
|
|
|
|
def build_roster(opts: dict, store, sign_cfg=None) -> list:
|
|
"""The site's inverters, in roster order.
|
|
|
|
TWO SHAPES, AND WHICH ONE IS IN FORCE IS DECIDED BY ONE TEST: is
|
|
`inverters:` empty?
|
|
|
|
EMPTY the six flat keys - `setpoint_entity`, `soc_entity`,
|
|
`batt_entity`, `batt_invert`, `max_w`, `bms_soc_floor` (SAFETY-32)
|
|
- describe the ONE inverter,
|
|
exactly as the shipped scalars did. This is what every install
|
|
that upgrades into this version reads, without an operator
|
|
touching anything, and it is byte-identical to what DEV-02
|
|
shipped. config.yaml's default is `[]` for precisely that reason,
|
|
and Supervisor 2026.08.0 refuses the whole options blob without
|
|
it - see the options-block note on `inverters:`.
|
|
|
|
NON-EMPTY each entry describes one inverter and the flat keys describe
|
|
none of them. ⚠️ NOTHING IS INHERITED. An entry that omits
|
|
`soc_entity` has no SoC sensor; it does NOT borrow the flat one,
|
|
because "this unit's number came from somewhere else" is the
|
|
defect DEV-02 spent a card removing and a fallback here would
|
|
rebuild it in three lines. `name`, `setpoint_entity`, `max_w` and
|
|
`bms_soc_floor` (SAFETY-32) are required of every entry - the schema enforces it on the
|
|
Supervisor path, `validate.py` rule 9 enforces it for sim/inject.py
|
|
and the checks, which reach this function with any dict at all.
|
|
|
|
⚠️ THIS FUNCTION REFUSES NOTHING, AND THAT IS ON PURPOSE. A refusal here
|
|
happens inside `Controller.__init__`, before the startup gate has run and
|
|
before anything can render the reason to an operator; validate.py's rule 9
|
|
is the place that says no, with a message, through `refuse()`. So this
|
|
builds the most honest roster it can out of whatever it was handed - a
|
|
missing name becomes a positional one, a missing rating becomes 0.0 W - and
|
|
every one of those states is something rule 9 has already refused by the
|
|
time a control loop exists. Keep the two in step: a shape this tolerates
|
|
that rule 9 does not name is a shape that runs.
|
|
"""
|
|
entries = roster_entries(opts)
|
|
if not entries:
|
|
# ⚠️ THE UNCHANGED PATH. Same name, same entities, same rating, same
|
|
# store keys as before CFG-04 - an install that upgrades reads
|
|
# identically, which is AC 3.
|
|
return [Inverter(
|
|
UNIT_A,
|
|
setpoint_entity=opts.get("setpoint_entity", ""),
|
|
soc_entity=opts.get("soc_entity", ""),
|
|
batt_entity=opts.get("batt_entity", ""),
|
|
batt_invert=bool(opts.get("batt_invert")),
|
|
max_w=float(opts.get("max_w", 2000)),
|
|
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
|
# - the one pack the flat options describe is the one pack whose
|
|
# cut-out `bms_soc_floor` states. Defaulted from CFG-03's single
|
|
# mirror exactly as validate.py rule 8 defaults it, so an install
|
|
# that never set the key reads the same 10 % in both places.
|
|
bms_soc_floor=_flat_bms_floor(opts),
|
|
store=store, sign_cfg=sign_cfg)]
|
|
|
|
roster = []
|
|
names = roster_names(opts)
|
|
# ⚠️ NAMES COME FROM `roster_names()`, NOT FROM A SECOND READING OF THE
|
|
# ENTRIES. TEL-07 announces one MQTT device and one `unique_id` per unit off
|
|
# these names, and it has to build them BEFORE a `Controller` exists - so a
|
|
# second spelling of the fallback here would be an entity announced under a
|
|
# name no unit answers to. `zip` pairs them positionally, which is what
|
|
# `roster_names()` is defined to be.
|
|
for entry, name in zip(entries, names):
|
|
entry = entry if isinstance(entry, dict) else {}
|
|
roster.append(Inverter(
|
|
name,
|
|
setpoint_entity=entry.get("setpoint_entity", ""),
|
|
soc_entity=entry.get("soc_entity", ""),
|
|
batt_entity=entry.get("batt_entity", ""),
|
|
batt_invert=bool(entry.get("batt_invert")),
|
|
# ⚠️ NOT `opts["max_w"]`. See the docstring: a rating an entry did
|
|
# not state is not the flat one, it is a rating rule 9 refuses.
|
|
max_w=_entry_max_w(entry),
|
|
# ⚠️ NOT `opts["bms_soc_floor"]`, FOR `max_w`'S REASON AND THIS
|
|
# CARD'S. An entry that omits its cut-out has NOT got the flat one;
|
|
# it has none, and rule 8 refuses it by name. Borrowing here would be
|
|
# SAFETY-32's own defect written in three lines - the shallower pack
|
|
# graded against the deeper pack's BMS.
|
|
bms_soc_floor=_entry_bms_floor(entry),
|
|
store=store, sign_cfg=sign_cfg))
|
|
return roster
|
|
|
|
|
|
def _flat_bms_floor(opts: dict):
|
|
"""The single-inverter shape's own cut-out, or None if it is unparseable.
|
|
|
|
Defaulted from `PEAK_OPTION_DEFAULTS` when the key is absent or blank, which
|
|
is `validate._number()`'s behaviour on the same key - the two have to agree
|
|
or the validator would grade a number the roster does not build.
|
|
"""
|
|
# ⚠️ ONE defaulting line, not two. The first cut wrote the fallback into the
|
|
# `.get()` AND into the blank test below it, and a mutation that deleted
|
|
# either one survived - each was covering for the other, which is
|
|
# MUTATION.md's masking section in three lines of configuration reading.
|
|
raw = opts.get("bms_soc_floor")
|
|
if raw is None or raw == "":
|
|
return float(PEAK_OPTION_DEFAULTS["bms_soc_floor"])
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _entry_bms_floor(entry: dict):
|
|
"""One entry's OWN cut-out, or None when it did not state a usable one.
|
|
|
|
None rather than a borrowed number and rather than 0.0 - `_entry_max_w()`
|
|
can answer 0.0 because a zero rating commands nothing, but a zero FLOOR is
|
|
the most permissive value the key has and would read as "this pack may be
|
|
emptied". Rule 8 refuses the missing key by name; until it does, None
|
|
withholds.
|
|
"""
|
|
raw = entry.get("bms_soc_floor")
|
|
if raw is None or raw == "":
|
|
return None
|
|
try:
|
|
return float(raw)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
|
|
|
|
def _entry_max_w(entry: dict) -> float:
|
|
"""One entry's supervised cap, or 0.0 when it did not state a usable one.
|
|
|
|
0.0 rather than a borrowed number, and rather than an exception: rule 9
|
|
refuses `max_w` missing or unparseable by name, and a roster that cannot be
|
|
built at all would refuse it with a traceback instead.
|
|
"""
|
|
try:
|
|
return float(entry.get("max_w"))
|
|
except (TypeError, ValueError):
|
|
return 0.0
|