Skip to content
Merged
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
37 changes: 37 additions & 0 deletions custom_components/monitormysolar/coordinator.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,10 @@ def __init__(
self._setup_errors = [] # Track errors during setup
self._drop_dongle_id = entry.data.get("drop_dongle_id", False) # Optional: omit dongle id from entity_ids (single-dongle only)
self._snapshot_requested: Set[str] = set() # Dongles we've requested a full snapshot from this session
# Dongles currently doing an MQTT OTA update. While a dongle is in OTA
# mode it has no snapshot queue allocated, so a {"what":"all"} request
# crashes it into a reboot loop. Every snapshot path checks this set.
self._ota_in_progress: Set[str] = set()
self._dongle_availability: Dict[str, bool] = {} # Track LWT online/offline per dongle
self._dongle_boot_count: Dict[str, int] = {} # Last-seen boot.count per dongle (detect silent reboots)
# Recovery bookkeeping (monotonic seconds): last time we saw ANY message
Expand Down Expand Up @@ -172,13 +176,38 @@ def _needs_snapshot(self, version: str) -> bool:
return True
return parsed >= (4, 3, 0)

def set_ota_in_progress(self, dongle_id: str, in_progress: bool) -> None:
"""Mark a dongle as (no longer) running an OTA update.

While marked, all snapshot requests to it are suppressed: in OTA mode
the dongle has no snapshot queue, and a {"what":"all"} request sends it
into a reboot loop.
"""
# getattr: test coordinators are built via __new__ and skip __init__.
ota_set = getattr(self, "_ota_in_progress", None)
if ota_set is None:
ota_set = self._ota_in_progress = set()
if in_progress:
ota_set.add(dongle_id)
else:
ota_set.discard(dongle_id)

def is_ota_in_progress(self, dongle_id: str) -> bool:
"""Whether a dongle is currently running an OTA update."""
return dongle_id in getattr(self, "_ota_in_progress", ())

async def request_snapshot(self, dongle_id: str, version: str = "", force: bool = False) -> None:
"""Ask a dongle for a full /input + /hold snapshot (once per session).

Dongles on FW >= 4.3.0 only publish change-data, so without this the
entities stay 'unknown' until each value happens to change. Gated to fire
once per dongle per HA session unless force=True (e.g. a reconnect).
"""
if self.is_ota_in_progress(dongle_id):
LOGGER.info(
"Suppressing snapshot request for %s: OTA in progress", dongle_id
)
return
if not force and dongle_id in self._snapshot_requested:
return
if not self._needs_snapshot(version):
Expand All @@ -205,6 +234,14 @@ async def request_recovery_snapshot(self, dongle_id: str, reason: str) -> None:
or a data gap). Debounced so a burst of triggers (e.g. availability +
first status arriving together) only sends one request.
"""
if self.is_ota_in_progress(dongle_id):
# Don't burn the debounce window while suppressed — the post-OTA
# snapshot (or the next trigger after OTA ends) must not be swallowed.
LOGGER.debug(
"Suppressing recovery snapshot for %s (%s): OTA in progress",
dongle_id, reason,
)
return
now = time.monotonic()
last = self._last_recovery_snapshot.get(dongle_id, 0.0)
if now - last < self._recovery_snapshot_debounce:
Expand Down
2 changes: 1 addition & 1 deletion custom_components/monitormysolar/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,6 @@
"issue_tracker": "https://github.com/Monitor-My-Solar/monitormysolar",
"loggers": [],
"requirements": [],
"version": "4.0.0"
"version": "4.0.1"
}

16 changes: 16 additions & 0 deletions custom_components/monitormysolar/update.py
Original file line number Diff line number Diff line change
Expand Up @@ -307,6 +307,11 @@ def _on_result(msg):
self.hass, f"{self._dongle_id}/ota/result", _on_result
)

# Suppress snapshot ({"what":"all"}) requests for the whole OTA window.
# In OTA mode the dongle has no snapshot queue allocated, so the
# reconnect-triggered snapshot request would crash it into a reboot loop.
self.coordinator.set_ota_in_progress(self._dongle_id, True)

try:
await mqtt.async_publish(
self.hass, f"{self._dongle_id}/admin", json.dumps(command), qos=1
Expand Down Expand Up @@ -358,6 +363,17 @@ def _on_result(msg):
unsub_response()
unsub_progress()
unsub_result()
self.coordinator.set_ota_in_progress(self._dongle_id, False)
if ack["ok"]:
# Reconnect snapshot triggers (availability online, boot-count
# change, data-gap recovery) that fired mid-OTA were suppressed
# and won't refire, so refresh the dongle's state ourselves now
# that OTA mode is over. request_snapshot swallows publish errors.
await self.coordinator.request_snapshot(
self._dongle_id,
self.coordinator.current_fw_versions.get(self._dongle_id, ""),
force=True,
)
self._attr_in_progress = False
self._attr_progress = None
self.async_write_ha_state()
Expand Down
70 changes: 70 additions & 0 deletions tests/test_ota_snapshot_suppression.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
"""Tests for snapshot suppression during an MQTT OTA update.

While a dongle is in OTA mode it has no snapshot queue allocated, so a
{"what":"all"} request crashes it into a reboot loop. The coordinator must
suppress every snapshot path (bootstrap, forced, and recovery) while the
update entity has marked the dongle as mid-OTA, and must not burn the
recovery debounce window while suppressed so the post-OTA refresh still
goes through.
"""
import asyncio
from unittest.mock import MagicMock


def _run(coro):
return asyncio.get_event_loop().run_until_complete(coro)


def test_request_snapshot_suppressed_during_ota(coordinator, monkeypatch):
from custom_components.monitormysolar import coordinator as coord_mod

publishes = []

async def fake_publish(hass, topic, payload, **kwargs):
publishes.append((topic, payload))

monkeypatch.setattr(coord_mod.mqtt, "async_publish", fake_publish)
coordinator._snapshot_requested = set()

coordinator.set_ota_in_progress("dongle-A", True)
_run(coordinator.request_snapshot("dongle-A", "4.3.0.111S3", force=True))
assert publishes == []
# Suppression must not mark the snapshot as already-requested.
assert "dongle-A" not in coordinator._snapshot_requested

coordinator.set_ota_in_progress("dongle-A", False)
_run(coordinator.request_snapshot("dongle-A", "4.3.0.111S3", force=True))
assert publishes == [("dongle-A/snapshot/request", '{"what":"all"}')]


def test_recovery_snapshot_suppressed_and_debounce_not_burned(coordinator, monkeypatch):
calls = []

async def fake_request_snapshot(dongle_id, version="", force=False):
calls.append((dongle_id, force))

monkeypatch.setattr(coordinator, "request_snapshot", fake_request_snapshot)
coordinator.current_fw_versions = {}
coordinator._last_recovery_snapshot = {}
coordinator._recovery_snapshot_debounce = 30.0

coordinator.set_ota_in_progress("dongle-A", True)
# Reconnect trigger fires mid-OTA (e.g. availability 'online').
_run(coordinator.request_recovery_snapshot("dongle-A", "availability online"))
assert calls == []
# The debounce window must not have been consumed by the suppressed call.
assert coordinator._last_recovery_snapshot == {}

coordinator.set_ota_in_progress("dongle-A", False)
_run(coordinator.request_recovery_snapshot("dongle-A", "post-ota"))
assert calls == [("dongle-A", True)]


def test_ota_flag_is_per_dongle(coordinator):
coordinator.set_ota_in_progress("dongle-A", True)
assert coordinator.is_ota_in_progress("dongle-A")
assert not coordinator.is_ota_in_progress("dongle-B")
# Clearing an unset dongle is a no-op, not an error.
coordinator.set_ota_in_progress("dongle-B", False)
coordinator.set_ota_in_progress("dongle-A", False)
assert not coordinator.is_ota_in_progress("dongle-A")
Loading