11"""Display-only gateway isolation and lifecycle regressions."""
22
33import asyncio
4+ import datetime
5+ import time
46from types import SimpleNamespace
57from 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