SMA-2: each inverter is reached through a driver; GoodWe behaviour unchanged
checks / unit (pull_request) Successful in 23m14s
infra-pin / fresh (pull_request) Successful in 2s

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:
glenn schrooyen
2026-11-07 11:28:47 +01:00
co-authored by Claude
parent 23ff9d0920
commit e56eeef8f0
18 changed files with 1238 additions and 65 deletions
+67 -9
View File
@@ -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)