Skip to content

Commit a6f4528

Browse files
committed
fix(gateway): isolate optional display connection health
1 parent 68e7ee5 commit a6f4528

2 files changed

Lines changed: 87 additions & 0 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -661,6 +661,7 @@ async def run(self, seconds, first):
661661
await self._startup_wait(ticks, lambda: self.api_started)
662662
if not self.api_started and not self.api_stop:
663663
self.log("Warn: GatewayMQTT: No display telemetry received, continuing startup")
664+
self._update_display_health()
664665
return True
665666
# The auto-config wait runs even when the first attempt failed: the MQTT loop
666667
# retries with backoff in the background, so a transient broker failure at
@@ -688,6 +689,7 @@ async def run(self, seconds, first):
688689
if self._mqtt_connected and self._last_status is not None:
689690
await self._publish_predbat_data()
690691
await self._check_token_refresh()
692+
self._update_display_health()
691693
return True
692694

693695
# Publish any queued plan from on_plan_executed hook
@@ -2623,16 +2625,34 @@ async def _publish_raw(self, topic, payload, retain=False):
26232625
else:
26242626
await self._mqtt_client.publish(topic, payload, qos=1, retain=retain)
26252627

2628+
def _update_display_health(self):
2629+
"""Report optional display connectivity separately from the component loop's health."""
2630+
online = self._mqtt_connected and self._gateway_online and self._last_telemetry_time > 0 and time.time() - self._last_telemetry_time < _TELEMETRY_STALE_THRESHOLD
2631+
self.dashboard_item(
2632+
f"binary_sensor.{self.prefix}_gateway_display_online",
2633+
state="on" if online else "off",
2634+
attributes={"friendly_name": "PredBat Hub display online", "device_class": "connectivity"},
2635+
)
2636+
# A disconnected desk display cannot make the working inverter provider
2637+
# fail its run or liveness probe. Components still checks the owning
2638+
# thread and timestamp; a stopped component thread remains unhealthy.
2639+
self.update_success_timestamp()
2640+
26262641
def is_alive(self):
26272642
"""Check if the gateway component is alive and receiving data.
26282643
26292644
Returns True when MQTT is connected AND either the gateway is
26302645
offline (LWT says so — we're still connected, just no data) OR
26312646
we've received telemetry within the last 2 minutes.
26322647
2648+
In display mode only the component must be started: optional transport
2649+
availability is reported separately by _update_display_health().
2650+
26332651
Returns:
26342652
bool: True if healthy, False otherwise.
26352653
"""
2654+
if self.gateway_display_only:
2655+
return self.api_started
26362656
if not self._mqtt_connected:
26372657
return False
26382658

‎apps/predbat/tests/test_gateway_display.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Display-only gateway isolation and lifecycle regressions."""
22

33
import asyncio
4+
import datetime
5+
import time
46
from types import SimpleNamespace
57
from unittest.mock import AsyncMock, MagicMock, patch
68

@@ -15,6 +17,7 @@ def _make_gateway(self, display=True):
1517
"""Build the real gateway state with an in-memory MQTT client."""
1618
gw = GatewayMQTT.__new__(GatewayMQTT)
1719
gw.base = MagicMock()
20+
gw.prefix = "predbat"
1821
gw.args = {}
1922
gw.log = MagicMock()
2023
gw.initialize(gateway_device_id="pbgw_test", mqtt_host="mqtt.test", gateway_display_only=display)
@@ -152,3 +155,67 @@ def test_control_mode_can_still_publish(self):
152155
gw = self._make_gateway(False)
153156
asyncio.run(gw._publish_raw(gw.topic_command, b"control"))
154157
gw._mqtt_client.publish.assert_awaited_once()
158+
159+
def test_display_outage_preserves_cloud_run_and_ping(self):
160+
"""Connection loss is visible without failing a healthy Cloud run or liveness probe."""
161+
from component_base import ComponentBase
162+
from components import Components
163+
from predbat import PredBat
164+
from web import WebInterface
165+
166+
gw = self._make_gateway()
167+
gw.api_started = True
168+
gw.update_success_timestamp = lambda: ComponentBase.update_success_timestamp(gw)
169+
gw._check_token_refresh = AsyncMock()
170+
comps = Components.__new__(Components)
171+
comps.components = {"gateway": gw}
172+
comps.component_tasks = {"gateway": SimpleNamespace(is_alive=lambda: True)}
173+
comps.component_errors = {}
174+
base = gw.base
175+
base.components = comps
176+
base.prefix = "predbat"
177+
base.stop_thread = False
178+
base.fatal_error = False
179+
base.had_errors = False
180+
base.get_state_wrapper.side_effect = lambda entity, attribute=None, default=None: False if attribute == "error" else datetime.datetime.now(datetime.timezone.utc).isoformat()
181+
base.is_running.side_effect = lambda: PredBat.is_running(base)
182+
web = WebInterface.__new__(WebInterface)
183+
web.base = base
184+
web.log = base.log
185+
186+
# A disconnected broker, an offline Hub and an online-but-stale Hub are
187+
# all display outages. The selected Cloud provider remains healthy.
188+
for connected, online in ((False, False), (True, False), (True, True)):
189+
gw._mqtt_connected = connected
190+
gw._gateway_online = online
191+
gw._last_telemetry_time = time.time() - 7200
192+
gw.last_success_timestamp = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=2)
193+
with patch("gateway.HAS_AIOMQTT", True):
194+
assert asyncio.run(gw.run(60, False))
195+
assert comps.is_all_alive()
196+
assert asyncio.run(web.html_api_ping(None)).status == 200
197+
PredBat.record_final_run_status(base, "Charging", "")
198+
assert base.record_status.call_args.args[0] == "Charging"
199+
display_call = next(call for call in reversed(base.dashboard_item.call_args_list) if call.args[0] == "binary_sensor.predbat_gateway_display_online")
200+
assert display_call.args[1] == "off"
201+
202+
gw._mqtt_connected = True
203+
gw._gateway_online = True
204+
gw._last_telemetry_time = time.time()
205+
gw._update_display_health()
206+
assert base.dashboard_item.call_args.args[1] == "on"
207+
# A stopped component thread remains a real process-health failure.
208+
comps.component_tasks["gateway"] = SimpleNamespace(is_alive=lambda: False)
209+
assert not comps.is_all_alive()
210+
assert asyncio.run(web.html_api_ping(None)).status == 500
211+
212+
def test_control_transport_outage_still_fails_health(self):
213+
"""Optional display health cannot relax the active controller's checks."""
214+
gw = self._make_gateway(False)
215+
gw.api_started = True
216+
gw._mqtt_connected = False
217+
assert not gw.is_alive()
218+
gw._mqtt_connected = True
219+
gw._gateway_online = True
220+
gw._last_telemetry_time = time.time() - 121
221+
assert not gw.is_alive()

0 commit comments

Comments
 (0)