Skip to content

Commit f0d87fb

Browse files
committed
fix(gateway): start optional displays without waiting for telemetry
1 parent 1ff102e commit f0d87fb

2 files changed

Lines changed: 76 additions & 6 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -650,19 +650,20 @@ async def run(self, seconds, first):
650650
# Start MQTT listener as a background task
651651
self._mqtt_task = asyncio.ensure_future(self._mqtt_loop())
652652
self.log("Info: GatewayMQTT: MQTT listener task started")
653+
if self.gateway_display_only:
654+
# The selected inverter provider must not wait for an optional
655+
# display's broker connection or first telemetry. Component
656+
# startup and actual display availability are separate states.
657+
self.api_started = True
658+
self._update_display_health()
659+
return True
653660
# Wait for the first connection attempt and then for the first telemetry →
654661
# auto-config, so the inverter args are wired up before startup continues.
655662
# Both waits share the single _STARTUP_WAIT_TICKS budget so an offline gateway
656663
# device can never stall startup for longer than that.
657664
ticks = await self._startup_wait(0, lambda: self._first_connection_attempted)
658665
if not self._first_connection_attempted and not self.api_stop:
659666
self.log("Warn: GatewayMQTT: First connection attempt not yet complete, continuing startup")
660-
if self.gateway_display_only:
661-
await self._startup_wait(ticks, lambda: self.api_started)
662-
if not self.api_started and not self.api_stop:
663-
self.log("Warn: GatewayMQTT: No display telemetry received, continuing startup")
664-
self._update_display_health()
665-
return True
666667
# The auto-config wait runs even when the first attempt failed: the MQTT loop
667668
# retries with backoff in the background, so a transient broker failure at
668669
# startup usually still yields telemetry inside the window. Returning early

‎apps/predbat/tests/test_gateway_display.py‎

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,75 @@ def test_display_outage_preserves_cloud_run_and_ping(self):
209209
assert not comps.is_all_alive()
210210
assert asyncio.run(web.html_api_ping(None)).status == 500
211211

212+
def test_offline_display_startup_does_not_wait_or_fail_cloud_health(self):
213+
"""An optional display starts before its first successful MQTT connection."""
214+
from component_base import ComponentBase
215+
from components import Components
216+
from predbat import PredBat
217+
from web import WebInterface
218+
219+
gw = self._make_gateway()
220+
gw._mqtt_connected = False
221+
gw._load_discovery = AsyncMock()
222+
gw._mqtt_loop = AsyncMock()
223+
gw._startup_wait = AsyncMock(return_value=0)
224+
gw.update_success_timestamp = lambda: ComponentBase.update_success_timestamp(gw)
225+
comps = Components.__new__(Components)
226+
comps.components = {"gateway": gw}
227+
comps.component_tasks = {"gateway": SimpleNamespace(is_alive=lambda: True)}
228+
comps.component_errors = {}
229+
base = gw.base
230+
base.components = comps
231+
base.prefix = "predbat"
232+
base.stop_thread = False
233+
base.fatal_error = False
234+
base.had_errors = False
235+
base.get_state_wrapper.side_effect = lambda entity, attribute=None, default=None: False if attribute == "error" else datetime.datetime.now(datetime.timezone.utc).isoformat()
236+
base.is_running.side_effect = lambda: PredBat.is_running(base)
237+
web = WebInterface.__new__(WebInterface)
238+
web.base = base
239+
web.log = base.log
240+
241+
async def exercise():
242+
try:
243+
assert await gw.run(0, True)
244+
assert gw.api_started
245+
gw._startup_wait.assert_not_awaited()
246+
assert comps.is_all_alive()
247+
assert (await web.html_api_ping(None)).status == 200
248+
PredBat.record_final_run_status(base, "Charging", "")
249+
assert base.record_status.call_args.args[0] == "Charging"
250+
assert not gw._auto_configured
251+
assert gw._last_status is None
252+
display_call = next(call for call in reversed(base.dashboard_item.call_args_list) if call.args[0] == "binary_sensor.predbat_gateway_display_online")
253+
assert display_call.args[1] == "off"
254+
finally:
255+
gw._mqtt_task.cancel()
256+
await asyncio.gather(gw._mqtt_task, return_exceptions=True)
257+
258+
with patch("gateway.HAS_AIOMQTT", True):
259+
asyncio.run(exercise())
260+
261+
def test_control_startup_still_waits_for_connection_and_configuration(self):
262+
"""The optional-display fast path cannot relax real inverter startup."""
263+
gw = self._make_gateway(False)
264+
gw._load_discovery = AsyncMock()
265+
gw._mqtt_loop = AsyncMock()
266+
gw._startup_wait = AsyncMock(return_value=0)
267+
268+
async def exercise():
269+
try:
270+
assert await gw.run(0, True)
271+
assert gw._startup_wait.await_count == 2
272+
assert not gw.api_started
273+
assert not gw._auto_configured
274+
finally:
275+
gw._mqtt_task.cancel()
276+
await asyncio.gather(gw._mqtt_task, return_exceptions=True)
277+
278+
with patch("gateway.HAS_AIOMQTT", True):
279+
asyncio.run(exercise())
280+
212281
def test_control_transport_outage_still_fails_health(self):
213282
"""Optional display health cannot relax the active controller's checks."""
214283
gw = self._make_gateway(False)

0 commit comments

Comments
 (0)