diff --git a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md index 0f7bfba..39f02b2 100644 --- a/goodwe_controller/DECISIONS/OPT-11-battery-modes.md +++ b/goodwe_controller/DECISIONS/OPT-11-battery-modes.md @@ -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 `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. +- 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. Price runs, selling (`pack_sell`, reserved) and the forecast are P3/P4. diff --git a/goodwe_controller/DOCS.md b/goodwe_controller/DOCS.md index 4f36859..059fdde 100644 --- a/goodwe_controller/DOCS.md +++ b/goodwe_controller/DOCS.md @@ -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. +**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//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`. ### Reading the grid-following loop: `Loop integrator`, `Loop integrator lead`, `Loop reason` diff --git a/goodwe_controller/app/main.py b/goodwe_controller/app/main.py index 4cdaaf2..b700dd5 100644 --- a/goodwe_controller/app/main.py +++ b/goodwe_controller/app/main.py @@ -72,7 +72,9 @@ from .maintenance import ( IDLE, POWER_MOVING, MaintConfig, Maintenance, MaintResult, 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) from . import peakguard from . import packshare @@ -901,9 +903,11 @@ class Controller: # 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, # 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). 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. # Empty at a roster of one, where nothing can defer anything. self._maint_deferred = [] @@ -2053,6 +2057,56 @@ class Controller: pm = self.pack_mode(inv) 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: """OPT-11: stop this battery's maintenance cycle because it is off.""" if machine.phase == IDLE: @@ -5144,6 +5198,8 @@ class Controller: values[unit_state_key(slug, "soc")] = inv.soc values[unit_state_key(slug, "sign_latch")] = ( "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) async def shutdown(self) -> None: @@ -6189,6 +6245,16 @@ async def amain() -> None: # took the control loop down with it. broker = None 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() pub = MqttPublisher( 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 # announces nothing new - see `announced_units()`. units=roster_names(opts), + modes=_modes, + retract=_retract, ) except Exception as err: # noqa: BLE001 _LOG.warning("MQTT unavailable (%s) - continuing without status entities", err) @@ -6221,6 +6289,8 @@ async def amain() -> None: stop = asyncio.Event() 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) loop = asyncio.get_running_loop() diff --git a/goodwe_controller/app/messages.py b/goodwe_controller/app/messages.py index 1481a54..e9dfac1 100644 --- a/goodwe_controller/app/messages.py +++ b/goodwe_controller/app/messages.py @@ -932,6 +932,11 @@ PACK_REFUSED = { "off": "Battery modes are off. Turn on “Battery modes” first.", "unit": "There is no battery with that name.", "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.", } diff --git a/goodwe_controller/app/mqtt.py b/goodwe_controller/app/mqtt.py index 94fc13d..91da048 100644 --- a/goodwe_controller/app/mqtt.py +++ b/goodwe_controller/app/mqtt.py @@ -7,8 +7,10 @@ That separation is deliberate; observability must never be able to take down control. """ +import asyncio import json import logging +import re try: import paho.mqtt.client as mqtt @@ -375,7 +377,26 @@ def _payload(name, object_id, unique_id, state_topic, device, 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. 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 ordered LIST precisely because a re-order is a zero-byte edit that 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) 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", _payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}", DEVICE, unit, dev_class, state_class, icon))) - names = announced_units(units) - slugs = unit_slugs(names) site_slug = _slug(DEVICE["name"]) - 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` 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 slug, device, prefix in _unit_places(announced_units(units)): 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(( f"homeassistant/sensor/{unique_id}/config", _payload(prefix + label, @@ -427,12 +441,124 @@ def discovery_payloads(units=(), omit=(), include=()) -> list: f"homeassistant/sensor/{BASE}_{key}/config", _payload(name, object_id, f"{BASE}_{key}", f"{BASE}/{key}", 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 +# --- 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: + # 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=(), - units=(), include=()): + units=(), include=(), modes=(), retract=()): # `omit` drops sensor keys from discovery entirely. ⚠️ Announcing a # 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 @@ -448,6 +574,10 @@ class MqttPublisher: # second reading of the option. Defaults to empty, so a caller that has # not been taught about it announces exactly what it announced before. 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.client = None if not self.enabled: @@ -470,7 +600,8 @@ class MqttPublisher: # paho DROPS QoS-0 publishes when disconnected, silently. The result is # an add-on that logs "MQTT connected" and creates no entities at all. # 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: self.client.connect(host, int(port), keepalive=60) self.client.loop_start() @@ -485,10 +616,35 @@ class MqttPublisher: # 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. 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) + for topic in self.retract: + self.client.publish(topic, "", 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: if not self.enabled or self.client is None: return diff --git a/goodwe_controller/app/packmode.py b/goodwe_controller/app/packmode.py index df2641d..a4989dd 100644 --- a/goodwe_controller/app/packmode.py +++ b/goodwe_controller/app/packmode.py @@ -42,6 +42,12 @@ MODE_KEY = "pack_mode" 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) class PackMode: """One battery's choice: on or off, its mode, and (P3) selling.""" diff --git a/goodwe_controller/test_messages.py b/goodwe_controller/test_messages.py index 543bc03..0e29a8d 100644 --- a/goodwe_controller/test_messages.py +++ b/goodwe_controller/test_messages.py @@ -845,7 +845,7 @@ DATA = { "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: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.unchanged_s", "main.py:inv.batt_fresh.held", "main.py:value", "main.py:float(value)", @@ -1058,6 +1058,8 @@ TEXT = { "main.py:rep.explain()": _migration_texts, "store.py:rep.explain()": _migration_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. "main.py:refusal": lambda: (_static(("main.py", "return", "write_refusal")) + _static(("allocator.py", "return", "authorises"))), diff --git a/goodwe_controller/test_mqtt.py b/goodwe_controller/test_mqtt.py index 75da0d1..bf5f4fd 100644 --- a/goodwe_controller/test_mqtt.py +++ b/goodwe_controller/test_mqtt.py @@ -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 breaks. A property nothing breaks is a property nothing is watching. 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, 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_MIN_ROSTER = 2", "PER_UNIT_MIN_ROSTER = 1")]), ("the unit is dropped from the unique_id", - [('unique_id = f"{BASE}_unit_{slug}_{key}"', - 'unique_id = f"{BASE}_unit_{key}"')]), + [('return f"{BASE}_unit_{slug}_{key}"', + 'return f"{BASE}_unit_{key}"')]), ("the unit is dropped from the state topic", [('return f"unit/{slug}/{key}"', 'return f"unit/{key}"')]), ("the slug de-collision is removed (rule 9(c) does not cover this)", @@ -944,6 +945,451 @@ for line in [ ]: 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__mode and goodwe_inverter__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() if fails: