Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions hass-addon-sunsynk-edge/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

<https://github.com/kellerza/sunsynk/commits/>

## Unreleased

- Re-send the last published sensor states 10 s and 60 s after Home Assistant comes back online, so
schedule-reported sensors (energy, temperatures) are no longer unknown for up to 5 min after an HA
restart.

## Release 1.3.0

All items below are changes since add-on **1.2.0**.
Expand Down
12 changes: 10 additions & 2 deletions src/ha_addon_sunsynk_multi/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from sunsynk import VERSION
from sunsynk.utils import pretty_table_sensors

from .a_inverter import STATE
from .a_inverter import STATE, republish_all_states
from .a_sensor import MQTT, SS_TOPIC
from .driver import callback_discovery_info, init_driver
from .errors import print_errors
Expand Down Expand Up @@ -93,10 +93,18 @@ async def main_loop() -> int:
)
return 2

republish_task: asyncio.Task[None] | None = None

async def on_ha_connected() -> None:
"""When MQTT is connected, ensure we update MQTT availability states."""
"""When HA comes online: update availability, schedule a state re-send."""
nonlocal republish_task
for ist in STATE:
await ist.set_lifecycle(None)
# HA does not keep non-retained states across a restart. Re-send them in
# the background: this runs under the discovery lock and must not wait.
if republish_task is not None and not republish_task.done():
republish_task.cancel()
republish_task = asyncio.create_task(republish_all_states())

MQTT.on_ha_connected = on_ha_connected

Expand Down
52 changes: 52 additions & 0 deletions src/ha_addon_sunsynk_multi/a_inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,39 @@ async def publish_sensors(self, *, states: dict[ASensor, ValType]) -> None:
else:
await state.publish(value)

async def republish_states(self) -> int:
"""Re-send the last published value of every non-retained sensor.

Home Assistant does not keep non-retained states across a restart, so
sensors reported on a fixed schedule (energy, temperatures) or only on
change would stay unknown until their next report. Retained (RW) sensors
come back from the broker and are skipped. Does nothing unless the
inverter is running. Returns the number of sensors re-sent.
"""
if self.lifecycle != "running":
return 0
count = 0
for asen in list(self.ss.values()):
if asen.entity is None or asen.retain:
continue
val = asen.value
if val is None:
continue
try:
await asen.entity.send_state(MQTT, val)
# ASensor.publish() updates _last only after its own send: if one
# was in flight, the older value just went out after it. Send the
# newer value again so it is the last one Home Assistant receives.
# ponytail: one re-check; a second publish landing in that window
# too would need a loop.
if asen.value is not None and asen.value != val:
await asen.entity.send_state(MQTT, asen.value)
except Exception as err:
_LOG.warning("Republish %s failed: %s", asen.name, err)
continue
count += 1
return count

async def connect(self) -> None:
"""Connect."""
await self.set_lifecycle("starting")
Expand Down Expand Up @@ -463,6 +496,25 @@ async def publish_stats(self, period: int) -> None:

STATE: list[AInverter] = []

REPUBLISH_DELAYS = (10, 60)
"""Seconds after Home Assistant comes online to re-send states: the first pass
once HA processed the discovery payload, the second for late subscriptions."""


async def republish_all_states(delays: Iterable[float] = REPUBLISH_DELAYS) -> None:
"""Re-send the states of all inverters after Home Assistant came online."""
start = time.monotonic()
for delay in delays:
await asyncio.sleep(max(0.0, start + delay - time.monotonic()))
for ist in STATE:
count = await ist.republish_states()
_LOG.debug(
"Republished %s states for %s (%ss after HA online)",
count,
ist.opt.ha_prefix,
delay,
)


def compact_exception_group(err: ExceptionGroup) -> str:
"""Compact exception group."""
Expand Down
139 changes: 139 additions & 0 deletions src/tests/ha_addon_sunsynk_multi/test_republish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
"""Tests for re-sending states when Home Assistant comes back online."""

from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, MagicMock, call

import pytest

from ha_addon_sunsynk_multi import a_inverter
from ha_addon_sunsynk_multi.a_inverter import AInverter, republish_all_states
from ha_addon_sunsynk_multi.a_sensor import MQTT, ASensor
from ha_addon_sunsynk_multi.sensor_options import SensorOption
from sunsynk.sensors import Sensor
from sunsynk.sunsynk import ValType

from .conftest import NOSCHEDULE, ist_factory


def _asensor(
name: str, value: ValType, *, retain: bool = False, entity: bool = True
) -> ASensor:
"""Return an ASensor whose last published value is `value`."""
ent = None
if entity:
ent = MagicMock()
ent.send_state = AsyncMock()
return ASensor(
opt=SensorOption(
sensor=Sensor(1, name, "W"), schedule=NOSCHEDULE, visible=True
),
entity=ent,
retain=retain,
_last=value,
)


def _running_ist() -> AInverter:
ist = ist_factory()
ist.lifecycle = "running"
ist.ss = {
"power": _asensor("power", 100),
"energy": _asensor("energy", 12.3),
"never": _asensor("never", None),
"rw": _asensor("rw", 50, retain=True),
"hidden": _asensor("hidden", 7, entity=False),
}
return ist


async def test_republish_states_sends_last_published_non_retained() -> None:
"""Only non-retained sensors with an entity and a published value are re-sent."""
ist = _running_ist()

assert await ist.republish_states() == 2

ist.ss["power"].entity.send_state.assert_awaited_once_with(MQTT, 100) # type: ignore[union-attr]
ist.ss["energy"].entity.send_state.assert_awaited_once_with(MQTT, 12.3) # type: ignore[union-attr]
ist.ss["never"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]
ist.ss["rw"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]
# never written by a republish
assert ist.ss["power"].value == 100


@pytest.mark.parametrize("lifecycle", ["starting", "stale_quiet", "stale_probing"])
async def test_republish_states_only_when_running(lifecycle: str) -> None:
"""Do not re-send values from an inverter that is not being read."""
ist = _running_ist()
ist.lifecycle = lifecycle # type: ignore[assignment]

assert await ist.republish_states() == 0
ist.ss["power"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]


async def test_republish_states_resends_value_published_meanwhile() -> None:
"""A publish in flight updates _last after our send: re-send the newer value."""
ist = _running_ist()
power = ist.ss["power"]

async def send_state(_client: Any, payload: Any, **_kw: Any) -> None:
if payload == 100:
power._last = 900 # the in-flight ASensor.publish(900) completes

power.entity.send_state.side_effect = send_state # type: ignore[union-attr]

await ist.republish_states()

assert power.entity.send_state.await_args_list == [call(MQTT, 100), call(MQTT, 900)] # type: ignore[union-attr]
assert power.value == 900


async def test_republish_states_send_failure_does_not_stop_the_pass() -> None:
"""A failed send is logged and the other sensors are still re-sent."""
ist = _running_ist()
ist.ss["power"].entity.send_state.side_effect = ConnectionError("broker gone") # type: ignore[union-attr]

assert await ist.republish_states() == 1

ist.ss["energy"].entity.send_state.assert_awaited_once_with(MQTT, 12.3) # type: ignore[union-attr]


async def test_republish_all_states_every_running_inverter_every_pass(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Each pass re-sends the states of every running inverter, and only those."""
ist1, ist2 = _running_ist(), _running_ist()
ist2.lifecycle = "starting"
monkeypatch.setattr(a_inverter, "STATE", [ist1, ist2])

await republish_all_states(delays=(0, 0))

assert ist1.ss["power"].entity.send_state.await_count == 2 # type: ignore[union-attr]
assert ist1.ss["energy"].entity.send_state.await_count == 2 # type: ignore[union-attr]
ist2.ss["power"].entity.send_state.assert_not_awaited() # type: ignore[union-attr]


async def test_republish_all_states_default_delays_from_ha_online(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The passes run 10 s and 60 s after HA came online, not 60 s after pass one."""
clock = [1000.0]
sleeps: list[float] = []

async def fake_sleep(seconds: float) -> None:
sleeps.append(seconds)
clock[0] += seconds

async def pass_takes_2s() -> int:
clock[0] += 2
return 1

ist = _running_ist()
monkeypatch.setattr(ist, "republish_states", pass_takes_2s)
monkeypatch.setattr(a_inverter, "STATE", [ist])
monkeypatch.setattr(a_inverter, "asyncio", SimpleNamespace(sleep=fake_sleep))
monkeypatch.setattr(a_inverter, "time", SimpleNamespace(monotonic=lambda: clock[0]))

await republish_all_states()

assert sleeps == [10, 48]
Loading