@@ -103,6 +103,7 @@ def _serial_suffix(serial):
103103GATEWAY_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
0 commit comments