From 68e7ee51f31a9886c4e458abd2fd754f5c332a7d Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Sun, 11 Oct 2026 19:50:49 +0100 Subject: [PATCH 1/4] feat(gateway): add isolated display-only mode --- apps/predbat/components.py | 7 +- apps/predbat/gateway.py | 57 +++++++- apps/predbat/tests/test_gateway.py | 2 + apps/predbat/tests/test_gateway_display.py | 154 +++++++++++++++++++++ docs/apps-yaml.md | 6 + 5 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 apps/predbat/tests/test_gateway_display.py diff --git a/apps/predbat/components.py b/apps/predbat/components.py index 7c4a6db9d..aa9d626c6 100644 --- a/apps/predbat/components.py +++ b/apps/predbat/components.py @@ -726,6 +726,7 @@ def load_component_class(component_info): "mqtt_port": {"required": False, "config": "gateway_mqtt_port", "default": 8883}, "mqtt_token": {"required": True, "secret": True, "config": "gateway_mqtt_token"}, "gateway_inverter_serial": {"required": False, "config": "gateway_inverter_serial", "default": None}, + "gateway_display_only": {"required": False, "config": "gateway_display_only", "default": False}, "gateway_evc_automatic": {"required": False, "config": "gateway_evc_automatic", "default": False}, "gateway_evc_control": {"required": False, "config": "gateway_evc_control", "default": False}, "gateway_shared_ct": {"required": False, "config": "gateway_shared_ct", "default": True}, @@ -1038,11 +1039,11 @@ def inverter_source_active(self): question rather than a per-inverter one: a component serves whichever inverters it discovered, and Inverter only needs to know whether to keep waiting. """ - return any(COMPONENT_LIST.get(name, {}).get("inverter", False) and component for name, component in self.components.items()) + return any(COMPONENT_LIST.get(name, {}).get("inverter", False) and component and getattr(component, "gateway_display_only", False) is not True for name, component in self.components.items()) def inverter_source_names(self): """The display names of every active inverter component, for user-facing messages.""" - return [COMPONENT_LIST[name]["name"] for name, component in self.components.items() if component and COMPONENT_LIST.get(name, {}).get("inverter", False)] + return [COMPONENT_LIST[name]["name"] for name, component in self.components.items() if component and COMPONENT_LIST.get(name, {}).get("inverter", False) and getattr(component, "gateway_display_only", False) is not True] def inverter_source_status(self): """ @@ -1075,6 +1076,8 @@ def inverter_source_status(self): continue load_error = self.load_error(name) component = self.components.get(name, None) + if component and getattr(component, "gateway_display_only", False) is True: + continue if load_error is not None: state = "failed to start: {}".format(load_error) elif not component: diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index d561501d4..2db998551 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -231,6 +231,7 @@ class GatewayMQTT(ComponentBase): gateway_shared_ct = True gateway_integrate_power = False gateway_inverter_serial = () + gateway_display_only = False _integrated_energy = None _unavailable_inverters = ((), ()) _discovery = {} # replaced, never changed in place, so this default is not shared state @@ -244,11 +245,26 @@ class GatewayMQTT(ComponentBase): _reserve_before_zero = {} # replaced, never changed in place gateway_device_id = None - def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqtt_token=None, gateway_inverter_serial=None, gateway_evc_automatic=False, gateway_evc_control=False, gateway_shared_ct=True, gateway_integrate_power=False, **kwargs): + def initialize( + self, + gateway_device_id=None, + mqtt_host=None, + mqtt_port=8883, + mqtt_token=None, + gateway_inverter_serial=None, + gateway_evc_automatic=False, + gateway_evc_control=False, + gateway_shared_ct=True, + gateway_integrate_power=False, + gateway_display_only=False, + **kwargs, + ): """Initialize gateway configuration and build MQTT topic strings. Args: gateway_device_id: The gateway's device ID (e.g. "pbgw_abc123"). + gateway_display_only: Publish display data without registering or controlling any inverter + or EV charger. The hub must already be parked by its standby controller. mqtt_host: MQTT broker hostname. mqtt_port: MQTT broker port (default 8883 for TLS). mqtt_token: JWT access token for MQTT authentication. @@ -270,6 +286,14 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt **kwargs: Additional keyword arguments (ignored). """ self.gateway_device_id = gateway_device_id + if isinstance(gateway_display_only, str): + value = gateway_display_only.strip().lower() + if value not in ("true", "false", "1", "0", "yes", "no", "on", "off"): + raise ValueError("gateway_display_only must be a boolean") + gateway_display_only = value in ("true", "1", "yes", "on") + if not isinstance(gateway_display_only, bool): + raise ValueError("gateway_display_only must be a boolean") + self.gateway_display_only = gateway_display_only self.gateway_evc_automatic = bool(gateway_evc_automatic) self.gateway_evc_control = bool(gateway_evc_control) self.gateway_shared_ct = bool(gateway_shared_ct) @@ -415,6 +439,8 @@ def _on_plan_executed(self, charge_windows=None, charge_limits=None, export_wind the optimizer's charge/export windows into ExecutionPlan entries and queues them for MQTT publishing on the next run() cycle. """ + if self.gateway_display_only: + return plan_entries = [] # Reserve as a percentage — used to detect freeze charge (charge limit == reserve) @@ -631,6 +657,11 @@ async def run(self, seconds, first): ticks = await self._startup_wait(0, lambda: self._first_connection_attempted) if not self._first_connection_attempted and not self.api_stop: self.log("Warn: GatewayMQTT: First connection attempt not yet complete, continuing startup") + if self.gateway_display_only: + await self._startup_wait(ticks, lambda: self.api_started) + if not self.api_started and not self.api_stop: + self.log("Warn: GatewayMQTT: No display telemetry received, continuing startup") + return True # The auto-config wait runs even when the first attempt failed: the MQTT loop # retries with backoff in the background, so a transient broker failure at # startup usually still yields telemetry inside the window. Returning early @@ -651,6 +682,14 @@ async def run(self, seconds, first): self.log("Info: GatewayMQTT: Restarting MQTT listener task") self._mqtt_task = asyncio.ensure_future(self._mqtt_loop()) + if self.gateway_display_only: + # The JSON display topic is separate from the executable schedule. Never + # refresh schedules, release read-only, reset inverters or control EVs here. + if self._mqtt_connected and self._last_status is not None: + await self._publish_predbat_data() + await self._check_token_refresh() + return True + # Publish any queued plan from on_plan_executed hook if self._pending_plan: plan_entries, tz = self._pending_plan @@ -859,6 +898,15 @@ def _process_telemetry(self, data): self._debug_dump("RX telemetry", status, raw=data) + if self.gateway_display_only: + # A desk display need not discover an inverter. Do not inject the hub's + # inverter entities or register hardware against the selected provider. + self._last_status = status + self._last_telemetry_time = time.time() + self.update_success_timestamp() + self.api_started = True + return + if len(status.inverters) == 0: if not (self.gateway_inverter_serial and self._discovery): return @@ -1807,6 +1855,8 @@ def automatic_config(self): (see ``_needs_reconfigure``). Maps proto fields to PredBat args so the plan engine has data to work with. """ + if self.gateway_display_only: + return if not self._last_status: self.log("Error: GatewayMQTT: automatic_config called with no status data") return @@ -2469,6 +2519,8 @@ async def publish_plan(self, plan_entries, timezone_str): plan_entries: List of plan entry dicts. timezone_str: IANA timezone string (e.g. "Europe/London"). """ + if self.gateway_display_only: + return if not self._plan_changed(plan_entries): return # No change, skip publish @@ -2556,6 +2608,9 @@ async def _publish_raw(self, topic, payload, retain=False): payload: Bytes to publish. retain: Whether to set the retain flag. """ + if self.gateway_display_only and topic != f"{self._topic_base}/predbat_data": + self.log(f"Warn: GatewayMQTT: Display mode blocked publish to {topic}") + return if not (self._mqtt_client and self._mqtt_connected): return diff --git a/apps/predbat/tests/test_gateway.py b/apps/predbat/tests/test_gateway.py index f83d2b08b..c465d72a7 100644 --- a/apps/predbat/tests/test_gateway.py +++ b/apps/predbat/tests/test_gateway.py @@ -7404,8 +7404,10 @@ def test_counters_restart_at_local_midnight(self): def run_gateway_tests(my_predbat=None): """Run all GatewayMQTT tests. Returns True on failure, False on success.""" from tests.test_gateway_token_refresh import TestIsAuthFailure, TestApplyRefreshResponse, TestMaybeRefreshOnAuthError + from tests.test_gateway_display import TestGatewayDisplayOnly test_classes = [ + TestGatewayDisplayOnly, TestProtobufDecode, TestPlanSerialization, TestCommandFormat, diff --git a/apps/predbat/tests/test_gateway_display.py b/apps/predbat/tests/test_gateway_display.py new file mode 100644 index 000000000..b9d4f8ead --- /dev/null +++ b/apps/predbat/tests/test_gateway_display.py @@ -0,0 +1,154 @@ +"""Display-only gateway isolation and lifecycle regressions.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import gateway_status_pb2 as pb +from gateway import GatewayMQTT + + +class TestGatewayDisplayOnly: + """A display cannot become an inverter source or publish control messages.""" + + def _make_gateway(self, display=True): + """Build the real gateway state with an in-memory MQTT client.""" + gw = GatewayMQTT.__new__(GatewayMQTT) + gw.base = MagicMock() + gw.args = {} + gw.log = MagicMock() + gw.initialize(gateway_device_id="pbgw_test", mqtt_host="mqtt.test", gateway_display_only=display) + gw._mqtt_connected = True + gw._mqtt_client = SimpleNamespace(publish=AsyncMock()) + gw.api_started = False + gw.api_stop = False + gw.update_success_timestamp = MagicMock() + return gw + + def test_zero_inverters_is_a_ready_display(self): + """A status with no inverter completes display startup without changing control.""" + gw = self._make_gateway() + gw.automatic_config = MagicMock() + gw._inject_entities = MagicMock() + status = pb.GatewayStatus(device_id="pbgw_test") + gw._process_telemetry(status.SerializeToString()) + assert gw.api_started + assert not gw._auto_configured + gw.automatic_config.assert_not_called() + gw._inject_entities.assert_not_called() + gw.update_success_timestamp.assert_called_once() + + def test_discovered_inverter_cannot_replace_cloud(self): + """Even full telemetry never registers inverters or EVs in display mode.""" + gw = self._make_gateway() + gw.set_arg = MagicMock() + status = pb.GatewayStatus(device_id="pbgw_test") + status.inverters.add(serial="CE12345678", connected=True) + status.ev_chargers.add(charge_point_id="charger") + gw._process_telemetry(status.SerializeToString()) + gw.automatic_config() + gw.set_arg.assert_not_called() + assert not gw._auto_configured + assert not gw._configured_ev_chargers + + def test_invalid_telemetry_does_not_start_display(self): + """A corrupt message must not satisfy the readiness condition.""" + gw = self._make_gateway() + gw._process_telemetry(b"\xff") + assert not gw.api_started + assert gw._last_status is None + + def test_display_only_allows_display_topic(self): + """The final MQTT boundary blocks all command and schedule paths.""" + gw = self._make_gateway() + + async def exercise(): + for suffix in ("command", "ev/command", "schedule", "unknown"): + await gw._publish_raw(f"{gw._topic_base}/{suffix}", b"control", retain=True) + await gw._publish_raw(f"{gw._topic_base}/predbat_data", b"display", retain=True) + + asyncio.run(exercise()) + gw._mqtt_client.publish.assert_awaited_once_with(f"{gw._topic_base}/predbat_data", b"display", qos=1, retain=True) + + def test_public_control_entry_points_cannot_send(self): + """Direct callers cannot bypass display mode through control helpers.""" + gw = self._make_gateway() + + async def exercise(): + await gw.publish_command("set_read_only", enable=False) + await gw._send_ev_command({"action": "RemoteStartTransaction"}) + await gw.publish_plan([], "Europe/London") + + asyncio.run(exercise()) + gw._mqtt_client.publish.assert_not_awaited() + assert gw._last_plan_data is None + + def test_plan_hook_does_not_queue_execution(self): + """Display plans travel through JSON, never the autonomous schedule hook.""" + gw = self._make_gateway() + gw._on_plan_executed() + assert gw._pending_plan is None + + def test_display_housekeeping_never_releases_standby(self): + """An online display publishes JSON and refreshes auth, without control housekeeping.""" + gw = self._make_gateway() + gw._last_status = pb.GatewayStatus(device_id="pbgw_test") + gw._publish_predbat_data = AsyncMock() + gw._check_token_refresh = AsyncMock() + gw._check_read_only_state = AsyncMock() + gw._check_inverter_resets = AsyncMock() + gw._check_rate_caps = AsyncMock() + gw._republish_plan_if_stale = AsyncMock() + with patch("gateway.HAS_AIOMQTT", True): + assert asyncio.run(gw.run(60, False)) + gw._publish_predbat_data.assert_awaited_once() + gw._check_token_refresh.assert_awaited_once() + for method in (gw._check_read_only_state, gw._check_inverter_resets, gw._check_rate_caps, gw._republish_plan_if_stale): + method.assert_not_awaited() + + def test_display_startup_does_not_wait_for_auto_config(self): + """Display startup accepts zero-inverter telemetry, using the same bounded budget.""" + gw = self._make_gateway() + gw.api_started = True + gw._first_connection_attempted = True + gw._startup_wait = AsyncMock(side_effect=lambda ticks, ready: ticks if ready() else 999) + gw._mqtt_loop = AsyncMock() + with patch("gateway.HAS_AIOMQTT", True): + assert asyncio.run(gw.run(0, True)) + assert all(call.args[1]() for call in gw._startup_wait.call_args_list) + assert not gw._auto_configured + + def test_display_is_not_an_inverter_source(self): + """A display cannot make the engine wait for battery readings it will never supply.""" + from components import Components + + comps = Components.__new__(Components) + comps.components = {"gateway": self._make_gateway()} + comps.load_error = lambda name: None + comps.is_alive = lambda name: True + assert not comps.inverter_source_active() + assert comps.inverter_source_names() == [] + assert comps.inverter_source_status() == [] + comps.components["gateway"].gateway_display_only = False + assert comps.inverter_source_active() + assert comps.inverter_source_names() == ["PredBat Gateway"] + assert comps.inverter_source_status() == ["PredBat Gateway (OK)"] + + def test_false_string_keeps_existing_control_mode(self): + """String config values are parsed rather than treated as truthy strings.""" + assert not self._make_gateway("false").gateway_display_only + assert self._make_gateway("true").gateway_display_only + + def test_invalid_display_flag_fails_closed(self): + """An invalid role value must not silently enable inverter control.""" + try: + self._make_gateway("typo") + except ValueError: + return + raise AssertionError("Invalid display flag was accepted") + + def test_control_mode_can_still_publish(self): + """The opt-in mode does not block the existing control transport.""" + gw = self._make_gateway(False) + asyncio.run(gw._publish_raw(gw.topic_command, b"control")) + gw._mqtt_client.publish.assert_awaited_once() diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c87acc0cf..173b19cdc 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -615,6 +615,12 @@ See also **ge_cloud_automatic_split_ct** which takes priority over this setting Use this to override automatic shared-CT detection if Predbat incorrectly identifies your system as sharing a CT clamp (e.g. when duplicate meter serials are reported by the cloud API but the inverters actually have separate CT clamps). This setting takes priority over **ge_cloud_automatic_shared_ct** if both are set. +- **gateway_display_only** - Optional, defaults to false. Set to `true` to send prices, costs and the plan timeline to a paired Hub while another integration controls the battery. +The Hub does not need a discovered inverter. This mode does not configure inverter or EV charger entities, change the selected inverter integration, or publish commands or executable schedules. +Keep the Hub in enforced standby (including passive mode on supported firmware) before enabling this option or handing control to another integration. The option only prevents this Predbat component from sending writes; it cannot stop an old schedule or autonomous fallback already running on the Hub. +The existing firmware still needs a local inverter connection for live power flow and battery readings; cloud-fed live readings and pairing without inverter discovery are separate changes. +This is a display option, not automatic backup switching. + - **gateway_shared_ct** - Optional, defaults to true. The equivalent of **ge_cloud_automatic_shared_ct** for inverters controlled through the Predbat Gateway. When `true`, Predbat treats multiple inverters as sharing a single physical CT clamp for grid and load measurement, so only the first inverter's grid and load power readings are used (the rest are zeroed out), preventing double-counting of grid import/export and house load. From a6f452836662b2ff0c0c9a95502baa1a768e5e18 Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 12 Oct 2026 07:25:58 +0100 Subject: [PATCH 2/4] fix(gateway): isolate optional display connection health --- apps/predbat/gateway.py | 20 +++++++ apps/predbat/tests/test_gateway_display.py | 67 ++++++++++++++++++++++ 2 files changed, 87 insertions(+) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index 2db998551..813db28df 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -661,6 +661,7 @@ async def run(self, seconds, first): await self._startup_wait(ticks, lambda: self.api_started) if not self.api_started and not self.api_stop: self.log("Warn: GatewayMQTT: No display telemetry received, continuing startup") + self._update_display_health() return True # The auto-config wait runs even when the first attempt failed: the MQTT loop # retries with backoff in the background, so a transient broker failure at @@ -688,6 +689,7 @@ async def run(self, seconds, first): if self._mqtt_connected and self._last_status is not None: await self._publish_predbat_data() await self._check_token_refresh() + self._update_display_health() return True # Publish any queued plan from on_plan_executed hook @@ -2623,6 +2625,19 @@ async def _publish_raw(self, topic, payload, retain=False): else: await self._mqtt_client.publish(topic, payload, qos=1, retain=retain) + def _update_display_health(self): + """Report optional display connectivity separately from the component loop's health.""" + online = self._mqtt_connected and self._gateway_online and self._last_telemetry_time > 0 and time.time() - self._last_telemetry_time < _TELEMETRY_STALE_THRESHOLD + self.dashboard_item( + f"binary_sensor.{self.prefix}_gateway_display_online", + state="on" if online else "off", + attributes={"friendly_name": "PredBat Hub display online", "device_class": "connectivity"}, + ) + # A disconnected desk display cannot make the working inverter provider + # fail its run or liveness probe. Components still checks the owning + # thread and timestamp; a stopped component thread remains unhealthy. + self.update_success_timestamp() + def is_alive(self): """Check if the gateway component is alive and receiving data. @@ -2630,9 +2645,14 @@ def is_alive(self): offline (LWT says so — we're still connected, just no data) OR we've received telemetry within the last 2 minutes. + In display mode only the component must be started: optional transport + availability is reported separately by _update_display_health(). + Returns: bool: True if healthy, False otherwise. """ + if self.gateway_display_only: + return self.api_started if not self._mqtt_connected: return False diff --git a/apps/predbat/tests/test_gateway_display.py b/apps/predbat/tests/test_gateway_display.py index b9d4f8ead..9a7e5df3d 100644 --- a/apps/predbat/tests/test_gateway_display.py +++ b/apps/predbat/tests/test_gateway_display.py @@ -1,6 +1,8 @@ """Display-only gateway isolation and lifecycle regressions.""" import asyncio +import datetime +import time from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch @@ -15,6 +17,7 @@ def _make_gateway(self, display=True): """Build the real gateway state with an in-memory MQTT client.""" gw = GatewayMQTT.__new__(GatewayMQTT) gw.base = MagicMock() + gw.prefix = "predbat" gw.args = {} gw.log = MagicMock() 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): gw = self._make_gateway(False) asyncio.run(gw._publish_raw(gw.topic_command, b"control")) gw._mqtt_client.publish.assert_awaited_once() + + def test_display_outage_preserves_cloud_run_and_ping(self): + """Connection loss is visible without failing a healthy Cloud run or liveness probe.""" + from component_base import ComponentBase + from components import Components + from predbat import PredBat + from web import WebInterface + + gw = self._make_gateway() + gw.api_started = True + gw.update_success_timestamp = lambda: ComponentBase.update_success_timestamp(gw) + gw._check_token_refresh = AsyncMock() + comps = Components.__new__(Components) + comps.components = {"gateway": gw} + comps.component_tasks = {"gateway": SimpleNamespace(is_alive=lambda: True)} + comps.component_errors = {} + base = gw.base + base.components = comps + base.prefix = "predbat" + base.stop_thread = False + base.fatal_error = False + base.had_errors = False + base.get_state_wrapper.side_effect = lambda entity, attribute=None, default=None: False if attribute == "error" else datetime.datetime.now(datetime.timezone.utc).isoformat() + base.is_running.side_effect = lambda: PredBat.is_running(base) + web = WebInterface.__new__(WebInterface) + web.base = base + web.log = base.log + + # A disconnected broker, an offline Hub and an online-but-stale Hub are + # all display outages. The selected Cloud provider remains healthy. + for connected, online in ((False, False), (True, False), (True, True)): + gw._mqtt_connected = connected + gw._gateway_online = online + gw._last_telemetry_time = time.time() - 7200 + gw.last_success_timestamp = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(hours=2) + with patch("gateway.HAS_AIOMQTT", True): + assert asyncio.run(gw.run(60, False)) + assert comps.is_all_alive() + assert asyncio.run(web.html_api_ping(None)).status == 200 + PredBat.record_final_run_status(base, "Charging", "") + assert base.record_status.call_args.args[0] == "Charging" + display_call = next(call for call in reversed(base.dashboard_item.call_args_list) if call.args[0] == "binary_sensor.predbat_gateway_display_online") + assert display_call.args[1] == "off" + + gw._mqtt_connected = True + gw._gateway_online = True + gw._last_telemetry_time = time.time() + gw._update_display_health() + assert base.dashboard_item.call_args.args[1] == "on" + # A stopped component thread remains a real process-health failure. + comps.component_tasks["gateway"] = SimpleNamespace(is_alive=lambda: False) + assert not comps.is_all_alive() + assert asyncio.run(web.html_api_ping(None)).status == 500 + + def test_control_transport_outage_still_fails_health(self): + """Optional display health cannot relax the active controller's checks.""" + gw = self._make_gateway(False) + gw.api_started = True + gw._mqtt_connected = False + assert not gw.is_alive() + gw._mqtt_connected = True + gw._gateway_online = True + gw._last_telemetry_time = time.time() - 121 + assert not gw.is_alive() From 1ff102ea20e24e780552d79e4ffb2d3b4b02e514 Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Mon, 12 Oct 2026 08:16:10 +0100 Subject: [PATCH 3/4] Add app attribute to gateway display state update Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/gateway.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index 813db28df..d8a4c1e5a 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -2632,6 +2632,7 @@ def _update_display_health(self): f"binary_sensor.{self.prefix}_gateway_display_online", state="on" if online else "off", attributes={"friendly_name": "PredBat Hub display online", "device_class": "connectivity"}, + app="gateway", ) # A disconnected desk display cannot make the working inverter provider # fail its run or liveness probe. Components still checks the owning From f0d87fb66ace52cdc298f6a6da5004702b982c9a Mon Sep 17 00:00:00 2001 From: Mark Gascoyne Date: Mon, 12 Oct 2026 12:10:23 +0100 Subject: [PATCH 4/4] fix(gateway): start optional displays without waiting for telemetry --- apps/predbat/gateway.py | 13 ++-- apps/predbat/tests/test_gateway_display.py | 69 ++++++++++++++++++++++ 2 files changed, 76 insertions(+), 6 deletions(-) diff --git a/apps/predbat/gateway.py b/apps/predbat/gateway.py index d8a4c1e5a..ee4f99d6c 100644 --- a/apps/predbat/gateway.py +++ b/apps/predbat/gateway.py @@ -650,6 +650,13 @@ async def run(self, seconds, first): # Start MQTT listener as a background task self._mqtt_task = asyncio.ensure_future(self._mqtt_loop()) self.log("Info: GatewayMQTT: MQTT listener task started") + if self.gateway_display_only: + # The selected inverter provider must not wait for an optional + # display's broker connection or first telemetry. Component + # startup and actual display availability are separate states. + self.api_started = True + self._update_display_health() + return True # Wait for the first connection attempt and then for the first telemetry → # auto-config, so the inverter args are wired up before startup continues. # Both waits share the single _STARTUP_WAIT_TICKS budget so an offline gateway @@ -657,12 +664,6 @@ async def run(self, seconds, first): ticks = await self._startup_wait(0, lambda: self._first_connection_attempted) if not self._first_connection_attempted and not self.api_stop: self.log("Warn: GatewayMQTT: First connection attempt not yet complete, continuing startup") - if self.gateway_display_only: - await self._startup_wait(ticks, lambda: self.api_started) - if not self.api_started and not self.api_stop: - self.log("Warn: GatewayMQTT: No display telemetry received, continuing startup") - self._update_display_health() - return True # The auto-config wait runs even when the first attempt failed: the MQTT loop # retries with backoff in the background, so a transient broker failure at # startup usually still yields telemetry inside the window. Returning early diff --git a/apps/predbat/tests/test_gateway_display.py b/apps/predbat/tests/test_gateway_display.py index 9a7e5df3d..7c67463a3 100644 --- a/apps/predbat/tests/test_gateway_display.py +++ b/apps/predbat/tests/test_gateway_display.py @@ -209,6 +209,75 @@ def test_display_outage_preserves_cloud_run_and_ping(self): assert not comps.is_all_alive() assert asyncio.run(web.html_api_ping(None)).status == 500 + def test_offline_display_startup_does_not_wait_or_fail_cloud_health(self): + """An optional display starts before its first successful MQTT connection.""" + from component_base import ComponentBase + from components import Components + from predbat import PredBat + from web import WebInterface + + gw = self._make_gateway() + gw._mqtt_connected = False + gw._load_discovery = AsyncMock() + gw._mqtt_loop = AsyncMock() + gw._startup_wait = AsyncMock(return_value=0) + gw.update_success_timestamp = lambda: ComponentBase.update_success_timestamp(gw) + comps = Components.__new__(Components) + comps.components = {"gateway": gw} + comps.component_tasks = {"gateway": SimpleNamespace(is_alive=lambda: True)} + comps.component_errors = {} + base = gw.base + base.components = comps + base.prefix = "predbat" + base.stop_thread = False + base.fatal_error = False + base.had_errors = False + base.get_state_wrapper.side_effect = lambda entity, attribute=None, default=None: False if attribute == "error" else datetime.datetime.now(datetime.timezone.utc).isoformat() + base.is_running.side_effect = lambda: PredBat.is_running(base) + web = WebInterface.__new__(WebInterface) + web.base = base + web.log = base.log + + async def exercise(): + try: + assert await gw.run(0, True) + assert gw.api_started + gw._startup_wait.assert_not_awaited() + assert comps.is_all_alive() + assert (await web.html_api_ping(None)).status == 200 + PredBat.record_final_run_status(base, "Charging", "") + assert base.record_status.call_args.args[0] == "Charging" + assert not gw._auto_configured + assert gw._last_status is None + display_call = next(call for call in reversed(base.dashboard_item.call_args_list) if call.args[0] == "binary_sensor.predbat_gateway_display_online") + assert display_call.args[1] == "off" + finally: + gw._mqtt_task.cancel() + await asyncio.gather(gw._mqtt_task, return_exceptions=True) + + with patch("gateway.HAS_AIOMQTT", True): + asyncio.run(exercise()) + + def test_control_startup_still_waits_for_connection_and_configuration(self): + """The optional-display fast path cannot relax real inverter startup.""" + gw = self._make_gateway(False) + gw._load_discovery = AsyncMock() + gw._mqtt_loop = AsyncMock() + gw._startup_wait = AsyncMock(return_value=0) + + async def exercise(): + try: + assert await gw.run(0, True) + assert gw._startup_wait.await_count == 2 + assert not gw.api_started + assert not gw._auto_configured + finally: + gw._mqtt_task.cancel() + await asyncio.gather(gw._mqtt_task, return_exceptions=True) + + with patch("gateway.HAS_AIOMQTT", True): + asyncio.run(exercise()) + def test_control_transport_outage_still_fails_health(self): """Optional display health cannot relax the active controller's checks.""" gw = self._make_gateway(False)