From 7e1bce82c0acb831180a528056529d5bdbe798a0 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 21 Jun 2026 21:36:23 +0100 Subject: [PATCH] Re-send readonly state to hub --- apps/predbat/gateway.py | 17 +++++++++++++---- apps/predbat/tests/test_gateway.py | 21 +++++++++++++++++++++ 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index e50011443..a3185ba3a 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -210,6 +210,9 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt # Last read-only state sent to the gateway (None until the first send, # so the current state is always pushed once on startup) self._last_read_only = None + # Monotonic-ish timestamp of the last set_read_only send, used to force a + # periodic re-send so the gateway re-syncs even if a command was missed + self._last_read_only_sent_time = 0 # Set once the first MQTT connection attempt has completed (success or failure) self._first_connection_attempted = False @@ -1119,19 +1122,25 @@ async def _check_read_only_state(self): Called on every run() cycle. Sends the current state once on startup (when _last_read_only is still None) and again on each subsequent transition, so the gateway firmware always knows whether PredBat is permitted to control the - inverter. The command is gateway-wide, so it carries no inverter serial. Gated - only on an active MQTT connection — it does not depend on auto-config, so the - state is established as early as possible. + inverter. To guard against the gateway losing sync (the command is not retained, + so a single missed message would leave it stale), the state is also re-sent at + least every 30 minutes even when unchanged. The command is gateway-wide, so it + carries no inverter serial. Gated only on an active MQTT connection — it does not + depend on auto-config, so the state is established as early as possible. """ if not self._mqtt_connected: # Force a re-send after reconnect (command is not retained). self._last_read_only = None return read_only = bool(self.get_arg("set_read_only", False)) - if read_only == self._last_read_only: + now = time.time() + changed = read_only != self._last_read_only + stale = now - self._last_read_only_sent_time >= 30 * 60 + if not changed and not stale: return await self.publish_command("set_read_only", enable=read_only) self._last_read_only = read_only + self._last_read_only_sent_time = now self.log(f"Info: GatewayMQTT: set_read_only command sent (read_only={read_only})") async def _check_inverter_resets(self): diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index 7d6220d5d..da968ec5e 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -3001,6 +3001,7 @@ def _make_gateway(self, read_only=False, connected=True): gw.log = MagicMock() gw._mqtt_connected = connected gw._last_read_only = None + gw._last_read_only_sent_time = 0 gw._published = [] self._read_only = read_only @@ -3058,6 +3059,26 @@ def test_sends_on_change(self): assert gw._published[1] == ("set_read_only", {"enable": True}) assert gw._last_read_only is True + def test_resends_when_stale(self): + """An unchanged state is re-published once more than 30 minutes have elapsed.""" + gw = self._make_gateway(read_only=False) + self._run(gw._check_read_only_state()) + assert len(gw._published) == 1 + # Pretend the last send happened over 30 minutes ago. + gw._last_read_only_sent_time -= 30 * 60 + 1 + self._run(gw._check_read_only_state()) + assert len(gw._published) == 2 + assert gw._published[1] == ("set_read_only", {"enable": False}) + + def test_no_resend_just_under_interval(self): + """An unchanged state is not re-published before the 30 minute interval.""" + gw = self._make_gateway(read_only=False) + self._run(gw._check_read_only_state()) + # Just under the re-send interval — no extra publish expected. + gw._last_read_only_sent_time -= 30 * 60 - 5 + self._run(gw._check_read_only_state()) + assert len(gw._published) == 1 + def test_not_connected_skips_send(self): """Nothing is published while MQTT is disconnected, and state is not latched.""" gw = self._make_gateway(read_only=True, connected=False)