Skip to content

Commit acc04b8

Browse files
Merge pull request #5220 from springfall2008/feat/gateway-read-only-entity
feat(gateway): expose the gateway's enforced read-only flag as an entity and warn on mismatch
2 parents c2c74a3 + 2105f92 commit acc04b8

4 files changed

Lines changed: 140 additions & 9 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ def _serial_suffix(serial):
103103
GATEWAY_ATTRIBUTE_TABLE = {
104104
# Binary sensors
105105
"gateway_online": {"friendly_name": "Gateway Online", "device_class": "connectivity"},
106+
"gateway_read_only": {"friendly_name": "Gateway Read-Only (enforced)", "icon": "mdi:lock"},
106107
# Timestamps
107108
"inverter_time": {"friendly_name": "Inverter Time", "icon": "mdi:clock", "device_class": "timestamp"},
108109
# Battery state
@@ -326,6 +327,9 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt
326327
# Monotonic-ish timestamp of the last set_read_only send, used to force a
327328
# periodic re-send so the gateway re-syncs even if a command was missed
328329
self._last_read_only_sent_time = 0
330+
# Latch so a gateway whose reported read-only state disagrees with what PredBat
331+
# requested is logged once per transition, not on every status cycle
332+
self._read_only_mismatch_logged = False
329333

330334
# Set once the first MQTT connection attempt has completed (success or failure)
331335
self._first_connection_attempted = False
@@ -855,13 +859,27 @@ def _inject_entities(self, status):
855859
device_id = status.device_id
856860
firmware = status.firmware
857861

862+
# Read-only as the gateway actually enforces it. Absent on firmware predating
863+
# the field, which is UNKNOWN and not False — publishing False there would claim
864+
# the gateway is controllable when nothing has reported either way.
865+
read_only = status.read_only if status.HasField("read_only") else None
866+
858867
self.dashboard_item(
859868
f"binary_sensor.{self.prefix}_gateway_online",
860869
True,
861-
attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware},
870+
attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware, "read_only": read_only},
862871
app="gateway",
863872
)
864873

874+
if read_only is not None:
875+
self.dashboard_item(
876+
f"binary_sensor.{self.prefix}_gateway_read_only",
877+
read_only,
878+
attributes=GATEWAY_ATTRIBUTE_TABLE.get("gateway_read_only", {}),
879+
app="gateway",
880+
)
881+
self._check_read_only_mismatch(read_only)
882+
865883
# Inverter time from gateway timestamp — write it under the suffix PredBat
866884
# actually reads (the control target), not the primary's, or the bound
867885
# inverter_time arg is never updated and silently freezes.
@@ -1651,6 +1669,27 @@ async def _check_read_only_state(self):
16511669
self._last_read_only_sent_time = now
16521670
self.log(f"Info: GatewayMQTT: set_read_only command sent (read_only={read_only})")
16531671

1672+
def _check_read_only_mismatch(self, reported):
1673+
"""Warn when the gateway's enforced read-only state differs from what PredBat asked for.
1674+
1675+
Observe-only: called from the telemetry path with the value the gateway reports in
1676+
its status, and never sends a command. _last_read_only holds the value of the last
1677+
set_read_only command PredBat published, so the two are directly comparable; it is
1678+
None before the first send (and after a disconnect forces a re-send), in which case
1679+
there is nothing to compare against yet.
1680+
1681+
The status arrives every cycle, so the warning is latched and logged once per
1682+
transition into disagreement, and the latch is cleared as soon as the two agree
1683+
again — otherwise a stuck gateway would fill the log.
1684+
"""
1685+
requested = self._last_read_only
1686+
if requested is None or reported == requested:
1687+
self._read_only_mismatch_logged = False
1688+
return
1689+
if not self._read_only_mismatch_logged:
1690+
self.log(f"Warn: GatewayMQTT: gateway reports read_only={reported} but PredBat requested {requested}")
1691+
self._read_only_mismatch_logged = True
1692+
16541693
async def _check_inverter_resets(self):
16551694
"""Send inverter_reset for each configured inverter not yet reset in control mode.
16561695

‎apps/predbat/gateway_status.proto‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -175,6 +175,15 @@ message GatewayStatus {
175175
uint32 heap_internal_free = 15; // heap_caps_get_free_size(INTERNAL | 8BIT)
176176
uint32 heap_internal_largest = 16; // largest contiguous block — the leading indicator
177177
uint32 heap_internal_min_free = 17; // low-water mark since boot
178+
179+
// Hub-side read-only flag (g_read_only), as actually enforced: write-class
180+
// commands rejected and autonomous mode disabled. PredBat mirrors its own
181+
// read-only mode here via the set_read_only command, but that is a two-hop
182+
// mirror with no other feedback — the ack carries no value, and a hub that was
183+
// offline at the transition only catches up on the 30-minute resend. Reporting
184+
// the enforced state on every status makes "is this hub actually read-only?"
185+
// answerable from the cloud instead of inferred. Absent on older firmware.
186+
optional bool read_only = 18;
178187
}
179188

180189
// Execution plan sent from PredBat cloud to gateway

‎apps/predbat/gateway_status_pb2.py‎

Lines changed: 8 additions & 8 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)