Skip to content
Merged
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
41 changes: 40 additions & 1 deletion apps/predbat/gateway.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ def _serial_suffix(serial):
GATEWAY_ATTRIBUTE_TABLE = {
# Binary sensors
"gateway_online": {"friendly_name": "Gateway Online", "device_class": "connectivity"},
"gateway_read_only": {"friendly_name": "Gateway Read-Only (enforced)", "icon": "mdi:lock"},
# Timestamps
"inverter_time": {"friendly_name": "Inverter Time", "icon": "mdi:clock", "device_class": "timestamp"},
# Battery state
Expand Down Expand Up @@ -293,6 +294,9 @@ def initialize(self, gateway_device_id=None, mqtt_host=None, mqtt_port=8883, mqt
# Monotonic-ish timestamp of the last set_read_only send, used to force a
# periodic re-send so the gateway re-syncs even if a command was missed
self._last_read_only_sent_time = 0
# Latch so a gateway whose reported read-only state disagrees with what PredBat
# requested is logged once per transition, not on every status cycle
self._read_only_mismatch_logged = False

# Set once the first MQTT connection attempt has completed (success or failure)
self._first_connection_attempted = False
Expand Down Expand Up @@ -791,13 +795,27 @@ def _inject_entities(self, status):
device_id = status.device_id
firmware = status.firmware

# Read-only as the gateway actually enforces it. Absent on firmware predating
# the field, which is UNKNOWN and not False — publishing False there would claim
# the gateway is controllable when nothing has reported either way.
read_only = status.read_only if status.HasField("read_only") else None

self.dashboard_item(
f"binary_sensor.{self.prefix}_gateway_online",
True,
attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware},
attributes={**GATEWAY_ATTRIBUTE_TABLE.get("gateway_online", {}), "device_id": device_id, "firmware": firmware, "read_only": read_only},
app="gateway",
)

if read_only is not None:
self.dashboard_item(
f"binary_sensor.{self.prefix}_gateway_read_only",
read_only,
attributes=GATEWAY_ATTRIBUTE_TABLE.get("gateway_read_only", {}),
app="gateway",
)
self._check_read_only_mismatch(read_only)

# Inverter time from gateway timestamp — write it under the suffix PredBat
# actually reads (the control target), not the primary's, or the bound
# inverter_time arg is never updated and silently freezes.
Expand Down Expand Up @@ -1592,6 +1610,27 @@ async def _check_read_only_state(self):
self._last_read_only_sent_time = now
self.log(f"Info: GatewayMQTT: set_read_only command sent (read_only={read_only})")

def _check_read_only_mismatch(self, reported):
"""Warn when the gateway's enforced read-only state differs from what PredBat asked for.

Observe-only: called from the telemetry path with the value the gateway reports in
its status, and never sends a command. _last_read_only holds the value of the last
set_read_only command PredBat published, so the two are directly comparable; it is
None before the first send (and after a disconnect forces a re-send), in which case
there is nothing to compare against yet.

The status arrives every cycle, so the warning is latched and logged once per
transition into disagreement, and the latch is cleared as soon as the two agree
again — otherwise a stuck gateway would fill the log.
"""
requested = self._last_read_only
if requested is None or reported == requested:
self._read_only_mismatch_logged = False
return
if not self._read_only_mismatch_logged:
self.log(f"Warn: GatewayMQTT: gateway reports read_only={reported} but PredBat requested {requested}")
self._read_only_mismatch_logged = True

async def _check_inverter_resets(self):
"""Send inverter_reset for each configured inverter not yet reset in control mode.

Expand Down
9 changes: 9 additions & 0 deletions apps/predbat/gateway_status.proto
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,15 @@ message GatewayStatus {
uint32 heap_internal_free = 15; // heap_caps_get_free_size(INTERNAL | 8BIT)
uint32 heap_internal_largest = 16; // largest contiguous block — the leading indicator
uint32 heap_internal_min_free = 17; // low-water mark since boot

// Hub-side read-only flag (g_read_only), as actually enforced: write-class
// commands rejected and autonomous mode disabled. PredBat mirrors its own
// read-only mode here via the set_read_only command, but that is a two-hop
// mirror with no other feedback — the ack carries no value, and a hub that was
// offline at the transition only catches up on the 30-minute resend. Reporting
// the enforced state on every status makes "is this hub actually read-only?"
// answerable from the cloud instead of inferred. Absent on older firmware.
optional bool read_only = 18;
}

// Execution plan sent from PredBat cloud to gateway
Expand Down
16 changes: 8 additions & 8 deletions apps/predbat/gateway_status_pb2.py

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading
Loading