Merge release/1.0 (7337b79, OPT-11 P2) into safety-79-peak-margin
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -72,8 +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,
|
||||
unit_state_key)
|
||||
from .mqtt import (MODE_CONFIGS_KEY, MODE_LABELS, USE_OFF, USE_ON,
|
||||
MqttPublisher, announced_units, mode_configs,
|
||||
optional_keys, parse_command, unit_slugs, unit_state_key)
|
||||
from . import peakguard
|
||||
from . import packshare
|
||||
from . import pv as pv_read
|
||||
@@ -905,9 +906,12 @@ 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()
|
||||
self._mqtt_refused_more = 0
|
||||
# [(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 = []
|
||||
@@ -2062,6 +2066,65 @@ 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, at most 32, and further refusals are counted in the
|
||||
log at 1, 10, 100 and so on. 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)
|
||||
# Printable only: a topic is sender-chosen, so no log injection.
|
||||
topic_s = "".join(c if c.isprintable() else "?"
|
||||
for c in 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:
|
||||
# Not logged again, but counted: 1, 10, 100... stay visible.
|
||||
self._mqtt_refused_more += 1
|
||||
more = self._mqtt_refused_more
|
||||
if str(more).rstrip("0") == "1":
|
||||
_LOG.warning("Home Assistant commands still ignored: %d "
|
||||
"more so far.", more)
|
||||
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:
|
||||
@@ -5153,6 +5216,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:
|
||||
@@ -6068,6 +6133,21 @@ class ControlTaskDied(RuntimeError):
|
||||
"""The control task ended while nothing had asked it to stop."""
|
||||
|
||||
|
||||
def mode_announcement(opts, store) -> tuple:
|
||||
"""OPT-11 P2: `(modes, retract)` for the MQTT publisher at start-up.
|
||||
|
||||
The config topics announced are kept in the store, so turning modes off
|
||||
removes exactly those (`mqtt.mode_configs()`). An install that never
|
||||
turned modes on gets nothing, and its store is not written.
|
||||
"""
|
||||
modes = packmode.MODES if packmode.enabled(opts) else ()
|
||||
stored = store.data.get(MODE_CONFIGS_KEY)
|
||||
keep, retract = mode_configs(roster_names(opts), modes, stored)
|
||||
if keep and keep != stored:
|
||||
store.set(MODE_CONFIGS_KEY, keep)
|
||||
return modes, retract
|
||||
|
||||
|
||||
def control_task_done(task: asyncio.Task, *, controller, stop: asyncio.Event,
|
||||
died: list) -> None:
|
||||
"""Done-callback on the control task: log its death now and wake amain()
|
||||
@@ -6198,6 +6278,9 @@ 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 (`mode_announcement()`).
|
||||
_modes, _retract = mode_announcement(opts, store)
|
||||
broker = await hass.mqtt_service()
|
||||
pub = MqttPublisher(
|
||||
broker.get("host") if broker else None,
|
||||
@@ -6217,6 +6300,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)
|
||||
@@ -6230,6 +6315,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()
|
||||
|
||||
Reference in New Issue
Block a user