OPT-11 P2: HA entities for battery modes
checks / unit (pull_request) Successful in 11m12s
infra-pin / fresh (pull_request) Successful in 2s

Each battery gets an MQTT-discovered Mode select and Use switch, only while
battery_modes_enabled is true. Commands go through handle_action(), the
/action path; refusals are logged once. Retained commands are never applied.
Turning modes off removes exactly the configs announced (kept in the store).
With modes off the announcement is byte-identical to ed739d4.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-09-28 23:12:04 +02:00
co-authored by Claude Opus 5.5
parent ed739d4cdb
commit b310282729
8 changed files with 716 additions and 20 deletions
@@ -17,5 +17,12 @@ P1 is its section 6 P1, and the pull request lists what was measured.
capped grid charge release an Off battery. Both are graded in capped grid charge release an Off battery. Both are graded in
`test_arbiter.py` part 11. The loop bound leaves a side that reaches the `test_arbiter.py` part 11. The loop bound leaves a side that reaches the
rail to the rail, so SAFETY-03 still flags a loop winding into it. rail to the rail, so SAFETY-03 still flags a loop winding into it.
- P2 (HA entities): a select and a switch per battery, only while modes are
on; commands go through `handle_action()`, the page's path. A retained
command is dropped, not applied: the store already holds the last choice,
and a replay after a restart could otherwise undo a newer one. The config
topics announced are kept in the store (`mqtt_mode_configs`), so turning
modes off removes exactly those, and an install that never turned them on
publishes what ed739d4 did (`test_mqtt.py` part 10).
- In P1 only Off closes a direction; Leftover solar differs by charging last. - In P1 only Off closes a direction; Leftover solar differs by charging last.
Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4. Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4.
+4
View File
@@ -522,6 +522,10 @@ Off by default. Turn it on, restart the add-on, and the Batteries screen gives e
The peak limit, the safety stops and the monthly cycle always come before a battery's mode. The peak limit, the safety stops and the monthly cycle always come before a battery's mode.
**In Home Assistant.** With MQTT set up, each battery also gets a *Mode* list and a *Use* switch, for example `select.goodwe_inverter_a_mode` and `switch.goodwe_inverter_a_use`. They do the same as the Batteries screen. Turn battery modes off and restart, and they are removed.
*For installers:* commands arrive on `goodwe_ctl/unit/<battery>/mode/set` (a *Mode* option, such as `Solar first`) and `.../use/set` (`ON` or `OFF`). They go through the same checks as the Batteries screen, and a refusal is logged once. A retained command is never applied, so the broker cannot replay an old choice after a restart; the add-on keeps the last choice itself.
*For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`. *For installers:* a mode is a limit at priority 35, kept apart from the hard limits. It gives way only as far as a limit above it (the peak guard) needs. A mode range always holds 0 W, so a mode can forbid a direction and never force one. With one inverter the mode also bounds the loop. The record is `DECISIONS/OPT-11-battery-modes.md`.
### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason` ### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason`
+72 -2
View File
@@ -72,7 +72,9 @@ from .maintenance import (
IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult, IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult,
arbitrate as maint_arbitrate, arbitrate as maint_arbitrate,
) )
from .mqtt import (MqttPublisher, announced_units, optional_keys, unit_slugs, from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
MqttPublisher, announced_units, mode_payloads,
mode_retractions, optional_keys, parse_command, unit_slugs,
unit_state_key) unit_state_key)
from . import peakguard from . import peakguard
from . import packshare from . import packshare
@@ -901,9 +903,11 @@ class Controller:
# turned them on behaves exactly as before (SAFETY-10 section 1). Each # turned them on behaves exactly as before (SAFETY-10 section 1). Each
# battery's mode and on/off switch live in the store, not in options, # battery's mode and on/off switch live in the store, not in options,
# so a change applies at once (`pack_use`, `pack_mode` actions). # so a change applies at once (`pack_use`, `pack_mode` actions).
self.modes_on = opts.get("battery_modes_enabled") is True self.modes_on = packmode.enabled(opts)
# The units whose mode fault is logged already (edge-logged). # The units whose mode fault is logged already (edge-logged).
self._mode_faults = set() self._mode_faults = set()
# OPT-11 P2: (topic, reason) of MQTT commands refused and logged once.
self._mqtt_refused = set()
# [(pack name, why)] for packs that are due and waiting their turn. # [(pack name, why)] for packs that are due and waiting their turn.
# Empty at a roster of one, where nothing can defer anything. # Empty at a roster of one, where nothing can defer anything.
self._maint_deferred = [] self._maint_deferred = []
@@ -2053,6 +2057,56 @@ class Controller:
pm = self.pack_mode(inv) pm = self.pack_mode(inv)
return {"unit": inv.name, "use": pm.use, "mode": pm.mode} return {"unit": inv.name, "use": pm.use, "mode": pm.mode}
def mode_values(self) -> dict:
"""OPT-11 P2: each battery's mode and switch, for their HA state
topics. Read from the store, so they are right after a restart. Empty
while battery modes are off."""
if not self.modes_on:
return {}
out = {}
names = [inv.name for inv in self.inverters]
for inv, slug in zip(self.inverters, unit_slugs(names)):
pm = self.pack_mode(inv)
out[unit_state_key(slug, "mode")] = MODE_LABELS.get(pm.mode,
pm.mode)
out[unit_state_key(slug, "use")] = USE_ON if pm.use else USE_OFF
return out
async def mqtt_command(self, topic, payload, retained) -> None:
"""OPT-11 P2: one command from a battery's HA switch or mode list.
It goes through handle_action(), the page's /action path, so the
checks and the store write are the same. A retained command (one the
broker replays on subscribe, e.g. after a restart) is never applied:
the store already holds the last choice. A refusal is logged once per
topic and reason. Never raises: MQTT must not reach control.
"""
try:
slug, action, value, why = parse_command(
topic, payload, retained, packmode.MODES)
if why is None:
names = [inv.name for inv in self.inverters]
unit = dict(zip(unit_slugs(names), names)).get(slug)
result = await self.handle_action(action, unit=unit,
value=value)
if not (isinstance(result, dict) and "error" in result):
self.mqtt.publish(self.mode_values())
return
why = result["error"]
else:
why = messages.pack_refused(why)
topic_s = str(topic)[:128]
_LOG.debug("Home Assistant command %s %s refused: %s", topic_s,
repr(payload)[:48], why)
if (topic_s, why) in self._mqtt_refused \
or len(self._mqtt_refused) >= 32:
return
self._mqtt_refused.add((topic_s, why))
_LOG.warning("Home Assistant command on %s ignored: %s", topic_s,
why)
except Exception: # noqa: BLE001
_LOG.exception("Home Assistant command could not be handled")
def _maint_stop_off(self, machine, now) -> None: def _maint_stop_off(self, machine, now) -> None:
"""OPT-11: stop this battery's maintenance cycle because it is off.""" """OPT-11: stop this battery's maintenance cycle because it is off."""
if machine.phase == IDLE: if machine.phase == IDLE:
@@ -5144,6 +5198,8 @@ class Controller:
values[unit_state_key(slug, "soc")] = inv.soc values[unit_state_key(slug, "soc")] = inv.soc
values[unit_state_key(slug, "sign_latch")] = ( values[unit_state_key(slug, "sign_latch")] = (
"tripped" if inv.sign_wd.tripped else "clear") "tripped" if inv.sign_wd.tripped else "clear")
# OPT-11 P2: each battery's mode and switch; nothing while modes are off.
values.update(self.mode_values())
self.mqtt.publish(values) self.mqtt.publish(values)
async def shutdown(self) -> None: async def shutdown(self) -> None:
@@ -6189,6 +6245,16 @@ async def amain() -> None:
# took the control loop down with it. # took the control loop down with it.
broker = None broker = None
try: try:
# OPT-11 P2: each battery's switch and mode list, only while
# battery modes are on. The config topics are kept in the store,
# so turning modes off removes exactly the ones announced.
_modes = packmode.MODES if packmode.enabled(opts) else ()
_mode_cfgs = [t for t, _ in mode_payloads(roster_names(opts),
_modes)]
_retract = mode_retractions(store.data.get(MODE_CONFIGS_KEY),
_mode_cfgs)
if _modes:
store.set(MODE_CONFIGS_KEY, _mode_cfgs)
broker = await hass.mqtt_service() broker = await hass.mqtt_service()
pub = MqttPublisher( pub = MqttPublisher(
broker.get("host") if broker else None, broker.get("host") if broker else None,
@@ -6208,6 +6274,8 @@ async def amain() -> None:
# is what `build_roster()` itself reads. At a roster of one this # is what `build_roster()` itself reads. At a roster of one this
# announces nothing new - see `announced_units()`. # announces nothing new - see `announced_units()`.
units=roster_names(opts), units=roster_names(opts),
modes=_modes,
retract=_retract,
) )
except Exception as err: # noqa: BLE001 except Exception as err: # noqa: BLE001
_LOG.warning("MQTT unavailable (%s) - continuing without status entities", err) _LOG.warning("MQTT unavailable (%s) - continuing without status entities", err)
@@ -6221,6 +6289,8 @@ async def amain() -> None:
stop = asyncio.Event() stop = asyncio.Event()
controller = Controller(opts, hass, store, pub, stop=stop) controller = Controller(opts, hass, store, pub, stop=stop)
if callable(getattr(pub, "bind", None)):
pub.bind(asyncio.get_running_loop(), controller.mqtt_command)
runner = await web.start(controller, port=INGRESS_PORT) runner = await web.start(controller, port=INGRESS_PORT)
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
+5
View File
@@ -932,6 +932,11 @@ PACK_REFUSED = {
"off": "Battery modes are off. Turn on “Battery modes” first.", "off": "Battery modes are off. Turn on “Battery modes” first.",
"unit": "There is no battery with that name.", "unit": "There is no battery with that name.",
"value": "That choice is not possible for this battery.", "value": "That choice is not possible for this battery.",
# OPT-11 P2: a Home Assistant command dropped before it reaches a battery.
"retained": "A saved command is not used after a restart.",
"size": "That command is too long.",
"encoding": "That command is not plain text.",
"topic": "That is not a battery command.",
} }
+171 -15
View File
@@ -7,8 +7,10 @@ That separation is deliberate; observability must never be able to take down
control. control.
""" """
import asyncio
import json import json
import logging import logging
import re
try: try:
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
@@ -375,7 +377,26 @@ def _payload(name, object_id, unique_id, state_topic, device,
return cfg return cfg
def discovery_payloads(units=(), omit=(), include=()) -> list: def unit_unique_id(slug: str, key: str) -> str:
"""One unit's entity `unique_id` (also its config topic's middle part)."""
return f"{BASE}_unit_{slug}_{key}"
def _unit_places(names):
"""`(slug, device, prefix)` for each roster name, in order."""
slugs = unit_slugs(names)
for i, (name, slug) in enumerate(zip(names, slugs)):
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
# keeps the device it already has and carries its name in the ENTITY
# name instead; every later one gets its own device and does not.
# Either way HA composes device-name + entity-name into the same
# string, which is why `object_id` can be written once for both.
device = DEVICE if i == 0 else unit_device(name, slug)
prefix = f"{_title(name)} " if i == 0 else ""
yield slug, device, prefix
def discovery_payloads(units=(), omit=(), include=(), modes=()) -> list:
"""Every `(topic, payload)` this add-on announces, in announcement order. """Every `(topic, payload)` this add-on announces, in announcement order.
Split out of `_announce()` so that the acceptance criterion can be graded as Split out of `_announce()` so that the acceptance criterion can be graded as
@@ -389,6 +410,8 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an the output here (`_payload()`'s note, and test_mqtt.py part 1 asserts an
ordered LIST precisely because a re-order is a zero-byte edit that ordered LIST precisely because a re-order is a zero-byte edit that
re-announces every entity on a live install). re-announces every entity on a live install).
`modes` (OPT-11 P2) is the battery modes offered, empty when they are off.
""" """
omit = set(omit) omit = set(omit)
include = [k for k in include if k not in omit] include = [k for k in include if k not in omit]
@@ -400,19 +423,10 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
f"homeassistant/sensor/{BASE}_{key}/config", f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}", _payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon))) DEVICE, unit, dev_class, state_class, icon)))
names = announced_units(units)
slugs = unit_slugs(names)
site_slug = _slug(DEVICE["name"]) site_slug = _slug(DEVICE["name"])
for i, (name, slug) in enumerate(zip(names, slugs)): for slug, device, prefix in _unit_places(announced_units(units)):
# ⚠️ `i == 0` IS THE ASYMMETRY, IN ONE LINE. The first roster entry
# keeps the device it already has and carries its name in the ENTITY
# name instead; every later one gets its own device and does not.
# Either way HA composes device-name + entity-name into the same
# string, which is why `object_id` below can be written once for both.
device = DEVICE if i == 0 else unit_device(name, slug)
prefix = f"{_title(name)} " if i == 0 else ""
for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS: for key, oid, label, unit, dev_class, state_class, icon in UNIT_SENSORS:
unique_id = f"{BASE}_unit_{slug}_{key}" unique_id = unit_unique_id(slug, key)
out.append(( out.append((
f"homeassistant/sensor/{unique_id}/config", f"homeassistant/sensor/{unique_id}/config",
_payload(prefix + label, _payload(prefix + label,
@@ -427,12 +441,124 @@ def discovery_payloads(units=(), omit=(), include=()) -> list:
f"homeassistant/sensor/{BASE}_{key}/config", f"homeassistant/sensor/{BASE}_{key}/config",
_payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}", _payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}",
DEVICE, unit, dev_class, state_class, icon))) DEVICE, unit, dev_class, state_class, icon)))
# OPT-11 P2: last, and only when battery modes are on.
out.extend(mode_payloads(units, modes))
return out return out
# --- OPT-11 P2: each battery's switch and mode, as HA entities ---------------
# Announced only when battery modes are on, after every other row, so no
# existing row moves. A command goes through Controller.handle_action(), the
# page's /action path. DECISIONS/OPT-11-battery-modes.md has the record.
#
# The select's options are these labels, in `packmode.MODES` order. A new mode
# (P3) is a new entry here and a new option at the end; no id changes.
MODE_LABELS = {"solar_first": "Solar first", "leftover_solar": "Leftover solar"}
USE_ON, USE_OFF = "ON", "OFF"
# A longer command payload is refused before it is decoded.
MAX_COMMAND_BYTES = 64
# The store key holding the mode config topics last announced, so that turning
# modes off can remove exactly those (an empty retained config) and an install
# that never turned them on publishes nothing new.
MODE_CONFIGS_KEY = "mqtt_mode_configs"
_MODE_CONFIG_RE = re.compile(
rf"homeassistant/(select|switch)/{BASE}_unit_[a-z0-9_]+_(mode|use)/config")
_COMMAND_RE = re.compile(rf"{BASE}/unit/([a-z0-9_]+)/(mode|use)/set")
# (entity key, HA component, entity name, icon)
MODE_ENTITIES = [
("mode", "select", "Mode", "mdi:battery-sync"),
("use", "switch", "Use", "mdi:power"),
]
def mode_payloads(units, modes) -> list:
"""The `(topic, payload)` for each battery's select and switch.
Every roster entry gets them, also at a roster of one. The device rule is
TEL-07's: the first battery's rows go on the site device with its name in
front, each later battery's on its own device. Empty when `modes` is empty.
"""
if not modes:
return []
site_slug = _slug(DEVICE["name"])
options = [MODE_LABELS[m] for m in modes]
out = []
for slug, device, prefix in _unit_places([str(u) for u in units]):
for key, component, label, icon in MODE_ENTITIES:
unique_id = unit_unique_id(slug, key)
state = f"{BASE}/{unit_state_key(slug, key)}"
cfg = {
"name": prefix + label,
"object_id": f"{site_slug}_{slug}_{key}",
"unique_id": unique_id,
"state_topic": state,
"command_topic": f"{state}/set",
}
if component == "select":
cfg["options"] = options
else:
cfg["payload_on"] = USE_ON
cfg["payload_off"] = USE_OFF
# HA sends commands unretained; a retained one is ignored anyway.
cfg["retain"] = False
cfg["availability_topic"] = AVAILABILITY
cfg["device"] = device
cfg["icon"] = icon
out.append((f"homeassistant/{component}/{unique_id}/config", cfg))
return out
def mode_retractions(stored, current) -> list:
"""Config topics announced before and not now: each gets an empty
retained config, which removes the entity from HA. Anything in the store
that is not one of our mode config topics is ignored."""
if not isinstance(stored, list):
return []
keep = set(current)
return [t for t in stored if isinstance(t, str)
and _MODE_CONFIG_RE.fullmatch(t) and t not in keep]
def parse_command(topic, payload, retained, modes):
"""`(slug, action, value, reject)` for one message on a command topic.
`action` is `pack_use` or `pack_mode`. `value` is what /action would be
sent: a bool for the switch, a mode key for the select, or None for a
payload that is neither (the store path then refuses it). `reject` is a
reason when the message is dropped here: replayed by the broker (retained),
too long, not UTF-8, or not a command topic.
"""
m = _COMMAND_RE.fullmatch(topic) if isinstance(topic, str) else None
if m is None:
return None, None, None, "topic"
slug, key = m.group(1), m.group(2)
action = "pack_use" if key == "use" else "pack_mode"
if retained:
return slug, action, None, "retained"
if not isinstance(payload, (bytes, bytearray)) \
or len(payload) > MAX_COMMAND_BYTES:
return slug, action, None, "size"
try:
text = bytes(payload).decode("utf-8")
except UnicodeDecodeError:
return slug, action, None, "encoding"
if key == "use":
value = {USE_ON: True, USE_OFF: False}.get(text)
else:
value = next((k for k in modes if MODE_LABELS.get(k) == text), None)
return slug, action, value, None
class MqttPublisher: class MqttPublisher:
# OPT-11 P2. Class defaults, so a publisher built without them (and every
# test fixture that builds one with __new__) behaves as before.
modes = ()
retract = ()
_loop = None
_handler = None
def __init__(self, host, port, username=None, password=None, omit=(), def __init__(self, host, port, username=None, password=None, omit=(),
units=(), include=()): units=(), include=(), modes=(), retract=()):
# `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a # `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a
# sensor that nothing will ever publish to is not harmless here: # sensor that nothing will ever publish to is not harmless here:
# p1_sample_age_s is a watchdog input, and an entity that exists but is # p1_sample_age_s is a watchdog input, and an entity that exists but is
@@ -448,6 +574,10 @@ class MqttPublisher:
# second reading of the option. Defaults to empty, so a caller that has # second reading of the option. Defaults to empty, so a caller that has
# not been taught about it announces exactly what it announced before. # not been taught about it announces exactly what it announced before.
self.include = [str(k) for k in include] self.include = [str(k) for k in include]
# OPT-11 P2: the modes offered (empty when battery modes are off), and
# the mode config topics to remove (`mode_retractions()`).
self.modes = tuple(modes)
self.retract = [str(t) for t in retract]
self.enabled = mqtt is not None and bool(host) self.enabled = mqtt is not None and bool(host)
self.client = None self.client = None
if not self.enabled: if not self.enabled:
@@ -470,7 +600,8 @@ class MqttPublisher:
# paho DROPS QoS-0 publishes when disconnected, silently. The result is # paho DROPS QoS-0 publishes when disconnected, silently. The result is
# an add-on that logs "MQTT connected" and creates no entities at all. # an add-on that logs "MQTT connected" and creates no entities at all.
# As a bonus, this also re-announces after every reconnect. # As a bonus, this also re-announces after every reconnect.
self.client.on_connect = lambda *_args, **_kw: self._announce() self.client.on_connect = lambda *_args, **_kw: self._on_connect()
self.client.on_message = self._on_message
try: try:
self.client.connect(host, int(port), keepalive=60) self.client.connect(host, int(port), keepalive=60)
self.client.loop_start() self.client.loop_start()
@@ -485,10 +616,35 @@ class MqttPublisher:
# diffing what actually goes on the wire - and a payload built here # diffing what actually goes on the wire - and a payload built here
# instead would be one this add-on publishes and no check has ever seen. # instead would be one this add-on publishes and no check has ever seen.
for topic, cfg in discovery_payloads(self.units, self.omit, for topic, cfg in discovery_payloads(self.units, self.omit,
self.include): self.include, self.modes):
self.client.publish(topic, json.dumps(cfg), retain=True) self.client.publish(topic, json.dumps(cfg), retain=True)
for topic in self.retract:
self.client.publish(topic, "", retain=True)
self.client.publish(AVAILABILITY, "online", retain=True) self.client.publish(AVAILABILITY, "online", retain=True)
def _on_connect(self) -> None:
self._announce()
if self.modes:
for key in ("mode", "use"):
self.client.subscribe(f"{BASE}/unit/+/{key}/set")
def bind(self, loop, handler) -> None:
"""OPT-11 P2: hand each command to `handler(topic, payload, retained)`,
a coroutine function run on `loop` (the controller's)."""
self._loop, self._handler = loop, handler
def _on_message(self, _client, _userdata, msg) -> None:
# paho's network thread: nothing is done here but the handoff.
loop, handler = self._loop, self._handler
if loop is None or handler is None:
_LOG.debug("MQTT command before start-up ignored: %s", msg.topic)
return
try:
asyncio.run_coroutine_threadsafe(
handler(msg.topic, bytes(msg.payload), bool(msg.retain)), loop)
except RuntimeError as err: # the loop has closed
_LOG.debug("MQTT command dropped: %s", err)
def publish(self, values: dict) -> None: def publish(self, values: dict) -> None:
if not self.enabled or self.client is None: if not self.enabled or self.client is None:
return return
+6
View File
@@ -42,6 +42,12 @@ MODE_KEY = "pack_mode"
SELL_KEY = "pack_sell" # reserved for P3 SELL_KEY = "pack_sell" # reserved for P3
def enabled(opts) -> bool:
"""Battery modes are on only when the option is exactly true. One answer,
read by the controller and by the MQTT announcement."""
return opts.get("battery_modes_enabled") is True
@dataclass(frozen=True) @dataclass(frozen=True)
class PackMode: class PackMode:
"""One battery's choice: on or off, its mode, and (P3) selling.""" """One battery's choice: on or off, its mode, and (P3) selling."""
+3 -1
View File
@@ -845,7 +845,7 @@ DATA = {
"main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'", "main.py:'cancelled' if task.cancelled() else repr(exc) if exc else 'returned'",
"main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH", "main.py:EXIT_CONTROL_DIED", "main.py:first.name", "main.py:OPTIONS_PATH",
"main.py:err", "main.py:', '.join(sorted(stranded))", "main.py:err", "main.py:', '.join(sorted(stranded))",
"main.py:sorted(on_roster)", "main.py:inv.name", "main.py:sorted(on_roster)", "main.py:inv.name", "main.py:topic_s",
"main.py:inv.batt_fresh.bound.release_s", "main.py:inv.batt_fresh.bound.move_w", "main.py:inv.batt_fresh.bound.release_s", "main.py:inv.batt_fresh.bound.move_w",
"main.py:inv.batt_fresh.bound.unchanged_s", "main.py:inv.batt_fresh.held", "main.py:inv.batt_fresh.bound.unchanged_s", "main.py:inv.batt_fresh.held",
"main.py:value", "main.py:float(value)", "main.py:value", "main.py:float(value)",
@@ -1058,6 +1058,8 @@ TEXT = {
"main.py:rep.explain()": _migration_texts, "main.py:rep.explain()": _migration_texts,
"store.py:rep.explain()": _migration_texts, "store.py:rep.explain()": _migration_texts,
"main.py:plan.words": _tariff_texts, "main.py:plan.words": _tariff_texts,
# OPT-11 P2: why an MQTT command was ignored.
"main.py:why": lambda: list(messages.PACK_REFUSED.values()),
# Families: every builder of the text, as a template. # Families: every builder of the text, as a template.
"main.py:refusal": lambda: (_static(("main.py", "return", "write_refusal")) "main.py:refusal": lambda: (_static(("main.py", "return", "write_refusal"))
+ _static(("allocator.py", "return", "authorises"))), + _static(("allocator.py", "return", "authorises"))),
+448 -2
View File
@@ -42,6 +42,7 @@ part is a diff against it, reported as `N topics, M missing, C changed, A added`
re-executed from mutated source, each one named with the property it re-executed from mutated source, each one named with the property it
breaks. A property nothing breaks is a property nothing is watching. breaks. A property nothing breaks is a property nothing is watching.
Part 9 What these checks CANNOT see, enumerated and sized. Part 9 What these checks CANNOT see, enumerated and sized.
Part 10 OPT-11 P2: each battery's select and switch, commands, removal.
⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC, ⚠️ THE FIXTURE GIVES A AND B DIFFERENT NAMES, DIFFERENT RATINGS, DIFFERENT SoC,
DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose DIFFERENT BATTERY POWER AND DIFFERENT LATCH STATES, ON PURPOSE. A fixture whose
@@ -821,8 +822,8 @@ MUTATIONS = [
("per-unit entities announced at a roster of ONE", ("per-unit entities announced at a roster of ONE",
[("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]), [("PER_UNIT_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]),
("the unit is dropped from the unique_id", ("the unit is dropped from the unique_id",
[('unique_id = f"{BASE}_unit_{slug}_{key}"', [('return f"{BASE}_unit_{slug}_{key}"',
'unique_id = f"{BASE}_unit_{key}"')]), 'return f"{BASE}_unit_{key}"')]),
("the unit is dropped from the state topic", ("the unit is dropped from the state topic",
[('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]), [('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]),
("the slug de-collision is removed (rule 9(c) does not cover this)", ("the slug de-collision is removed (rule 9(c) does not cover this)",
@@ -944,6 +945,451 @@ for line in [
]: ]:
print(" " + line) print(" " + line)
# ---------------------------------------------------------------------------
print("\n10. OPT-11 P2: each battery's switch and mode as HA entities")
# ---------------------------------------------------------------------------
import logging # noqa: E402
import os # noqa: E402
import tempfile # noqa: E402
from app import messages, packmode # noqa: E402
from app.store import Store, unit_key # noqa: E402
def announce_p2(units=(), omit=(), include=(), modes=(), retract=(),
module=mqtt):
"""What `_on_connect()` sends: the announcement, then any subscriptions."""
pub = module.MqttPublisher.__new__(module.MqttPublisher)
pub.omit, pub.units, pub.include = set(omit), [str(u) for u in units], \
[str(k) for k in include]
pub.modes, pub.retract = tuple(modes), list(retract)
pub.enabled, pub.client = True, FakeClient()
subs = []
pub.client.subscribe = lambda topic, *a: subs.append(topic)
pub._on_connect()
return pub.client.msgs, subs
# --- 10a. modes off: byte-identical to ed739d4 --------------------------------
# The digest below is GENERATED from release/1.0 at ed739d4 (the base of this
# PR): the same matrix run through that commit's `_announce()`. Reproduce with
# `git show ed739d4:goodwe_controller/app/mqtt.py` and this loop.
ROSTERS = [[], ["inverter-a"], ["inverter-a", "inverter-b"], ["a", "b", "c"],
["inverter-b", "Inverter B", "x", "y"], ["café"]]
ED739D4_OFF = "501a7d93782a260b"
_h, _n, _subs_off = hashlib.sha256(), 0, []
for _r in ROSTERS:
for _om in [(), ("p1_age",), ("p1_age", "grid")]:
for _inc in [(), ("pv",)]:
_m, _s = announce_p2(_r, _om, _inc)
_h.update(repr(_m).encode())
_subs_off += _s
_n += 1
print(f" modes off, {_n} configurations: digest {_h.hexdigest()[:16]} "
f"(ed739d4: {ED739D4_OFF})")
check("modes off: every configuration announces byte-for-byte what ed739d4 "
"announces, in the same order, and subscribes to nothing",
_h.hexdigest()[:16] == ED739D4_OFF and not _subs_off)
# --- 10b. modes on: the ordered payload diff ----------------------------------
MODES = packmode.MODES
ON1, SUB1 = announce_p2(["inverter-a"], modes=MODES)
m, c, a = report("roster of one, modes on", ON1)
_opt = '["Solar first", "Leftover solar"]'
MODE_N1 = [
("homeassistant/select/goodwe_ctl_unit_inverter_a_mode/config",
'{"name": "Inverter A Mode", "object_id": "goodwe_inverter_a_mode", '
'"unique_id": "goodwe_ctl_unit_inverter_a_mode", '
'"state_topic": "goodwe_ctl/unit/inverter_a/mode", '
'"command_topic": "goodwe_ctl/unit/inverter_a/mode/set", '
'"options": ' + _opt + ', "retain": false, '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"icon": "mdi:battery-sync"}', True),
("homeassistant/switch/goodwe_ctl_unit_inverter_a_use/config",
'{"name": "Inverter A Use", "object_id": "goodwe_inverter_a_use", '
'"unique_id": "goodwe_ctl_unit_inverter_a_use", '
'"state_topic": "goodwe_ctl/unit/inverter_a/use", '
'"command_topic": "goodwe_ctl/unit/inverter_a/use/set", '
'"payload_on": "ON", "payload_off": "OFF", "retain": false, '
'"availability_topic": "goodwe_ctl/availability", "device": ' + _DEV + ', '
'"icon": "mdi:power"}', True),
]
check("modes on at a roster of one: every existing message byte-identical and "
"in place, then exactly the select and the switch, then availability",
ON1 == SITE_NOW[:-1] + MODE_N1 + SITE_NOW[-1:])
check("...and it subscribes to the two command topics, and nothing else",
SUB1 == ["goodwe_ctl/unit/+/mode/set", "goodwe_ctl/unit/+/use/set"])
ON2, _ = announce_p2(NAMES2, modes=MODES)
m, c, a = report("roster of two, modes on", ON2, TWO)
check("modes on at a roster of two: nothing missing or changed, four added, "
"and the modes-off list is an unchanged prefix",
(m, c) == ([], []) and len(a) == 4 and ON2[:len(TWO) - 1] == TWO[:-1]
and ON2[-1] == TWO[-1])
_mc = cfgs(ON2)[-4:]
check("...A's two stay on the site device with its name in front; B's two are "
"on B's own device, as TEL-07's rule has it",
[c_["name"] for c_ in _mc] == ["Inverter A Mode", "Inverter A Use",
"Mode", "Use"]
and _mc[0]["device"] == mqtt.DEVICE == _mc[1]["device"]
and _mc[2]["device"] == _mc[3]["device"]
== mqtt.unit_device("inverter-b", "inverter_b"))
check("...unique_ids are stable per battery and distinct from every other id",
[c_["unique_id"] for c_ in _mc]
== ["goodwe_ctl_unit_inverter_a_mode", "goodwe_ctl_unit_inverter_a_use",
"goodwe_ctl_unit_inverter_b_mode", "goodwe_ctl_unit_inverter_b_use"]
and len({c_["unique_id"] for c_ in cfgs(ON2)}) == len(cfgs(ON2)))
_ids = [compose(c_["device"]["name"], c_["name"]).split(".", 1)[1]
for c_ in _mc]
print(f" composed ids: {_ids}")
check("...and HA composes the ids the design names: "
"goodwe_inverter_<x>_mode and goodwe_inverter_<x>_use",
_ids == ["goodwe_inverter_a_mode", "goodwe_inverter_a_use",
"goodwe_inverter_b_mode", "goodwe_inverter_b_use"])
check("every mode has a plain label, in MODES order, and a later mode only "
"appends an option: no existing option or id changes",
all(k in mqtt.MODE_LABELS for k in MODES)
and len(set(mqtt.MODE_LABELS.values())) == len(mqtt.MODE_LABELS)
and json.loads(MODE_N1[0][1])["options"]
== [mqtt.MODE_LABELS[k] for k in MODES])
_later = dict(mqtt.MODE_LABELS, cheap_hours="Cheap hours only")
_orig = mqtt.MODE_LABELS
mqtt.MODE_LABELS = _later
try:
_p3 = mqtt.mode_payloads(NAMES2, MODES + ("cheap_hours",))
finally:
mqtt.MODE_LABELS = _orig
_p2 = mqtt.mode_payloads(NAMES2, MODES)
check("...(simulated P3: adding `cheap_hours` changes only the options list, "
"by one entry at its end)",
[t for t, _ in _p3] == [t for t, _ in _p2]
and all({k: v for k, v in x.items() if k != "options"}
== {k: v for k, v in y.items() if k != "options"}
for (_, x), (_, y) in zip(_p3, _p2))
and _p3[0][1]["options"] == _p2[0][1]["options"] + ["Cheap hours only"])
# --- 10c. modes turned off: removal ------------------------------------------
_cur2 = [t for t, _ in mqtt.mode_payloads(NAMES2, MODES)]
_ret = mqtt.mode_retractions(_cur2, [])
OFF2, _subs = announce_p2(NAMES2, retract=_ret)
check("modes turned off: the modes-off list, then an EMPTY retained config "
"for each of the four, then availability, and no subscription",
OFF2 == TWO[:-1] + [(t, "", True) for t in _cur2] + TWO[-1:]
and not _subs)
check("...the store keeps what was announced, so a roster that shrinks removes "
"only the battery that left",
mqtt.mode_retractions(_cur2, [t for t, _ in
mqtt.mode_payloads(["inverter-a"], MODES)])
== _cur2[2:])
check("...and nothing from the store is removed that is not one of our mode "
"configs: a non-list, a number, a site sensor, another add-on's select",
mqtt.mode_retractions("homeassistant/select/x/config", []) == []
and mqtt.mode_retractions(
[3, None, "homeassistant/sensor/goodwe_ctl_grid/config",
"homeassistant/select/other_mode/config",
"homeassistant/select/goodwe_ctl_unit_a_mode/config/x",
"homeassistant/select/goodwe_ctl_unit_a_mode/config"], [])
== ["homeassistant/select/goodwe_ctl_unit_a_mode/config"])
# --- 10d. state topics, from the store ----------------------------------------
_tmp = tempfile.mkdtemp()
OPTS2M = dict(OPTS2, battery_modes_enabled=True)
def ctl_for(opts, path):
pub = _Pub()
pub.sent = []
pub.publish = lambda values, _p=pub: (_p.sent.append(dict(values)),
setattr(_p, "last", dict(values)))
return Controller(dict(opts), None, Store(path), pub,
stop=asyncio.Event()), pub
_path = os.path.join(_tmp, "state.json")
ctlm, pubm = ctl_for(OPTS2M, _path)
ctlm.publish()
_fed = {f"{mqtt.BASE}/{k}" for k in pubm.last
if k.endswith("/mode") or k.endswith("/use")}
check("every announced mode entity is fed, and nothing else is: the state "
"topics published are exactly the ones announced",
_fed == {c_["state_topic"] for c_ in _mc})
check("...defaults with nothing in the store: both On, both Solar first",
[pubm.last[k] for k in ("unit/inverter_a/mode", "unit/inverter_a/use",
"unit/inverter_b/mode", "unit/inverter_b/use")]
== ["Solar first", "ON", "Solar first", "ON"])
_c_off, _p_off = ctl_for(dict(OPTS2, battery_modes_enabled=False),
os.path.join(_tmp, "off2.json"))
_c_off.publish()
_c_abs, _p_abs = ctl_for(OPTS2, os.path.join(_tmp, "off3.json"))
_c_abs.publish()
check("modes off (false or absent): the controller publishes the same keys as "
"before, with no mode or switch topic",
set(_p_off.last) == set(_p_abs.last) == set(vals2)
and not [k for k in _p_off.last if k.endswith(("/mode", "/use"))])
# --- 10e. commands: the /action path, validated -------------------------------
class _Logs(logging.Handler):
def __init__(self):
super().__init__(logging.DEBUG)
self.rows = []
def emit(self, record):
self.rows.append((record.levelno, record.getMessage()))
_logs = _Logs()
logging.getLogger("goodwe").addHandler(_logs)
logging.getLogger("goodwe").setLevel(logging.DEBUG)
_calls = []
_real_handle = ctlm.handle_action
async def _spy(what, **kw):
_calls.append((what, kw))
return await _real_handle(what, **kw)
ctlm.handle_action = _spy
def cmd(topic, payload, retained=False, ctl=None):
ctl = ctl or ctlm
asyncio.run(ctl.mqtt_command(topic, payload, retained))
def stored(ctl=None):
d = (ctl or ctlm).store.data
return {k: d.get(k) for k in (unit_key("inverter-a", "pack_use"),
unit_key("inverter-a", "pack_mode"),
unit_key("inverter-b", "pack_use"),
unit_key("inverter-b", "pack_mode"))}
T_A = "goodwe_ctl/unit/inverter_a"
T_B = "goodwe_ctl/unit/inverter_b"
pubm.sent.clear()
cmd(f"{T_B}/use/set", b"OFF")
cmd(f"{T_A}/mode/set", b"Leftover solar")
check("good commands go through handle_action() with the /action arguments",
_calls == [("pack_use", {"unit": "inverter-b", "value": False}),
("pack_mode", {"unit": "inverter-a",
"value": "leftover_solar"})])
check("...and reach the store under P1's keys, and nowhere else",
stored() == {unit_key("inverter-a", "pack_use"): None,
unit_key("inverter-a", "pack_mode"): "leftover_solar",
unit_key("inverter-b", "pack_use"): False,
unit_key("inverter-b", "pack_mode"): None})
check("...and the state topics say so at once, from the store",
pubm.sent and pubm.sent[-1] == {
"unit/inverter_a/mode": "Leftover solar", "unit/inverter_a/use": "ON",
"unit/inverter_b/mode": "Solar first", "unit/inverter_b/use": "OFF"})
check("...and the action log records each change",
any("inverter-b switched off" in e for e in ctlm.events)
and any("inverter-a mode set to leftover solar" in e
for e in ctlm.events))
_before = stored()
_n_calls = len(_calls)
BAD = [
(f"{T_A}/use/set", b"on"), (f"{T_A}/use/set", b"ON\n"),
(f"{T_A}/use/set", b"true"), (f"{T_A}/use/set", b"1"),
(f"{T_A}/use/set", b""),
(f"{T_A}/mode/set", b"solar_first"), # a key, not the option
(f"{T_A}/mode/set", b"Cheap hours only"), # not offered before P3
(f"{T_A}/mode/set", b"solar first"),
(f"{T_A}/mode/set", "Sοlar first".encode()), # Greek omicron
(f"{T_A}/mode/set", b"Solar first\x00"),
(f"{T_A}/mode/set", b'{"action": "pack_use", "value": false}'),
(f"{T_A}/mode/set", b"Solar first; rm -rf /"),
(f"{T_A}/mode/set", b"' OR 1=1 --"),
(f"{T_A}/use/set", b"\xff\xfe"), # not UTF-8
(f"{T_A}/mode/set", b"Solar first" * 10), # too long
("goodwe_ctl/unit/inverter_z/use/set", b"OFF"), # no such battery
("goodwe_ctl/unit/../use/set", b"OFF"),
("goodwe_ctl/unit/inverter_a/soc/set", b"OFF"),
("goodwe_ctl/unit/inverter_a/use/set/x", b"OFF"),
("goodwe_ctl/unit/Inverter_A/use/set", b"OFF"),
(f"{T_A}/use/set", None),
]
for _t, _p in BAD:
cmd(_t, _p)
check(f"all {len(BAD)} bad commands are refused: the store is unchanged",
stored() == _before)
_passed = [c_ for c_ in _calls[_n_calls:]]
check("...those that reach handle_action() carry a value /action also refuses "
"(None) or no known battery; nothing is coerced into a valid choice",
all(kw["value"] is None or kw["unit"] is None for _, kw in _passed))
# Log once per topic and reason.
_logs.rows.clear()
for _ in range(5):
cmd(f"{T_B}/use/set", b"maybe")
_warn = [r for r in _logs.rows if r[0] >= logging.WARNING]
check("a refused command repeated 5 times is logged once at WARNING, in plain "
"words, and at DEBUG the other times",
len(_warn) == 1 and messages.pack_refused("value") in _warn[0][1]
and len([r for r in _logs.rows if r[0] == logging.DEBUG]) == 5)
_logs.rows.clear()
_flood, _ = ctl_for(OPTS2M, os.path.join(_tmp, "flood.json"))
for _i in range(100):
cmd(f"goodwe_ctl/unit/x{_i}/use/set", b"OFF", ctl=_flood)
check("...and a flood of 100 different bad topics logs 32 WARNINGs, no more",
len([r for r in _logs.rows if r[0] >= logging.WARNING]) == 32)
# Modes off: refused on the same path, even if a message arrives.
cmd(f"{T_A}/use/set", b"OFF", ctl=_c_off)
check("with modes off a command is refused by the same check /action makes",
stored(_c_off)[unit_key("inverter-a", "pack_use")] is None)
# --- 10f. a retained command after a restart ----------------------------------
_logs.rows.clear()
_before = stored()
_n_calls = len(_calls)
cmd(f"{T_A}/use/set", b"OFF", retained=True)
cmd(f"{T_B}/use/set", b"ON", retained=True)
check("a RETAINED command (the broker replays it on subscribe, e.g. after a "
"restart) is never applied, and never reaches handle_action()",
stored() == _before and len(_calls) == _n_calls)
check("...and it is logged once, in plain words",
any(messages.pack_refused("retained") in r[1]
for r in _logs.rows if r[0] >= logging.WARNING))
ctl_r, pub_r = ctl_for(OPTS2M, _path) # a restart: same state file
ctl_r.publish()
check("after a restart the state topics come from the store: A Leftover solar "
"and on, B off",
[pub_r.last[k] for k in ("unit/inverter_a/mode", "unit/inverter_a/use",
"unit/inverter_b/use")]
== ["Leftover solar", "ON", "OFF"])
logging.getLogger("goodwe").removeHandler(_logs)
# --- 10g. the paho side ----------------------------------------------------------
class _Msg:
def __init__(self, topic, payload, retain):
self.topic, self.payload, self.retain = topic, payload, retain
_got = []
async def _handler(*args):
_got.append(args)
async def _drive():
pub = mqtt.MqttPublisher.__new__(mqtt.MqttPublisher)
pub._on_message(None, None, _Msg("t", b"x", 0)) # not bound: dropped
pub.bind(asyncio.get_running_loop(), _handler)
pub._on_message(None, None, _Msg(f"{T_A}/use/set", b"OFF", 1))
await asyncio.sleep(0.05)
asyncio.run(_drive())
check("paho's thread hands (topic, bytes, retained) to the controller's loop, "
"and drops a message that arrives before the controller exists",
_got == [(f"{T_A}/use/set", b"OFF", True)])
_kws = {k.arg: ast.unparse(k.value) for c_ in _call for k in c_.keywords}
_main_src = (HERE / "app" / "main.py").read_text()
check("(source read) main() passes modes and retractions to the publisher, "
"and binds its commands to the controller",
_kws.get("modes") == "_modes" and _kws.get("retract") == "_retract"
and "pub.bind(asyncio.get_running_loop(), controller.mqtt_command)"
in _main_src)
# --- 10h. the guards, broken one at a time --------------------------------------
def p2_properties(mod):
out = {}
try:
off, s_off = announce_p2(NAMES2, module=mod)
on1, s_on = announce_p2(["inverter-a"], modes=MODES, module=mod)
on2, _ = announce_p2(NAMES2, modes=MODES, module=mod)
out["off identical"] = off == TWO and not s_off
out["on payloads"] = on1 == ON1 and on2 == ON2
out["subscribes when on"] = s_on == SUB1
cur = [t for t, _ in mod.mode_payloads(NAMES2, MODES)]
out["retract filters store"] = mod.mode_retractions(
[3, "homeassistant/sensor/goodwe_ctl_grid/config",
"homeassistant/select/other/config"] + cur, cur) == []
out["retract keeps current"] = mod.mode_retractions(cur, cur) == []
out["retract published"] = announce_p2(
NAMES2, retract=cur[:1], module=mod)[0] == \
TWO[:-1] + [(cur[0], "", True)] + TWO[-1:]
pc = mod.parse_command
out["retained dropped"] = pc(f"{T_A}/use/set", b"OFF", True,
MODES)[3] == "retained"
out["size capped"] = pc(f"{T_A}/mode/set", b"Solar first" * 10,
False, MODES)[3] == "size"
out["utf-8 only"] = pc(f"{T_A}/use/set", b"\xff", False,
MODES)[3] == "encoding"
out["switch exact"] = (
pc(f"{T_A}/use/set", b"on", False, MODES)[2] is None
and pc(f"{T_A}/use/set", b"OFF", False, MODES)[2] is False)
out["select labels only"] = (
pc(f"{T_A}/mode/set", b"solar_first", False, MODES)[2] is None
and pc(f"{T_A}/mode/set", b"Leftover solar", False,
MODES)[2] == "leftover_solar")
out["select offered only"] = pc(
f"{T_A}/mode/set", b"Solar first", False, ("leftover_solar",))[2] \
is None
out["topic exact"] = all(
pc(t, b"OFF", False, MODES)[3] == "topic"
for t in ("goodwe_ctl/unit/../use/set", "goodwe_ctl/unit/a/b/use/set",
"goodwe_ctl/unit/a/use/set/x", "x/goodwe_ctl/unit/a/use/set"))
except Exception as err: # noqa: BLE001
out["did not raise"] = False
out["__error__"] = repr(err)[:70]
return out
P2_SHIPPED = p2_properties(mqtt)
check("the shipped module has every P2 property (so a red below is the "
"mutation)", all(v is True for v in P2_SHIPPED.values()))
P2_MUTATIONS = [
("mode entities announced with modes off",
[(" if not modes:\n return []\n", " modes = modes or ('solar_first', 'leftover_solar')\n")]),
("subscribed with modes off", [(" if self.modes:\n", " if True:\n")]),
("retained commands applied", [(" if retained:\n", " if False:\n")]),
("no payload size cap", [("or len(payload) > MAX_COMMAND_BYTES", "or False")]),
("bad UTF-8 replaced, not refused",
[('.decode("utf-8")', '.decode("utf-8", "replace")')]),
("switch payload case-folded",
[("{USE_ON: True, USE_OFF: False}.get(text)",
"{USE_ON: True, USE_OFF: False}.get(text.upper())")]),
("select accepts mode keys too",
[("if MODE_LABELS.get(k) == text", "if text in (k, MODE_LABELS.get(k))")]),
("select accepts any labelled mode, offered or not",
[("(k for k in modes if MODE_LABELS.get(k) == text)",
"(k for k in MODE_LABELS if MODE_LABELS.get(k) == text)")]),
("command topic loosened", [('/([a-z0-9_]+)/(mode|use)/set")', '/(.+)/(mode|use)/set")')]),
("command topic matched as a prefix",
[("_COMMAND_RE.fullmatch(topic)", "_COMMAND_RE.search(topic)")]),
("store list not filtered",
[("and _MODE_CONFIG_RE.fullmatch(t) and t not in keep", "and t not in keep")]),
("current configs also removed", [("_MODE_CONFIG_RE.fullmatch(t) and t not in keep", "_MODE_CONFIG_RE.fullmatch(t)")]),
("commands published retained by HA", [('cfg["retain"] = False', 'cfg["retain"] = True')]),
("retractions not published",
[(' self.client.publish(topic, "", retain=True)\n', " pass\n")]),
]
_p2_surv = []
for label, pairs in P2_MUTATIONS:
got = properties(load(pairs))
got.update(p2_properties(load(pairs)))
broke = sorted(k for k, v in {**SHIPPED, **P2_SHIPPED}.items()
if v is True and got.get(k) is not True)
print(f" {'RED ' if broke else 'GREEN'} {label}"
+ (f" (breaks: {', '.join(broke)})" if broke else ""))
if not broke:
_p2_surv.append(label)
check(f"all {len(P2_MUTATIONS)} P2 mutations of app/mqtt.py are red",
not _p2_surv)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
print() print()
if fails: if fails: