OPT-11 P2: HA entities for battery modes
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:
@@ -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.
|
||||||
|
|||||||
@@ -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,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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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"))),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user