Skip to content

Commit 68e7ee5

Browse files
committed
feat(gateway): add isolated display-only mode
1 parent d6985b6 commit 68e7ee5

5 files changed

Lines changed: 223 additions & 3 deletions

File tree

‎apps/predbat/components.py‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,7 @@ def load_component_class(component_info):
726726
"mqtt_port": {"required": False, "config": "gateway_mqtt_port", "default": 8883},
727727
"mqtt_token": {"required": True, "secret": True, "config": "gateway_mqtt_token"},
728728
"gateway_inverter_serial": {"required": False, "config": "gateway_inverter_serial", "default": None},
729+
"gateway_display_only": {"required": False, "config": "gateway_display_only", "default": False},
729730
"gateway_evc_automatic": {"required": False, "config": "gateway_evc_automatic", "default": False},
730731
"gateway_evc_control": {"required": False, "config": "gateway_evc_control", "default": False},
731732
"gateway_shared_ct": {"required": False, "config": "gateway_shared_ct", "default": True},
@@ -1038,11 +1039,11 @@ def inverter_source_active(self):
10381039
question rather than a per-inverter one: a component serves whichever inverters it
10391040
discovered, and Inverter only needs to know whether to keep waiting.
10401041
"""
1041-
return any(COMPONENT_LIST.get(name, {}).get("inverter", False) and component for name, component in self.components.items())
1042+
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())
10421043

10431044
def inverter_source_names(self):
10441045
"""The display names of every active inverter component, for user-facing messages."""
1045-
return [COMPONENT_LIST[name]["name"] for name, component in self.components.items() if component and COMPONENT_LIST.get(name, {}).get("inverter", False)]
1046+
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]
10461047

10471048
def inverter_source_status(self):
10481049
"""
@@ -1075,6 +1076,8 @@ def inverter_source_status(self):
10751076
continue
10761077
load_error = self.load_error(name)
10771078
component = self.components.get(name, None)
1079+
if component and getattr(component, "gateway_display_only", False) is True:
1080+
continue
10781081
if load_error is not None:
10791082
state = "failed to start: {}".format(load_error)
10801083
elif not component:

‎apps/predbat/gateway.py‎

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -231,6 +231,7 @@ class GatewayMQTT(ComponentBase):
231231
gateway_shared_ct = True
232232
gateway_integrate_power = False
233233
gateway_inverter_serial = ()
234+
gateway_display_only = False
234235
_integrated_energy = None
235236
_unavailable_inverters = ((), ())
236237
_discovery = {} # replaced, never changed in place, so this default is not shared state
@@ -244,11 +245,26 @@ class GatewayMQTT(ComponentBase):
244245
_reserve_before_zero = {} # replaced, never changed in place
245246
gateway_device_id = None
246247

247-
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):
248+
def initialize(
249+
self,
250+
gateway_device_id=None,
251+
mqtt_host=None,
252+
mqtt_port=8883,
253+
mqtt_token=None,
254+
gateway_inverter_serial=None,
255+
gateway_evc_automatic=False,
256+
gateway_evc_control=False,
257+
gateway_shared_ct=True,
258+
gateway_integrate_power=False,
259+
gateway_display_only=False,
260+
**kwargs,
261+
):
248262
"""Initialize gateway configuration and build MQTT topic strings.
249263
250264
Args:
251265
gateway_device_id: The gateway's device ID (e.g. "pbgw_abc123").
266+
gateway_display_only: Publish display data without registering or controlling any inverter
267+
or EV charger. The hub must already be parked by its standby controller.
252268
mqtt_host: MQTT broker hostname.
253269
mqtt_port: MQTT broker port (default 8883 for TLS).
254270
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
270286
**kwargs: Additional keyword arguments (ignored).
271287
"""
272288
self.gateway_device_id = gateway_device_id
289+
if isinstance(gateway_display_only, str):
290+
value = gateway_display_only.strip().lower()
291+
if value not in ("true", "false", "1", "0", "yes", "no", "on", "off"):
292+
raise ValueError("gateway_display_only must be a boolean")
293+
gateway_display_only = value in ("true", "1", "yes", "on")
294+
if not isinstance(gateway_display_only, bool):
295+
raise ValueError("gateway_display_only must be a boolean")
296+
self.gateway_display_only = gateway_display_only
273297
self.gateway_evc_automatic = bool(gateway_evc_automatic)
274298
self.gateway_evc_control = bool(gateway_evc_control)
275299
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
415439
the optimizer's charge/export windows into ExecutionPlan entries and
416440
queues them for MQTT publishing on the next run() cycle.
417441
"""
442+
if self.gateway_display_only:
443+
return
418444
plan_entries = []
419445

420446
# Reserve as a percentage — used to detect freeze charge (charge limit == reserve)
@@ -631,6 +657,11 @@ async def run(self, seconds, first):
631657
ticks = await self._startup_wait(0, lambda: self._first_connection_attempted)
632658
if not self._first_connection_attempted and not self.api_stop:
633659
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+
return True
634665
# The auto-config wait runs even when the first attempt failed: the MQTT loop
635666
# retries with backoff in the background, so a transient broker failure at
636667
# startup usually still yields telemetry inside the window. Returning early
@@ -651,6 +682,14 @@ async def run(self, seconds, first):
651682
self.log("Info: GatewayMQTT: Restarting MQTT listener task")
652683
self._mqtt_task = asyncio.ensure_future(self._mqtt_loop())
653684

685+
if self.gateway_display_only:
686+
# The JSON display topic is separate from the executable schedule. Never
687+
# refresh schedules, release read-only, reset inverters or control EVs here.
688+
if self._mqtt_connected and self._last_status is not None:
689+
await self._publish_predbat_data()
690+
await self._check_token_refresh()
691+
return True
692+
654693
# Publish any queued plan from on_plan_executed hook
655694
if self._pending_plan:
656695
plan_entries, tz = self._pending_plan
@@ -859,6 +898,15 @@ def _process_telemetry(self, data):
859898

860899
self._debug_dump("RX telemetry", status, raw=data)
861900

901+
if self.gateway_display_only:
902+
# A desk display need not discover an inverter. Do not inject the hub's
903+
# inverter entities or register hardware against the selected provider.
904+
self._last_status = status
905+
self._last_telemetry_time = time.time()
906+
self.update_success_timestamp()
907+
self.api_started = True
908+
return
909+
862910
if len(status.inverters) == 0:
863911
if not (self.gateway_inverter_serial and self._discovery):
864912
return
@@ -1807,6 +1855,8 @@ def automatic_config(self):
18071855
(see ``_needs_reconfigure``). Maps proto fields to PredBat args so the plan
18081856
engine has data to work with.
18091857
"""
1858+
if self.gateway_display_only:
1859+
return
18101860
if not self._last_status:
18111861
self.log("Error: GatewayMQTT: automatic_config called with no status data")
18121862
return
@@ -2469,6 +2519,8 @@ async def publish_plan(self, plan_entries, timezone_str):
24692519
plan_entries: List of plan entry dicts.
24702520
timezone_str: IANA timezone string (e.g. "Europe/London").
24712521
"""
2522+
if self.gateway_display_only:
2523+
return
24722524
if not self._plan_changed(plan_entries):
24732525
return # No change, skip publish
24742526

@@ -2556,6 +2608,9 @@ async def _publish_raw(self, topic, payload, retain=False):
25562608
payload: Bytes to publish.
25572609
retain: Whether to set the retain flag.
25582610
"""
2611+
if self.gateway_display_only and topic != f"{self._topic_base}/predbat_data":
2612+
self.log(f"Warn: GatewayMQTT: Display mode blocked publish to {topic}")
2613+
return
25592614
if not (self._mqtt_client and self._mqtt_connected):
25602615
return
25612616

‎apps/predbat/tests/test_gateway.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7404,8 +7404,10 @@ def test_counters_restart_at_local_midnight(self):
74047404
def run_gateway_tests(my_predbat=None):
74057405
"""Run all GatewayMQTT tests. Returns True on failure, False on success."""
74067406
from tests.test_gateway_token_refresh import TestIsAuthFailure, TestApplyRefreshResponse, TestMaybeRefreshOnAuthError
7407+
from tests.test_gateway_display import TestGatewayDisplayOnly
74077408

74087409
test_classes = [
7410+
TestGatewayDisplayOnly,
74097411
TestProtobufDecode,
74107412
TestPlanSerialization,
74117413
TestCommandFormat,
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
"""Display-only gateway isolation and lifecycle regressions."""
2+
3+
import asyncio
4+
from types import SimpleNamespace
5+
from unittest.mock import AsyncMock, MagicMock, patch
6+
7+
import gateway_status_pb2 as pb
8+
from gateway import GatewayMQTT
9+
10+
11+
class TestGatewayDisplayOnly:
12+
"""A display cannot become an inverter source or publish control messages."""
13+
14+
def _make_gateway(self, display=True):
15+
"""Build the real gateway state with an in-memory MQTT client."""
16+
gw = GatewayMQTT.__new__(GatewayMQTT)
17+
gw.base = MagicMock()
18+
gw.args = {}
19+
gw.log = MagicMock()
20+
gw.initialize(gateway_device_id="pbgw_test", mqtt_host="mqtt.test", gateway_display_only=display)
21+
gw._mqtt_connected = True
22+
gw._mqtt_client = SimpleNamespace(publish=AsyncMock())
23+
gw.api_started = False
24+
gw.api_stop = False
25+
gw.update_success_timestamp = MagicMock()
26+
return gw
27+
28+
def test_zero_inverters_is_a_ready_display(self):
29+
"""A status with no inverter completes display startup without changing control."""
30+
gw = self._make_gateway()
31+
gw.automatic_config = MagicMock()
32+
gw._inject_entities = MagicMock()
33+
status = pb.GatewayStatus(device_id="pbgw_test")
34+
gw._process_telemetry(status.SerializeToString())
35+
assert gw.api_started
36+
assert not gw._auto_configured
37+
gw.automatic_config.assert_not_called()
38+
gw._inject_entities.assert_not_called()
39+
gw.update_success_timestamp.assert_called_once()
40+
41+
def test_discovered_inverter_cannot_replace_cloud(self):
42+
"""Even full telemetry never registers inverters or EVs in display mode."""
43+
gw = self._make_gateway()
44+
gw.set_arg = MagicMock()
45+
status = pb.GatewayStatus(device_id="pbgw_test")
46+
status.inverters.add(serial="CE12345678", connected=True)
47+
status.ev_chargers.add(charge_point_id="charger")
48+
gw._process_telemetry(status.SerializeToString())
49+
gw.automatic_config()
50+
gw.set_arg.assert_not_called()
51+
assert not gw._auto_configured
52+
assert not gw._configured_ev_chargers
53+
54+
def test_invalid_telemetry_does_not_start_display(self):
55+
"""A corrupt message must not satisfy the readiness condition."""
56+
gw = self._make_gateway()
57+
gw._process_telemetry(b"\xff")
58+
assert not gw.api_started
59+
assert gw._last_status is None
60+
61+
def test_display_only_allows_display_topic(self):
62+
"""The final MQTT boundary blocks all command and schedule paths."""
63+
gw = self._make_gateway()
64+
65+
async def exercise():
66+
for suffix in ("command", "ev/command", "schedule", "unknown"):
67+
await gw._publish_raw(f"{gw._topic_base}/{suffix}", b"control", retain=True)
68+
await gw._publish_raw(f"{gw._topic_base}/predbat_data", b"display", retain=True)
69+
70+
asyncio.run(exercise())
71+
gw._mqtt_client.publish.assert_awaited_once_with(f"{gw._topic_base}/predbat_data", b"display", qos=1, retain=True)
72+
73+
def test_public_control_entry_points_cannot_send(self):
74+
"""Direct callers cannot bypass display mode through control helpers."""
75+
gw = self._make_gateway()
76+
77+
async def exercise():
78+
await gw.publish_command("set_read_only", enable=False)
79+
await gw._send_ev_command({"action": "RemoteStartTransaction"})
80+
await gw.publish_plan([], "Europe/London")
81+
82+
asyncio.run(exercise())
83+
gw._mqtt_client.publish.assert_not_awaited()
84+
assert gw._last_plan_data is None
85+
86+
def test_plan_hook_does_not_queue_execution(self):
87+
"""Display plans travel through JSON, never the autonomous schedule hook."""
88+
gw = self._make_gateway()
89+
gw._on_plan_executed()
90+
assert gw._pending_plan is None
91+
92+
def test_display_housekeeping_never_releases_standby(self):
93+
"""An online display publishes JSON and refreshes auth, without control housekeeping."""
94+
gw = self._make_gateway()
95+
gw._last_status = pb.GatewayStatus(device_id="pbgw_test")
96+
gw._publish_predbat_data = AsyncMock()
97+
gw._check_token_refresh = AsyncMock()
98+
gw._check_read_only_state = AsyncMock()
99+
gw._check_inverter_resets = AsyncMock()
100+
gw._check_rate_caps = AsyncMock()
101+
gw._republish_plan_if_stale = AsyncMock()
102+
with patch("gateway.HAS_AIOMQTT", True):
103+
assert asyncio.run(gw.run(60, False))
104+
gw._publish_predbat_data.assert_awaited_once()
105+
gw._check_token_refresh.assert_awaited_once()
106+
for method in (gw._check_read_only_state, gw._check_inverter_resets, gw._check_rate_caps, gw._republish_plan_if_stale):
107+
method.assert_not_awaited()
108+
109+
def test_display_startup_does_not_wait_for_auto_config(self):
110+
"""Display startup accepts zero-inverter telemetry, using the same bounded budget."""
111+
gw = self._make_gateway()
112+
gw.api_started = True
113+
gw._first_connection_attempted = True
114+
gw._startup_wait = AsyncMock(side_effect=lambda ticks, ready: ticks if ready() else 999)
115+
gw._mqtt_loop = AsyncMock()
116+
with patch("gateway.HAS_AIOMQTT", True):
117+
assert asyncio.run(gw.run(0, True))
118+
assert all(call.args[1]() for call in gw._startup_wait.call_args_list)
119+
assert not gw._auto_configured
120+
121+
def test_display_is_not_an_inverter_source(self):
122+
"""A display cannot make the engine wait for battery readings it will never supply."""
123+
from components import Components
124+
125+
comps = Components.__new__(Components)
126+
comps.components = {"gateway": self._make_gateway()}
127+
comps.load_error = lambda name: None
128+
comps.is_alive = lambda name: True
129+
assert not comps.inverter_source_active()
130+
assert comps.inverter_source_names() == []
131+
assert comps.inverter_source_status() == []
132+
comps.components["gateway"].gateway_display_only = False
133+
assert comps.inverter_source_active()
134+
assert comps.inverter_source_names() == ["PredBat Gateway"]
135+
assert comps.inverter_source_status() == ["PredBat Gateway (OK)"]
136+
137+
def test_false_string_keeps_existing_control_mode(self):
138+
"""String config values are parsed rather than treated as truthy strings."""
139+
assert not self._make_gateway("false").gateway_display_only
140+
assert self._make_gateway("true").gateway_display_only
141+
142+
def test_invalid_display_flag_fails_closed(self):
143+
"""An invalid role value must not silently enable inverter control."""
144+
try:
145+
self._make_gateway("typo")
146+
except ValueError:
147+
return
148+
raise AssertionError("Invalid display flag was accepted")
149+
150+
def test_control_mode_can_still_publish(self):
151+
"""The opt-in mode does not block the existing control transport."""
152+
gw = self._make_gateway(False)
153+
asyncio.run(gw._publish_raw(gw.topic_command, b"control"))
154+
gw._mqtt_client.publish.assert_awaited_once()

‎docs/apps-yaml.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -615,6 +615,12 @@ See also **ge_cloud_automatic_split_ct** which takes priority over this setting
615615
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).
616616
This setting takes priority over **ge_cloud_automatic_shared_ct** if both are set.
617617

618+
- **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.
619+
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.
620+
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.
621+
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.
622+
This is a display option, not automatic backup switching.
623+
618624
- **gateway_shared_ct** - Optional, defaults to true. The equivalent of **ge_cloud_automatic_shared_ct** for inverters controlled through the Predbat Gateway.
619625
When `true`, Predbat treats multiple inverters as sharing a single physical CT clamp for grid and load measurement,
620626
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.

0 commit comments

Comments
 (0)