SMA-2: each inverter is reached through a driver; GoodWe behaviour unchanged
app/drivers.py holds one Driver per roster entry (configured, actuator_id, limits, read, write, release, probe, link_stale_s, batt_bound, settle_s, brand, words). GoodWeEsphomeDriver makes the calls the code it replaced made, in the same order. Inverter gets driver; has_writer, is_live and link_note read it. The SoC/power read, the write, the device range (run_control, amain, the settings panel), rule 2 per unit and the SAFETY-28 census go through it. setpoint_entity is optional per entry beside driver, host, port, unit_id and maintenance; only goodwe_esphome is selectable, the rest are checked and unused. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude
parent
23ff9d0920
commit
e56eeef8f0
@@ -0,0 +1,299 @@
|
||||
"""The inverter driver seam (SMA-2): how this add-on reads and steers ONE inverter.
|
||||
|
||||
Before this, every place that read or steered an inverter talked to the GoodWe
|
||||
ESPHome entities itself (`hass.limits`, `hass.number`, `hass.set_number`, the
|
||||
SAFETY-28 census, selfconfig, validate), so a second brand meant touching all of
|
||||
them. Now each `Inverter` holds a `Driver`, and those places ask it. This card
|
||||
is a pure refactor: `GoodWeEsphomeDriver` does exactly what the code it replaced
|
||||
did, in the same order, with the same arguments. DECISIONS/SMA-2-driver-seam.md.
|
||||
|
||||
THE SEAM IS NOT TCP-SHAPED. Nothing here assumes a connection, or one connection
|
||||
per host: a later FoxESS unit is RTU framing over a TCP socket, and its
|
||||
`actuator_id()` is host:port:unit_id, so two units behind one gateway are two
|
||||
actuators and one unit named twice is the SAFETY-28 refusal.
|
||||
|
||||
WHAT STAYS OUTSIDE THE DRIVER, on purpose. The gate (SAFETY-28), the clamp
|
||||
(SAFETY-03), shadow mode and the write budget all sit above `write()` in
|
||||
`Controller._write_unit`, and the freshness gate sits above `read()`; a driver
|
||||
never sees a value those did not pass. A driver moves one number to or from one
|
||||
inverter and says whether it worked.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from .control import PLANT_SETTLE_S
|
||||
from .freshness import BOUNDS
|
||||
|
||||
# How long every write to one GoodWe inverter must have been REJECTED before
|
||||
# this controller stops claiming it can command it. Layer 1's own window: the
|
||||
# ESP32 commands 0 W after ~30 s without a refresh (`validate.
|
||||
# FIRMWARE_INGEST_VETO_S`). `inverter.LINK_STALE_S` is this number, re-exported.
|
||||
GOODWE_LINK_STALE_S = 30.0
|
||||
|
||||
# `driver` when an entry does not say: every install that exists today.
|
||||
DEFAULT_DRIVER = "goodwe_esphome"
|
||||
|
||||
# The `driver` values the options accept. Only the GoodWe one for now; the
|
||||
# registry below may hold more (a test registers a fake), but this tuple is
|
||||
# what validate.py rule 9 lets through and what config.yaml's schema lists.
|
||||
SELECTABLE = (DEFAULT_DRIVER,)
|
||||
|
||||
|
||||
class DriverNotBound(RuntimeError):
|
||||
"""A driver was asked to talk to the world before it was given a Home
|
||||
Assistant. A wiring bug, so it is loud rather than a quiet "no answer"."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Probe:
|
||||
"""What a read-only look at one inverter found. None means "did not answer"."""
|
||||
|
||||
dev_min: float | None = None
|
||||
dev_max: float | None = None
|
||||
soc: float | None = None
|
||||
batt_w: float | None = None
|
||||
extra: dict = field(default_factory=dict)
|
||||
|
||||
|
||||
def census_key(actuator_id: str) -> str:
|
||||
"""The form SAFETY-28 compares: stripped and lowercase, so a config cannot
|
||||
name one actuator twice by changing its case (`Number.X` vs `number.x`)."""
|
||||
return str(actuator_id or "").strip().lower()
|
||||
|
||||
|
||||
class Driver:
|
||||
"""One inverter's I/O. Subclasses set the class attributes and the methods.
|
||||
|
||||
Constructed once per roster entry, with no Home Assistant: `bind()` gives
|
||||
it one (the Controller does, and so does the settings panel, which has no
|
||||
Controller). Everything that talks to the world is a coroutine.
|
||||
"""
|
||||
|
||||
#: the `driver` option value that selects this class
|
||||
name = ""
|
||||
#: the option key that names the actuator in an entry (the census reads it)
|
||||
writer_key = ""
|
||||
#: the brand word the per-driver sentences and device names use
|
||||
brand = ""
|
||||
#: per-driver sentences (SMA-10 and later). Empty until a caller needs one.
|
||||
words: dict = {}
|
||||
#: how long all-rejected writes last before the unit stops being live
|
||||
link_stale_s = GOODWE_LINK_STALE_S
|
||||
#: this driver's `freshness.Bound` for the battery power reading
|
||||
batt_bound = BOUNDS["batt"]
|
||||
#: the plant's settling time after a step, in s (control.PLANT_SETTLE_S)
|
||||
settle_s = PLANT_SETTLE_S
|
||||
|
||||
# -- from the options ----------------------------------------------------
|
||||
@classmethod
|
||||
def from_entry(cls, entry: dict) -> "Driver":
|
||||
raise NotImplementedError
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry: dict) -> str:
|
||||
"""The actuator an options entry names, or "" for none. Pure: the
|
||||
SAFETY-28 census runs on options, before any driver exists."""
|
||||
raise NotImplementedError
|
||||
|
||||
# -- wiring --------------------------------------------------------------
|
||||
def bind(self, hass) -> "Driver":
|
||||
"""Give this driver its Home Assistant (or whatever it talks through)."""
|
||||
return self
|
||||
|
||||
def bind_getter(self, getter) -> "Driver":
|
||||
"""Like `bind()`, but looked up at each use: the Controller passes
|
||||
`lambda: self.hass`, so replacing `controller.hass` is seen at once."""
|
||||
return self
|
||||
|
||||
def bound(self) -> bool:
|
||||
"""Has it been given what it talks through? A driver that talks
|
||||
through nothing the Controller owns is always bound."""
|
||||
return True
|
||||
|
||||
# -- what it is ----------------------------------------------------------
|
||||
def configured(self) -> bool:
|
||||
"""Is there any way to reach this inverter at all?"""
|
||||
raise NotImplementedError
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
"""What this driver writes to. Two units with one id are one actuator."""
|
||||
raise NotImplementedError
|
||||
|
||||
def soc_source(self) -> str:
|
||||
"""Where the pack level is read from; "" when nowhere. Status rows only."""
|
||||
return ""
|
||||
|
||||
def batt_source(self) -> str:
|
||||
"""Where the battery power is read from; "" when nowhere."""
|
||||
return ""
|
||||
|
||||
# -- what it does --------------------------------------------------------
|
||||
async def limits(self) -> tuple:
|
||||
"""`(dev_min, dev_max)` in W the device accepts; (None, None) unread."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def read(self) -> tuple:
|
||||
"""`(soc, batt_w, extra)`. A value that did not arrive is None. `batt_w`
|
||||
is the RAW power, in the sign the controller uses; the freshness gate
|
||||
is the caller's."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def write(self, w: float) -> bool:
|
||||
"""Put `w` on the inverter. True only when it took it."""
|
||||
raise NotImplementedError
|
||||
|
||||
async def release(self) -> bool:
|
||||
"""Stop steering this inverter. For a driver with nothing better, 0 W."""
|
||||
return await self.write(0.0)
|
||||
|
||||
async def probe(self) -> Probe:
|
||||
"""A read-only look, usable without a Controller (panel, wizard)."""
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class GoodWeEsphomeDriver(Driver):
|
||||
"""The GoodWe behind the ESP32: three Home Assistant entities.
|
||||
|
||||
A transcription of what `main.py` and `selfconfig.py` did inline: `limits()`
|
||||
is `hass.limits(setpoint)`, `read()` is `hass.number(soc)` then
|
||||
`hass.number(batt, invert)`, `write()` is `hass.set_number(setpoint, w)`.
|
||||
`release()` is `write(0)`: the board's own watchdog is what hands back.
|
||||
"""
|
||||
|
||||
name = DEFAULT_DRIVER
|
||||
writer_key = "setpoint_entity"
|
||||
brand = "GoodWe"
|
||||
|
||||
def __init__(self, setpoint_entity="", soc_entity="", batt_entity="",
|
||||
batt_invert=False):
|
||||
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)
|
||||
self._hass = None
|
||||
self._getter = None
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, entry: dict) -> "GoodWeEsphomeDriver":
|
||||
return cls(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")))
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry: dict) -> str:
|
||||
return str(entry.get("setpoint_entity") or "").strip()
|
||||
|
||||
def bind(self, hass) -> "GoodWeEsphomeDriver":
|
||||
self._hass, self._getter = hass, None
|
||||
return self
|
||||
|
||||
def bind_getter(self, getter) -> "GoodWeEsphomeDriver":
|
||||
self._hass, self._getter = None, getter
|
||||
return self
|
||||
|
||||
def bound(self) -> bool:
|
||||
return self._hass is not None or self._getter is not None
|
||||
|
||||
def _h(self):
|
||||
hass = self._getter() if self._getter is not None else self._hass
|
||||
if hass is None:
|
||||
raise DriverNotBound(f"{self.brand} driver for "
|
||||
f"{self.setpoint_entity!r} has no Home Assistant")
|
||||
return hass
|
||||
|
||||
def configured(self) -> bool:
|
||||
return bool(self.setpoint_entity)
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
return self.setpoint_entity
|
||||
|
||||
def soc_source(self) -> str:
|
||||
return self.soc_entity
|
||||
|
||||
def batt_source(self) -> str:
|
||||
return self.batt_entity
|
||||
|
||||
async def limits(self) -> tuple:
|
||||
dev_min, dev_max, _step = await self._h().limits(self.setpoint_entity)
|
||||
return dev_min, dev_max
|
||||
|
||||
async def read(self) -> tuple:
|
||||
hass = self._h()
|
||||
soc = await hass.number(self.soc_entity)
|
||||
raw = await hass.number(self.batt_entity, self.batt_invert)
|
||||
return soc, raw, {}
|
||||
|
||||
async def write(self, w: float) -> bool:
|
||||
return await self._h().set_number(self.setpoint_entity, w)
|
||||
|
||||
async def probe(self) -> Probe:
|
||||
dev_min, dev_max = await self.limits()
|
||||
soc, raw, extra = await self.read()
|
||||
return Probe(dev_min, dev_max, soc, raw, extra)
|
||||
|
||||
|
||||
class UnavailableDriver(Driver):
|
||||
"""What an entry naming a driver this build does not have gets. Validation
|
||||
refuses such an entry before a Controller exists; this is only what
|
||||
`build_roster()` (which refuses nothing) builds meanwhile: a unit that is
|
||||
not configured, so the refusal branch names it and commands 0 W."""
|
||||
|
||||
name = ""
|
||||
# The census still counts a `setpoint_entity` on such an entry: refusing
|
||||
# too much is the safe direction for SAFETY-28.
|
||||
writer_key = "setpoint_entity"
|
||||
brand = ""
|
||||
|
||||
def __init__(self, wanted=""):
|
||||
self.wanted = str(wanted or "")
|
||||
|
||||
@classmethod
|
||||
def from_entry(cls, entry: dict) -> "UnavailableDriver":
|
||||
return cls(entry.get("driver"))
|
||||
|
||||
@classmethod
|
||||
def actuator_id_of(cls, entry: dict) -> str:
|
||||
return str(entry.get("setpoint_entity") or "").strip()
|
||||
|
||||
def configured(self) -> bool:
|
||||
return False
|
||||
|
||||
def actuator_id(self) -> str:
|
||||
return ""
|
||||
|
||||
async def limits(self) -> tuple:
|
||||
return None, None
|
||||
|
||||
async def read(self) -> tuple:
|
||||
return None, None, {}
|
||||
|
||||
async def write(self, w: float) -> bool:
|
||||
return False
|
||||
|
||||
async def probe(self) -> Probe:
|
||||
return Probe()
|
||||
|
||||
|
||||
# name -> class. The options' `driver` value picks one. A test may add a fake;
|
||||
# `SELECTABLE` (not this) is what the options accept.
|
||||
REGISTRY = {GoodWeEsphomeDriver.name: GoodWeEsphomeDriver}
|
||||
|
||||
|
||||
def driver_name(entry) -> str:
|
||||
"""The `driver` an options entry selects; blank or absent is the default."""
|
||||
raw = entry.get("driver") if isinstance(entry, dict) else None
|
||||
return str(raw).strip() if raw not in (None, "") else DEFAULT_DRIVER
|
||||
|
||||
|
||||
def driver_class(entry):
|
||||
"""The class an options entry selects, `UnavailableDriver` if none."""
|
||||
return REGISTRY.get(driver_name(entry), UnavailableDriver)
|
||||
|
||||
|
||||
def build_driver(entry) -> Driver:
|
||||
"""The driver for ONE options entry (or the flat options, for one inverter)."""
|
||||
entry = entry if isinstance(entry, dict) else {}
|
||||
return driver_class(entry).from_entry(entry)
|
||||
@@ -124,9 +124,12 @@ from datetime import datetime
|
||||
from .clamp import ClampResult
|
||||
from .delivery import DeliveryConfig, DeliveryWatchdog
|
||||
from . import messages
|
||||
# SMA-2. The driver seam: how this unit's inverter is read and steered.
|
||||
from .drivers import (GOODWE_LINK_STALE_S, GoodWeEsphomeDriver,
|
||||
build_driver)
|
||||
# SAFETY-37. The freshness policy, and the ONE place its bounds are written.
|
||||
# freshness.py imports nothing from this package, so the direction is one-way.
|
||||
from .freshness import BOUNDS, Freshness
|
||||
from .freshness import Freshness
|
||||
from .idle import IdleReading
|
||||
# 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
|
||||
@@ -149,7 +152,11 @@ from .watchdog import SignWatchdog, SignWatchdogConfig
|
||||
# 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
|
||||
#
|
||||
# SMA-2. This is the GoodWe driver's `link_stale_s` (drivers.py), kept under its
|
||||
# old name for the checks and for main.py. `is_live()` and `link_note()` now ask
|
||||
# each unit's DRIVER for its window; a driver for another make states its own.
|
||||
LINK_STALE_S = GOODWE_LINK_STALE_S
|
||||
|
||||
# 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
|
||||
@@ -195,12 +202,14 @@ class Inverter:
|
||||
def __init__(self, name, *, setpoint_entity="", soc_entity="",
|
||||
batt_entity="", batt_invert=False, max_w=2000.0,
|
||||
bms_soc_floor=None, capacity_kwh=None, store=None,
|
||||
sign_cfg=None, delivery_cfg=None):
|
||||
sign_cfg=None, delivery_cfg=None, driver=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)
|
||||
# SMA-2. HOW THIS UNIT IS READ AND STEERED. The entity keywords above
|
||||
# are the GoodWe driver's, kept so every existing caller builds the
|
||||
# same unit; a roster entry that selects another driver passes
|
||||
# `driver` and the entity keywords stay empty.
|
||||
self.driver = driver if driver is not None else GoodWeEsphomeDriver(
|
||||
setpoint_entity, soc_entity, batt_entity, 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)
|
||||
@@ -256,7 +265,7 @@ class Inverter:
|
||||
# constructed here: SoC's obligation term is energy through the pack, not
|
||||
# command travel, and the constant it needs has not been measured. That
|
||||
# is TEL-08's, and freshness.py's docstring says exactly what it owes.
|
||||
self.batt_fresh = Freshness(BOUNDS["batt"])
|
||||
self.batt_fresh = Freshness(self.driver.batt_bound)
|
||||
|
||||
# --- what this unit was last told, and what it last accepted ---------
|
||||
# `target` is THIS unit's share of the site target from the last
|
||||
@@ -338,6 +347,25 @@ class Inverter:
|
||||
def idle_w(self) -> float:
|
||||
return self.idle.w
|
||||
|
||||
# -- the GoodWe driver's entities, read through for the callers that name
|
||||
# them (the panel's range check, the status rows, the checks). A driver
|
||||
# without such an entity answers "" - the same as "not set".
|
||||
@property
|
||||
def setpoint_entity(self) -> str:
|
||||
return getattr(self.driver, "setpoint_entity", "")
|
||||
|
||||
@property
|
||||
def soc_entity(self) -> str:
|
||||
return getattr(self.driver, "soc_entity", "")
|
||||
|
||||
@property
|
||||
def batt_entity(self) -> str:
|
||||
return getattr(self.driver, "batt_entity", "")
|
||||
|
||||
@property
|
||||
def batt_invert(self) -> bool:
|
||||
return bool(getattr(self.driver, "batt_invert", False))
|
||||
|
||||
# -- store ---------------------------------------------------------------
|
||||
def key(self, name: str) -> str:
|
||||
"""This unit's namespaced Store key for `name`. See store.unit_key()."""
|
||||
@@ -387,7 +415,7 @@ class Inverter:
|
||||
return out
|
||||
|
||||
def has_writer(self) -> bool:
|
||||
return bool(self.setpoint_entity)
|
||||
return self.driver.configured()
|
||||
|
||||
# -- liveness ------------------------------------------------------------
|
||||
def note_write(self, ok: bool, now: datetime) -> None:
|
||||
@@ -398,7 +426,7 @@ class Inverter:
|
||||
elif self.rejected_since is None:
|
||||
self.rejected_since = now
|
||||
|
||||
def is_live(self, now: datetime, stale_s: float = LINK_STALE_S):
|
||||
def is_live(self, now: datetime, stale_s: float | None = None):
|
||||
"""`(live, why_not)` - may this controller command this unit THIS TICK?
|
||||
|
||||
Read the module docstring before widening this - in particular on why a
|
||||
@@ -411,7 +439,11 @@ class Inverter:
|
||||
|
||||
⚠️ ANSWERED PER TICK, NEVER CACHED, for `allocator.plan()`'s reason: a
|
||||
link goes stale between one cycle and the next.
|
||||
|
||||
`stale_s` is the driver's `link_stale_s` unless a caller passes one.
|
||||
"""
|
||||
if stale_s is None:
|
||||
stale_s = self.driver.link_stale_s
|
||||
if self.rejected_since is not None:
|
||||
held = (now - self.rejected_since).total_seconds()
|
||||
if held >= float(stale_s):
|
||||
@@ -451,9 +483,11 @@ class Inverter:
|
||||
held = (0.0 if self.rejected_since is None
|
||||
else (now - self.rejected_since).total_seconds())
|
||||
if not live:
|
||||
return messages.row_not_controllable(self.name, held, LINK_STALE_S)
|
||||
return messages.row_not_controllable(self.name, held,
|
||||
self.driver.link_stale_s)
|
||||
if self.rejected_since is not None:
|
||||
return messages.row_commands_refused(self.name, held, LINK_STALE_S)
|
||||
return messages.row_commands_refused(self.name, held,
|
||||
self.driver.link_stale_s)
|
||||
return ""
|
||||
|
||||
def __repr__(self) -> str:
|
||||
@@ -556,10 +590,8 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
# 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")),
|
||||
# SMA-2. The flat keys are the GoodWe shape and only that.
|
||||
driver=GoodWeEsphomeDriver.from_entry(opts),
|
||||
max_w=_flat_max_w(opts),
|
||||
# SAFETY-32. THE FLAT KEY, because this IS the single-inverter shape
|
||||
# - the one pack the flat options describe is the one pack whose
|
||||
@@ -588,10 +620,9 @@ def build_roster(opts: dict, store, sign_cfg=None,
|
||||
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")),
|
||||
# SMA-2. The entry's `driver` (default goodwe_esphome) picks the
|
||||
# class; the entry itself says where its entities are.
|
||||
driver=build_driver(entry),
|
||||
# ⚠️ 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),
|
||||
|
||||
@@ -71,6 +71,7 @@ from . import messages
|
||||
from .freeze import (
|
||||
FreezeWatchdog, FreezeWatchdogConfig, looks_frozen, rail_unreachable, rail_w,
|
||||
)
|
||||
from .drivers import GoodWeEsphomeDriver
|
||||
from .hass import CORE_API, OUTAGE, HomeAssistant
|
||||
# ⚠️ `LINK_STALE_S` IS RE-EXPORTED DELIBERATELY. `live_units()` reasons about it
|
||||
# and two checks read `app.main.LINK_STALE_S` to build a stale link, so the
|
||||
@@ -3774,6 +3775,18 @@ class Controller:
|
||||
elif self.inputs_bad_since is None:
|
||||
self.inputs_bad_since = datetime.now(timezone.utc)
|
||||
|
||||
def _driver(self, inv):
|
||||
"""`inv`'s driver, wired to this Controller's Home Assistant on first use.
|
||||
|
||||
SMA-2. Bound HERE rather than when the roster is built, so a unit added
|
||||
to `self.inverters` afterwards is wired too, and the driver follows
|
||||
whatever `self.hass` is at the time (a check may swap it).
|
||||
"""
|
||||
drv = inv.driver
|
||||
if not drv.bound():
|
||||
drv.bind_getter(lambda: self.hass)
|
||||
return drv
|
||||
|
||||
async def _read_unit(self, inv, now_mono: float) -> None:
|
||||
"""One inverter's own SoC and battery power, from ITS OWN entities.
|
||||
|
||||
@@ -3793,8 +3806,10 @@ class Controller:
|
||||
second spelling of "what is standing" would let the two disagree about
|
||||
the number they are both reasoning about.
|
||||
"""
|
||||
inv.soc = await self.hass.number(inv.soc_entity)
|
||||
raw = await self.hass.number(inv.batt_entity, inv.batt_invert)
|
||||
# SMA-2. The driver reads, in the order this method always did: SoC,
|
||||
# then power. `extra` is whatever else the driver learned; nothing
|
||||
# consumes it yet.
|
||||
inv.soc, raw, _extra = await self._driver(inv).read()
|
||||
was_stale = inv.batt_fresh.stale
|
||||
was_releases = inv.batt_fresh.releases
|
||||
inv.batt_fresh.submit(raw, inv.standing_w(), now_mono)
|
||||
@@ -4029,9 +4044,9 @@ class Controller:
|
||||
for inv in targets))
|
||||
|
||||
async def _write_unit(self, inv, value: float, *, force: bool = False) -> None:
|
||||
entity = inv.setpoint_entity
|
||||
if not entity:
|
||||
if not inv.driver.configured():
|
||||
return
|
||||
entity = inv.driver.actuator_id() # for the log line below
|
||||
|
||||
# --- SAFETY-03: THE CLAMP, AND IT IS ON THE ONLY PATH TO THE WIRE -----
|
||||
# Every write in this add-on comes through here - the cycle, the
|
||||
@@ -4154,7 +4169,7 @@ class Controller:
|
||||
# tick by itself: `last_write` is updated only on an ACCEPTED write, so
|
||||
# `changed` is still true.
|
||||
try:
|
||||
ok = await asyncio.wait_for(self.hass.set_number(entity, value),
|
||||
ok = await asyncio.wait_for(self._driver(inv).write(value),
|
||||
timeout=WRITE_BUDGET_S)
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
ok = False
|
||||
@@ -6730,9 +6745,8 @@ class Controller:
|
||||
# Concurrently, for `read_inputs()`'s reason: a slow ESPHome on one unit
|
||||
# must not delay the controller's start on every other.
|
||||
async def _limits(inv):
|
||||
if inv.setpoint_entity:
|
||||
inv.dev_min, inv.dev_max, _ = await self.hass.limits(
|
||||
inv.setpoint_entity)
|
||||
if inv.driver.configured():
|
||||
inv.dev_min, inv.dev_max = await self._driver(inv).limits()
|
||||
await asyncio.gather(*(_limits(inv) for inv in self.inverters))
|
||||
entity = self.o.get("setpoint_entity", "")
|
||||
# ⚠️ THIS USED TO WARN AND CARRY ON, and that was the bug CFG-01
|
||||
@@ -7316,9 +7330,10 @@ class Controller:
|
||||
rows = []
|
||||
for inv in self.inverters:
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
rows.append((messages.LABEL_SOC, unit, inv.soc, inv.soc_entity))
|
||||
rows.append((messages.LABEL_SOC, unit, inv.soc,
|
||||
inv.driver.soc_source()))
|
||||
rows.append((messages.LABEL_BATTERY, unit, inv.batt,
|
||||
inv.batt_entity))
|
||||
inv.driver.batt_source()))
|
||||
if not self.p1_enabled:
|
||||
# In P1 mode the check above replaces this one; leaving both in
|
||||
# would report "no entity configured" for a meter_entity that is
|
||||
@@ -7360,9 +7375,8 @@ class Controller:
|
||||
"text": messages.row_sensor(label, value, unit)})
|
||||
|
||||
for inv in self.inverters:
|
||||
sp = inv.setpoint_entity
|
||||
unit = None if len(self.inverters) == 1 else inv.name
|
||||
if not sp:
|
||||
if not inv.driver.configured():
|
||||
out.append({"ok": False, "warn": False, "id": "control",
|
||||
"text": messages.row_control_unset(unit)})
|
||||
elif inv.dev_max is None:
|
||||
@@ -8205,10 +8219,13 @@ async def amain() -> None:
|
||||
# validator cannot do for itself; (None, None) when ESPHome has not come
|
||||
# up yet is not a refusal - run_control() re-checks once the real rating
|
||||
# is known. See validate.py for where the line is drawn and why.
|
||||
# SMA-2. The flat options are the one-inverter shape, so it is the
|
||||
# GoodWe driver's, asked without a Controller (none exists yet).
|
||||
_sp = str(opts.get("setpoint_entity", "") or "").strip()
|
||||
_drv = GoodWeEsphomeDriver(setpoint_entity=_sp).bind(hass)
|
||||
_dev_min = _dev_max = None
|
||||
if _sp:
|
||||
_dev_min, _dev_max, _ = await hass.limits(_sp)
|
||||
_dev_min, _dev_max = await _drv.limits()
|
||||
_LOG.info("setpoint device %s reports range %s…%s W", _sp, _dev_min, _dev_max)
|
||||
_problems = validate_startup_config(opts, _dev_min, _dev_max)
|
||||
# UX-15: a new install names no inverter. When that is the ONLY
|
||||
|
||||
@@ -1683,6 +1683,11 @@ LABELS = {
|
||||
"capacity_kwh": "Usable battery capacity",
|
||||
"inverters": "Inverter list",
|
||||
"name": "Name",
|
||||
"driver": "Inverter type",
|
||||
"host": "Inverter address",
|
||||
"port": "Inverter port",
|
||||
"unit_id": "Inverter unit number",
|
||||
"maintenance": "Battery maintenance",
|
||||
"meter_source": "Where the meter reading comes from",
|
||||
"meter_phases": "Number of phases",
|
||||
"p1_host": "HomeWizard P1 address",
|
||||
@@ -1764,6 +1769,11 @@ LABELS_NL = {
|
||||
"capacity_kwh": "Bruikbare capaciteit",
|
||||
"inverters": "Lijst met omvormers",
|
||||
"name": "Naam",
|
||||
"driver": "Type omvormer",
|
||||
"host": "Adres van de omvormer",
|
||||
"port": "Poort van de omvormer",
|
||||
"unit_id": "Eenheidsnummer van de omvormer",
|
||||
"maintenance": "Onderhoud van de batterij",
|
||||
"meter_source": "Waar de meterstand vandaan komt",
|
||||
"meter_phases": "Aantal fasen",
|
||||
"p1_host": "Adres van de HomeWizard P1",
|
||||
|
||||
@@ -343,10 +343,12 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
f"disagreement between the two and should be reported."]
|
||||
return found, []
|
||||
if hass is not None:
|
||||
# SMA-2. Each unit's driver, bound to the panel's own (read-only)
|
||||
# Home Assistant: there is no Controller here.
|
||||
async def _limits(unit):
|
||||
if unit.setpoint_entity:
|
||||
unit.dev_min, unit.dev_max, _ = await hass.limits(
|
||||
unit.setpoint_entity)
|
||||
if unit.driver.configured():
|
||||
unit.dev_min, unit.dev_max = await unit.driver.bind(
|
||||
hass).limits()
|
||||
await asyncio.gather(*(_limits(unit) for unit in units))
|
||||
|
||||
dev_min = dev_max = None
|
||||
@@ -357,8 +359,8 @@ async def problems_and_ranges(opts: dict, hass=None, notes=None):
|
||||
problems = list(validate_startup_config(opts, dev_min, dev_max,
|
||||
notes=notes))
|
||||
problems += validate_unit_ranges(units, skip_entity=flat)
|
||||
unread = [unit.setpoint_entity for unit in units
|
||||
if unit.setpoint_entity and unit.dev_min is None
|
||||
unread = [unit.driver.actuator_id() for unit in units
|
||||
if unit.driver.configured() and unit.dev_min is None
|
||||
and unit.dev_max is None]
|
||||
return problems, unread
|
||||
|
||||
|
||||
@@ -302,6 +302,19 @@ DERIVED = {
|
||||
HIDDEN: dict = {"price_service_token": "a secret, set on the Configuration tab"}
|
||||
|
||||
|
||||
# SMA-2. Per-entry keys of the inverter list the page never shows. They pick and
|
||||
# address an inverter driver, only `goodwe_esphome` is selectable, and nothing
|
||||
# reads the other four yet. An entry's keys the page does not draw travel
|
||||
# through a save as stored (uimodel._roster_decode keeps the whole entry).
|
||||
ROSTER_HIDDEN: dict = {
|
||||
"driver": "only goodwe_esphome for now; empty means that",
|
||||
"host": "not read yet",
|
||||
"port": "not read yet",
|
||||
"unit_id": "not read yet",
|
||||
"maintenance": "not read yet",
|
||||
}
|
||||
|
||||
|
||||
def fields():
|
||||
"""Every field once, in page order, roster fields after `inverters`."""
|
||||
for group in GROUPS:
|
||||
|
||||
@@ -334,6 +334,7 @@ from typing import NamedTuple
|
||||
from . import dayplan, messages, pv, solarfc
|
||||
from .arbitrage import enabled as arbitrage_on
|
||||
from .control import SLEW_RAIL_FRAC, LOOP_TICK_S, PLANT_SETTLE_S
|
||||
from . import drivers
|
||||
from .custom_formula import (
|
||||
EXPORT_FORMULA_OPTION, FormulaInvalid, implausible_all_in,
|
||||
parse_export_formula, parse_price_formula, stale_card)
|
||||
@@ -457,12 +458,18 @@ def _collect_writers(node, path: str, out: list) -> None:
|
||||
however deeply nested and under whatever container.
|
||||
"""
|
||||
if isinstance(node, dict):
|
||||
# SMA-2. THE DRIVER NAMES THE ACTUATOR. An entry's `driver` (default
|
||||
# goodwe_esphome) says which key declares its writer - `setpoint_entity`
|
||||
# for GoodWe, `host` for a driver reached over a socket - and what that
|
||||
# entry's `actuator_id()` is. The walk is as structural as before: it
|
||||
# still finds an entry wherever the options keep it.
|
||||
cls = drivers.driver_class(node)
|
||||
for k, v in node.items():
|
||||
here = f"{path}.{k}" if path else str(k)
|
||||
if k in ACTUATOR_WRITE_KEYS:
|
||||
entity = str(v or "").strip()
|
||||
if entity:
|
||||
out.append((here, entity))
|
||||
if k == cls.writer_key:
|
||||
actuator = cls.actuator_id_of(node)
|
||||
if actuator:
|
||||
out.append((here, actuator))
|
||||
continue
|
||||
_collect_writers(v, here, out)
|
||||
elif isinstance(node, (list, tuple)):
|
||||
@@ -498,7 +505,7 @@ def actuator_writers(opts: dict) -> dict:
|
||||
_collect_writers(opts, "", found)
|
||||
census: dict = {}
|
||||
for where, entity in found:
|
||||
census.setdefault(entity.lower(), []).append(where)
|
||||
census.setdefault(drivers.census_key(entity), []).append(where)
|
||||
return census
|
||||
|
||||
|
||||
@@ -729,11 +736,16 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
else:
|
||||
seen[name] = where
|
||||
who = f"{where} ({name})" if name else where
|
||||
if not str(entry.get("setpoint_entity") or "").strip():
|
||||
# SMA-2. WHETHER THE ENTRY REACHES ITS INVERTER IS THE DRIVER'S TO SAY.
|
||||
# For goodwe_esphome that is a setpoint entity, and the sentence is the
|
||||
# one it always was. An unknown driver is refused by (h) below.
|
||||
_cls = drivers.driver_class(entry)
|
||||
if _cls is not drivers.UnavailableDriver and not _cls.actuator_id_of(entry):
|
||||
problems.append(
|
||||
f"{who} has no {S('setpoint_entity')}, so the add-on cannot "
|
||||
"control it. Set its ESPHome number entity, or remove the "
|
||||
"entry.")
|
||||
problems += _driver_problems(entry, who)
|
||||
if _number(entry, "max_w", None) is None or (_number(entry, "max_w", None) or 0) <= 0:
|
||||
problems.append(
|
||||
f"{who} has no usable {S('max_w')}: it is "
|
||||
@@ -775,6 +787,47 @@ def _roster_problems(opts: dict) -> list[str]:
|
||||
return problems
|
||||
|
||||
|
||||
def _whole(raw, lo: int, hi: int):
|
||||
"""`raw` as an int in [lo, hi], or None. A bool or a fraction is not one."""
|
||||
if isinstance(raw, bool):
|
||||
return None
|
||||
try:
|
||||
val = float(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return int(val) if val == int(val) and lo <= val <= hi else None
|
||||
|
||||
|
||||
def _driver_problems(entry: dict, who: str) -> list[str]:
|
||||
"""Rule 9(h) (SMA-2): the keys that choose and address an inverter driver.
|
||||
|
||||
Only `goodwe_esphome` is selectable for now. `host`, `port`, `unit_id` and
|
||||
`maintenance` are accepted and checked for shape, and nothing reads them
|
||||
yet: a later driver's entry must not be refused for carrying them.
|
||||
"""
|
||||
out: list[str] = []
|
||||
raw = entry.get("driver")
|
||||
if raw not in (None, "") and str(raw).strip() not in drivers.SELECTABLE:
|
||||
out.append(
|
||||
f"{who} has {S('driver')} {messages.shown(raw)}. Use "
|
||||
f"{', '.join(drivers.SELECTABLE)}, or leave it empty.")
|
||||
host = entry.get("host")
|
||||
if host not in (None, "") and (not isinstance(host, str)
|
||||
or re.search(r"\s", host.strip())):
|
||||
out.append(f"{who} has {S('host')} {messages.shown(host)}, which is "
|
||||
"not an address. Use a name or an IP address.")
|
||||
for key, lo, hi in (("port", 1, 65535), ("unit_id", 0, 247)):
|
||||
val = entry.get(key)
|
||||
if val not in (None, "") and _whole(val, lo, hi) is None:
|
||||
out.append(f"{who} has {S(key)} {messages.shown(val)}. Use a "
|
||||
f"whole number from {lo} to {hi}.")
|
||||
maint = entry.get("maintenance")
|
||||
if maint is not None and not isinstance(maint, bool):
|
||||
out.append(f"{who} has {S('maintenance')} {messages.shown(maint)}. "
|
||||
"Use on or off.")
|
||||
return out
|
||||
|
||||
|
||||
def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
"""Rule 2, per inverter, against each unit's OWN reported device range.
|
||||
|
||||
@@ -787,8 +840,8 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
2's own failure mode restored by a config shape: the operator's stated
|
||||
ceiling is not the ceiling and nothing says which won.
|
||||
|
||||
`units` is the roster (anything with `.name`, `.setpoint_entity`, `.max_w`,
|
||||
`.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
|
||||
`units` is the roster (anything with `.name`, `.driver` or `.setpoint_entity`,
|
||||
`.max_w`, `.dev_min`, `.dev_max`). A unit whose device did not answer is SKIPPED, not
|
||||
guessed at, for the reason in this module's docstring: ESPHome is often not
|
||||
up when the add-on boots and (None, None) means "not read", not "zero".
|
||||
|
||||
@@ -800,7 +853,12 @@ def validate_unit_ranges(units, *, skip_entity: str = "") -> list[str]:
|
||||
problems: list[str] = []
|
||||
skip = str(skip_entity or "").strip().lower()
|
||||
for unit in units:
|
||||
entity = str(getattr(unit, "setpoint_entity", "") or "").strip()
|
||||
# SMA-2. THE DRIVER'S ACTUATOR, not a GoodWe key: a unit reached some
|
||||
# other way is graded too. A bare object with only `.setpoint_entity`
|
||||
# (the checks pass some) is read as it always was.
|
||||
drv = getattr(unit, "driver", None)
|
||||
entity = str((drv.actuator_id() if drv is not None
|
||||
else getattr(unit, "setpoint_entity", "")) or "").strip()
|
||||
if not entity or entity.lower() == skip:
|
||||
continue
|
||||
cap = float(getattr(unit, "max_w", 0.0) or 0.0)
|
||||
|
||||
Reference in New Issue
Block a user