Skip to content

Commit 08d96da

Browse files
committed
fix(gateway): separate coordinator staging from async publication failure
1 parent ac661f1 commit 08d96da

3 files changed

Lines changed: 35 additions & 8 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2644,12 +2644,22 @@ async def select_event(self, entity_id, value):
26442644
# Read current charge slot times to send both start and end
26452645
await self._update_charge_slot(entity_id, hhmm, serial=serial)
26462646

2647+
def charge_window_uses_staging(self, start_entity, end_entity):
2648+
"""Identify a coordinator synchronously, before crossing an async bridge."""
2649+
if not isinstance(start_entity, str) or not start_entity.endswith("_charge_slot1_start"):
2650+
return False
2651+
if end_entity != start_entity.removesuffix("_start") + "_end":
2652+
return False
2653+
serial = self._serial_from_entity_id(start_entity)
2654+
record = getattr(self, "_discovery", {}).get((serial or "").upper(), {})
2655+
return bool(serial) and record.get("type") in GATEWAY_COORDINATOR_TYPES
2656+
26472657
async def update_charge_window(self, start_entity, end_entity, start, end):
26482658
"""Send an engine charge window as one full-serial command, not two partial slots.
26492659
26502660
True means publication was attempted, not that the inverter applied it. The
26512661
caller must verify both endpoint entities, which only an applied ACK or
2652-
physical telemetry updates. None preserves the existing coordinator path:
2662+
physical telemetry updates. Coordinators use their existing separate path:
26532663
those devices can stage a disabled slot and apply it only on enable.
26542664
Manual single-endpoint selects keep their path.
26552665
"""
@@ -2662,9 +2672,8 @@ async def update_charge_window(self, start_entity, end_entity, start, end):
26622672
serial = self._serial_from_entity_id(start_entity)
26632673
if not serial or self._serial_from_entity_id(end_entity) != serial:
26642674
return False
2665-
record = getattr(self, "_discovery", {}).get(serial.upper(), {})
2666-
if record.get("type") in GATEWAY_COORDINATOR_TYPES:
2667-
return None
2675+
if self.charge_window_uses_staging(start_entity, end_entity):
2676+
return False
26682677
try:
26692678
times = [datetime.datetime.strptime(value, "%H:%M:%S") for value in (start, end)]
26702679
except (ValueError, TypeError):

‎apps/predbat/inverter.py‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3507,6 +3507,8 @@ def write_and_poll_gateway_charge_window(self, new_start, new_end):
35073507
return False
35083508
if not all(self.check_write_entity("write_and_poll_gateway_charge_window", name, entity, value) for name, entity, value in zip(names, entities, target)):
35093509
return False
3510+
if gateway.charge_window_uses_staging(*entities):
3511+
return None # Coordinator slot staging must precede its enable write.
35103512

35113513
def read_pair():
35123514
"""Read both endpoint entities without substituting desired values."""
@@ -3527,9 +3529,7 @@ def read_pair():
35273529
except Exception as error:
35283530
self.base.log(f"Warn: Inverter {self.id} Hub charge window publication failed: {error}")
35293531
break
3530-
if published is None:
3531-
return None # Coordinator slot staging must precede its enable write.
3532-
if not published:
3532+
if published is not True:
35333533
break
35343534
self._poll_after_write(entities[0], lambda state: state == new_start and self.base.get_state_wrapper(entities[1], refresh=True) == new_end)
35353535
verified = read_pair() == target

‎apps/predbat/tests/test_gateway_charge_window.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
"""Joined engine window, Hub JSON and applied-ACK regressions."""
2+
# cspell:ignore excepthook
23

34
import asyncio
45
import datetime
56
import json
67
import types
78
import unittest
8-
from unittest.mock import MagicMock
9+
from unittest.mock import MagicMock, patch
910

1011
from gateway import GatewayMQTT
1112
from inverter import Inverter
@@ -150,6 +151,23 @@ def test_success_records_both_controls_and_no_redundant_second_command(self):
150151
self.apply(inverter)
151152
self.assertEqual(len(wire), 1)
152153

154+
def test_async_timer_publication_exception_never_becomes_coordinator_fallback(self):
155+
"""RunThread returns None on error; that must remain a failed direct write."""
156+
inverter, gateway, base, wire = self.fixture(outcome="exception")
157+
158+
async def timer():
159+
"""Match the standalone timer calling synchronous execution on its loop."""
160+
self.apply(inverter)
161+
162+
with patch("threading.excepthook") as thread_error:
163+
asyncio.run(timer())
164+
thread_error.assert_called_once()
165+
self.assertEqual(len(wire), 1)
166+
self.assertTrue(base.errors)
167+
inverter.write_and_poll_option.assert_not_called()
168+
inverter.write_and_poll_switch.assert_not_called()
169+
self.assertFalse(any("Updated start and end" in line for line in base.logs))
170+
153171
def test_read_only_or_mismatched_entity_ownership_refuses_without_publication(self):
154172
"""Never route a Cloud endpoint or user read-only window to the Hub."""
155173
for reason in ("read_only", "owner", "serial"):

0 commit comments

Comments
 (0)