Skip to content

Commit 431bd77

Browse files
mgazzaclaude
andcommitted
fix(gateway): never enable discharge/export after a failed window write
For a direct Hub battery, adjust_force_export wrote the discharge start and end as two separate set_discharge_slot commands, then enabled the scheduled discharge and switched to export mode regardless of whether the endpoint writes were applied. schedule_write_ok only withheld the later commit record. When the end write was refused after the start was accepted, the Hub was left enabled with a stale wrapping window (e.g. 02:55-00:01) and actively exporting. Send both endpoints in one full-serial set_discharge_slot for direct Hub batteries and verify both endpoint read-backs before continuing. Any refusal, staged or partial ACK, timeout, publication error or swallowed async-bridge exception returns before the enable write, export mode and commit, leaving the commit pending for the next cycle to retry. Both endpoints are tracked in the control ledger and share one backoff key. Read-only, entity ownership and serial checks refuse before publishing. Coordinators keep their staged separate-endpoint sequence; other providers, disable and midnight windows are unchanged. Mirrors the charge-window fix in #5531. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018BaJjLpuMEtQYfvmTRcFJf
1 parent 40b9ce2 commit 431bd77

4 files changed

Lines changed: 356 additions & 2 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2654,6 +2654,44 @@ async def _update_charge_slot(self, entity_id, hhmm, serial):
26542654
await self._send_control(entity_id, "set_charge_slot", schedule_json=json.dumps(schedule), serial=serial)
26552655
self.log(f"Info: GatewayMQTT: Charge slot update: {schedule}")
26562656

2657+
def discharge_window_uses_staging(self, start_entity, end_entity):
2658+
"""Identify a coordinator synchronously, before crossing an async bridge."""
2659+
if not isinstance(start_entity, str) or not start_entity.endswith("_discharge_slot1_start"):
2660+
return False
2661+
if end_entity != start_entity.removesuffix("_start") + "_end":
2662+
return False
2663+
serial = self._serial_from_entity_id(start_entity)
2664+
record = getattr(self, "_discovery", {}).get((serial or "").upper(), {})
2665+
return bool(serial) and record.get("type") in GATEWAY_COORDINATOR_TYPES
2666+
2667+
async def update_discharge_window(self, start_entity, end_entity, start, end):
2668+
"""Send an engine discharge window as one full-serial command, not two partial slots.
2669+
2670+
True means publication was attempted, not that the inverter applied it. The
2671+
caller must verify both endpoint entities, which only an applied ACK or
2672+
physical telemetry updates. Coordinators use their existing separate path:
2673+
those devices can stage a disabled slot and apply it only on enable.
2674+
Manual single-endpoint selects keep their path.
2675+
"""
2676+
if getattr(self.base, "set_read_only", False):
2677+
return False
2678+
if not isinstance(start_entity, str) or not start_entity.endswith("_discharge_slot1_start"):
2679+
return False
2680+
if end_entity != start_entity.removesuffix("_start") + "_end":
2681+
return False
2682+
serial = self._serial_from_entity_id(start_entity)
2683+
if not serial or self._serial_from_entity_id(end_entity) != serial:
2684+
return False
2685+
if self.discharge_window_uses_staging(start_entity, end_entity):
2686+
return False
2687+
try:
2688+
times = [datetime.datetime.strptime(value, "%H:%M:%S") for value in (start, end)]
2689+
except (ValueError, TypeError):
2690+
return False
2691+
schedule = {field: value.hour * 100 + value.minute for field, value in zip(("start", "end"), times)}
2692+
await self._send_control(start_entity, "set_discharge_slot", schedule_json=json.dumps(schedule), serial=serial)
2693+
return True
2694+
26572695
async def _update_discharge_slot(self, entity_id, hhmm, serial):
26582696
"""Send set_discharge_slot command with updated start or end time."""
26592697
if "_start" in entity_id:

‎apps/predbat/inverter.py‎

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2927,6 +2927,74 @@ def adjust_idle_time(self, charge_start=None, charge_end=None, discharge_start=N
29272927
self.write_and_poll_option("idle_end_time", idle_end_time_id, idle_end)
29282928
self.idle_end_minutes = idle_end_minutes
29292929

2930+
def write_and_poll_gateway_discharge_window(self, new_start, new_end):
2931+
"""Verify a complete Hub slot together, or return None for another provider.
2932+
2933+
A start equal to the old end cannot be sent alone while discharging is enabled:
2934+
it temporarily disables the slot and the Hub correctly refuses it. Sending
2935+
both bounds also handles swapping the bounds of a midnight-spanning slot.
2936+
"""
2937+
from ha import run_async
2938+
2939+
entities = [self.base.get_arg(name, indirect=False, index=self.id) for name in ("discharge_start_time", "discharge_end_time")]
2940+
owners = [getattr(self.base, "dashboard_index_app", {}).get(entity) for entity in entities]
2941+
if "gateway" not in owners:
2942+
return None
2943+
components = getattr(self.base, "components", None)
2944+
gateway = components.get_component("gateway") if components else None
2945+
names = ("discharge_start_time", "discharge_end_time")
2946+
target = (new_start, new_end)
2947+
if owners != ["gateway", "gateway"] or gateway is None:
2948+
self.base.record_status(f"Warn: Inverter {self.id} Hub discharge window has inconsistent entity ownership", had_errors=True)
2949+
return False
2950+
if not all(self.check_write_entity("write_and_poll_gateway_discharge_window", name, entity, value) for name, entity, value in zip(names, entities, target)):
2951+
return False
2952+
if gateway.discharge_window_uses_staging(*entities):
2953+
return None # Coordinator slot staging must precede its enable write.
2954+
2955+
def read_pair():
2956+
"""Read both endpoint entities without substituting desired values."""
2957+
return tuple(self.base.get_state_wrapper(entity, refresh=True) for entity in entities)
2958+
2959+
before = read_pair()
2960+
ledger = self.base.control_ledger
2961+
if ledger is not None:
2962+
for name, entity, value, wanted in zip(names, entities, before, target):
2963+
self._ledger_observe(ledger, name, entity, value)
2964+
if value != wanted:
2965+
ledger.note_write_attempt(entity)
2966+
key = "|".join(entities)
2967+
verified = before == target
2968+
for _retry in range(0 if verified else self._write_attempts("discharge_window", key, target)):
2969+
try:
2970+
published = run_async(gateway.update_discharge_window(*entities, *target))
2971+
except Exception as error:
2972+
self.base.log(f"Warn: Inverter {self.id} Hub discharge window publication failed: {error}")
2973+
break
2974+
if published is not True:
2975+
break
2976+
self._poll_after_write(entities[0], lambda state: state == new_start and self.base.get_state_wrapper(entities[1], refresh=True) == new_end)
2977+
verified = read_pair() == target
2978+
if verified:
2979+
self.count_register_writes += 1
2980+
self.registers_moved += sum(old != wanted for old, wanted in zip(before, target))
2981+
break
2982+
self._write_backoff_result("discharge_window", key, target, verified)
2983+
if ledger is not None:
2984+
for name, entity, value in zip(names, entities, target):
2985+
if verified:
2986+
if before == target:
2987+
ledger.record_ownership_from_read(entity, name, value, now=time.time(), generation=self._ledger_generation(entity))
2988+
else:
2989+
ledger.record_write(entity, name, value, now=time.time(), generation=self._ledger_generation(entity))
2990+
else:
2991+
ledger.clear(entity)
2992+
if not verified:
2993+
message = f"Warn: Inverter {self.id} write to Hub discharge window failed: wanted {target}, got {read_pair()}"
2994+
self.base.log(message)
2995+
self.base.record_status(message, had_errors=True)
2996+
return verified
2997+
29302998
def adjust_force_export(self, force_export, new_start_time=None, new_end_time=None):
29312999
"""
29323000
Adjust force export on/off and set the time window correctly
@@ -3024,8 +3092,20 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No
30243092
else:
30253093
self.adjust_idle_time(discharge_start="00:00:00", discharge_end="00:00:00")
30263094

3095+
# Direct Hub batteries accept the complete slot as one physical write. Do not
3096+
# enable or enter export mode after a refused, staged or incomplete read-back.
3097+
# Coordinators retain their existing staged-endpoint then enable sequence.
3098+
gateway_window = None
3099+
if (new_start and new_start != old_start) or (new_end and new_end != old_end):
3100+
gateway_window = self.write_and_poll_gateway_discharge_window(new_start or old_start, new_end or old_end)
3101+
if gateway_window is False:
3102+
self.record_commit("export_window", (new_start, new_end, force_export), False)
3103+
return
3104+
if gateway_window is True:
3105+
changed_start_end = True
3106+
30273107
# Change start time
3028-
if new_start and (new_start != old_start or is_hm_format):
3108+
if gateway_window is None and new_start and (new_start != old_start or is_hm_format):
30293109
self.base.log("Inverter {} set new export start time to {}".format(self.id, new_start))
30303110
if "discharge_start_time" in self.base.args:
30313111
# Always write to this as it is the GE default
@@ -3047,7 +3127,7 @@ def adjust_force_export(self, force_export, new_start_time=None, new_end_time=No
30473127
self.log("Warn: Inverter {} unable write export start time as neither REST or discharge_start_time are set".format(self.id))
30483128

30493129
# Change end time
3050-
if new_end and (new_end != old_end or is_hm_format):
3130+
if gateway_window is None and new_end and (new_end != old_end or is_hm_format):
30513131
self.base.log("Inverter {} Set new export end time to {} was {}".format(self.id, new_end, old_end))
30523132
if "discharge_end_time" in self.base.args:
30533133
# Always write to this as it is the GE default

‎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_discharge_window import TestGatewayDischargeWindow
74077408

74087409
test_classes = [
7410+
TestGatewayDischargeWindow,
74097411
TestProtobufDecode,
74107412
TestPlanSerialization,
74117413
TestCommandFormat,

0 commit comments

Comments
 (0)