Skip to content

Commit 3b236e8

Browse files
mgazzaCIclaude
authored
fix(gateway): apply direct battery charge windows as paired slots (#5531)
* fix(gateway): send direct battery charge windows as paired slots * fix(gateway): separate coordinator staging from async publication failure * fix(gateway): detect coordinator staging before charge ownership checks A coordinator whose charge end entity was not (yet) owned by the gateway was refused with an ownership error, so its charge window was never written, instead of keeping its staged separate-endpoint sequence. Identify coordinators first, as that decision only needs the entity names and discovery record. Same ordering as the discharge side in #5532. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: CI <ci@example.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent c24750e commit 3b236e8

4 files changed

Lines changed: 341 additions & 3 deletions

File tree

‎apps/predbat/gateway.py‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2727,6 +2727,44 @@ async def select_event(self, entity_id, value):
27272727
# Read current charge slot times to send both start and end
27282728
await self._update_charge_slot(entity_id, hhmm, serial=serial)
27292729

2730+
def charge_window_uses_staging(self, start_entity, end_entity):
2731+
"""Identify a coordinator synchronously, before crossing an async bridge."""
2732+
if not isinstance(start_entity, str) or not start_entity.endswith("_charge_slot1_start"):
2733+
return False
2734+
if end_entity != start_entity.removesuffix("_start") + "_end":
2735+
return False
2736+
serial = self._serial_from_entity_id(start_entity)
2737+
record = getattr(self, "_discovery", {}).get((serial or "").upper(), {})
2738+
return bool(serial) and record.get("type") in GATEWAY_COORDINATOR_TYPES
2739+
2740+
async def update_charge_window(self, start_entity, end_entity, start, end):
2741+
"""Send an engine charge window as one full-serial command, not two partial slots.
2742+
2743+
True means publication was attempted, not that the inverter applied it. The
2744+
caller must verify both endpoint entities, which only an applied ACK or
2745+
physical telemetry updates. Coordinators use their existing separate path:
2746+
those devices can stage a disabled slot and apply it only on enable.
2747+
Manual single-endpoint selects keep their path.
2748+
"""
2749+
if getattr(self.base, "set_read_only", False):
2750+
return False
2751+
if not isinstance(start_entity, str) or not start_entity.endswith("_charge_slot1_start"):
2752+
return False
2753+
if end_entity != start_entity.removesuffix("_start") + "_end":
2754+
return False
2755+
serial = self._serial_from_entity_id(start_entity)
2756+
if not serial or self._serial_from_entity_id(end_entity) != serial:
2757+
return False
2758+
if self.charge_window_uses_staging(start_entity, end_entity):
2759+
return False
2760+
try:
2761+
times = [datetime.datetime.strptime(value, "%H:%M:%S") for value in (start, end)]
2762+
except (ValueError, TypeError):
2763+
return False
2764+
schedule = {field: value.hour * 100 + value.minute for field, value in zip(("start", "end"), times)}
2765+
await self._send_control(start_entity, "set_charge_slot", schedule_json=json.dumps(schedule), serial=serial)
2766+
return True
2767+
27302768
async def _update_charge_slot(self, entity_id, hhmm, serial):
27312769
"""Send set_charge_slot command with updated start or end time."""
27322770
# Determine which field changed

‎apps/predbat/inverter.py‎

Lines changed: 77 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3621,6 +3621,74 @@ def adjust_export_immediate(self, target_soc, freeze=False):
36213621
if not self.call_service_template("discharge_stop_service", service_data_stop, domain="discharge"):
36223622
self.call_service_template("charge_stop_service", service_data_stop, domain="discharge")
36233623

3624+
def write_and_poll_gateway_charge_window(self, new_start, new_end):
3625+
"""Verify a complete Hub slot together, or return None for another provider.
3626+
3627+
A start equal to the old end cannot be sent alone while charging is enabled:
3628+
it temporarily disables the slot and the Hub correctly refuses it. Sending
3629+
both bounds also handles swapping the bounds of a midnight-spanning slot.
3630+
"""
3631+
from ha import run_async
3632+
3633+
entities = [self.base.get_arg(name, indirect=False, index=self.id) for name in ("charge_start_time", "charge_end_time")]
3634+
owners = [getattr(self.base, "dashboard_index_app", {}).get(entity) for entity in entities]
3635+
if "gateway" not in owners:
3636+
return None
3637+
components = getattr(self.base, "components", None)
3638+
gateway = components.get_component("gateway") if components else None
3639+
names = ("charge_start_time", "charge_end_time")
3640+
target = (new_start, new_end)
3641+
if gateway is not None and gateway.charge_window_uses_staging(*entities):
3642+
return None # Coordinator slot staging must precede its enable write.
3643+
if owners != ["gateway", "gateway"] or gateway is None:
3644+
self.base.record_status(f"Warn: Inverter {self.id} Hub charge window has inconsistent entity ownership", had_errors=True)
3645+
return False
3646+
if not all(self.check_write_entity("write_and_poll_gateway_charge_window", name, entity, value) for name, entity, value in zip(names, entities, target)):
3647+
return False
3648+
3649+
def read_pair():
3650+
"""Read both endpoint entities without substituting desired values."""
3651+
return tuple(self.base.get_state_wrapper(entity, refresh=True) for entity in entities)
3652+
3653+
before = read_pair()
3654+
ledger = self.base.control_ledger
3655+
if ledger is not None:
3656+
for name, entity, value, wanted in zip(names, entities, before, target):
3657+
self._ledger_observe(ledger, name, entity, value)
3658+
if value != wanted:
3659+
ledger.note_write_attempt(entity)
3660+
key = "|".join(entities)
3661+
verified = before == target
3662+
for _retry in range(0 if verified else self._write_attempts("charge_window", key, target)):
3663+
try:
3664+
published = run_async(gateway.update_charge_window(*entities, *target))
3665+
except Exception as error:
3666+
self.base.log(f"Warn: Inverter {self.id} Hub charge window publication failed: {error}")
3667+
break
3668+
if published is not True:
3669+
break
3670+
self._poll_after_write(entities[0], lambda state: state == new_start and self.base.get_state_wrapper(entities[1], refresh=True) == new_end)
3671+
verified = read_pair() == target
3672+
if verified:
3673+
self.count_register_writes += 1
3674+
self.registers_moved += sum(old != wanted for old, wanted in zip(before, target))
3675+
break
3676+
self._write_backoff_result("charge_window", key, target, verified)
3677+
if ledger is not None:
3678+
for name, entity, value in zip(names, entities, target):
3679+
if verified:
3680+
if before == target:
3681+
ledger.record_ownership_from_read(entity, name, value, now=time.time(), generation=self._ledger_generation(entity))
3682+
else:
3683+
ledger.record_write(entity, name, value, now=time.time(), generation=self._ledger_generation(entity))
3684+
else:
3685+
ledger.clear(entity)
3686+
if not verified:
3687+
message = f"Warn: Inverter {self.id} write to Hub charge window failed: wanted {target}, got {read_pair()}"
3688+
self.base.log(message)
3689+
self.base.record_status(message, had_errors=True)
3690+
return verified
3691+
36243692
def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):
36253693
"""
36263694
Adjust the charging window times (start and end) in GivTCP
@@ -3692,8 +3760,14 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):
36923760
# Some inverters have an idle time setting
36933761
self.adjust_idle_time(charge_start=new_start, charge_end=new_end)
36943762

3763+
gateway_window = None
3764+
if new_start != old_start or new_end != old_end:
3765+
gateway_window = self.write_and_poll_gateway_charge_window(new_start, new_end)
3766+
if gateway_window is False:
3767+
return
3768+
36953769
# Disable charging if required to avoid a blip while the start/end entities are written
3696-
if not in_new_window and ((new_start != old_start) or (new_end != old_end)) and self.inv_has_charge_enable_time:
3770+
if gateway_window is None and not in_new_window and ((new_start != old_start) or (new_end != old_end)) and self.inv_has_charge_enable_time:
36973771
self.disable_charge_window(notify=False)
36983772
have_disabled = True
36993773

@@ -3707,7 +3781,7 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):
37073781
# Registers this cycle's writes actually moved, for commit_needed() below
37083782
moved_before = self.registers_moved
37093783

3710-
if new_start != old_start or (self.inv_charge_time_format in ["H M", "H:M-H:M"]):
3784+
if gateway_window is None and (new_start != old_start or (self.inv_charge_time_format in ["H M", "H:M-H:M"])):
37113785
if "charge_start_time" in self.base.args:
37123786
# Always write to this as it is the GE default
37133787
entity_id_start = self.base.get_arg("charge_start_time", indirect=False, index=self.id)
@@ -3725,7 +3799,7 @@ def adjust_charge_window(self, charge_start_time, charge_end_time, minutes_now):
37253799
self.log("Warn: Inverter {} unable write charge window start as neither REST or charge_start_time are set".format(self.id))
37263800

37273801
# Program end slot
3728-
if new_end != old_end or (self.inv_charge_time_format in ["H M", "H:M-H:M"]):
3802+
if gateway_window is None and (new_end != old_end or (self.inv_charge_time_format in ["H M", "H:M-H:M"])):
37293803
if "charge_end_time" in self.base.args:
37303804
# Always write to this as it is the GE default
37313805
entity_id_end = self.base.get_arg("charge_end_time", indirect=False, index=self.id)

‎apps/predbat/tests/test_gateway.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7444,10 +7444,12 @@ def test_counters_restart_at_local_midnight(self):
74447444
def run_gateway_tests(my_predbat=None):
74457445
"""Run all GatewayMQTT tests. Returns True on failure, False on success."""
74467446
from tests.test_gateway_token_refresh import TestIsAuthFailure, TestApplyRefreshResponse, TestMaybeRefreshOnAuthError
7447+
from tests.test_gateway_charge_window import TestGatewayChargeWindow
74477448
from tests.test_gateway_discharge_window import TestGatewayDischargeWindow
74487449
from tests.test_gateway_display import TestGatewayDisplayOnly
74497450

74507451
test_classes = [
7452+
TestGatewayChargeWindow,
74517453
TestGatewayDischargeWindow,
74527454
TestGatewayDisplayOnly,
74537455
TestProtobufDecode,

0 commit comments

Comments
 (0)