Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions apps/predbat/components.py
Original file line number Diff line number Diff line change
Expand Up @@ -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},
Expand Down Expand Up @@ -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):
"""
Expand Down Expand Up @@ -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:
Expand Down
79 changes: 78 additions & 1 deletion apps/predbat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -624,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
Expand Down Expand Up @@ -651,6 +684,15 @@ 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()
self._update_display_health()
return True

# Publish any queued plan from on_plan_executed hook
if self._pending_plan:
plan_entries, tz = self._pending_plan
Expand Down Expand Up @@ -859,6 +901,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
Expand Down Expand Up @@ -1807,6 +1858,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
Expand Down Expand Up @@ -2469,6 +2522,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

Expand Down Expand Up @@ -2556,6 +2611,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

Expand All @@ -2568,16 +2626,35 @@ 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"},
app="gateway",
)
# 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.

Returns True when MQTT is connected AND either the gateway is
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

Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/tests/test_gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading