Skip to content

Commit f98eae2

Browse files
authored
bugfix: avoid request collisions when enabling timed charging (#152)
1 parent 2463176 commit f98eae2

4 files changed

Lines changed: 39 additions & 6 deletions

File tree

custom_components/givenergy_local/coordinator.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@
4343
_RECONNECT_BACKOFF_MAX = 60.0
4444

4545

46+
def _dedupe_requests(requests: list[TransparentRequest]) -> list[TransparentRequest]:
47+
"""Collapse repeat writes to the same register within a single batch.
48+
49+
Client.execute() gathers requests concurrently, and Client keys in-flight
50+
requests by expected-response shape - which covers the register, not the
51+
value. Two requests sharing a shape make the second cancel the first in
52+
flight, and gather(return_exceptions=False) then aborts the whole batch
53+
with the resulting CancelledError. Later entries win, so an intentional
54+
final value for a register is preserved.
55+
"""
56+
unique: dict[int, TransparentRequest] = {}
57+
for request in requests:
58+
key = request.expected_response().shape_hash()
59+
if key in unique:
60+
_LOGGER.warning("Dropping duplicate request from batch: %s", request)
61+
unique[key] = request
62+
return list(unique.values())
63+
64+
4665
class GivEnergyUpdateCoordinator(DataUpdateCoordinator[Plant]):
4766
"""Update coordinator that fetches data from a GivEnergy inverter."""
4867

@@ -216,6 +235,7 @@ async def _async_update_data(self) -> Plant:
216235

217236
async def execute(self, requests: list[TransparentRequest]) -> None:
218237
"""Execute a set of requests and force an update to read any new values."""
238+
requests = _dedupe_requests(requests)
219239
try:
220240
async with asyncio.timeout(_EXECUTE_TIMEOUT):
221241
await self.client.execute(requests, _COMMAND_TIMEOUT, _COMMAND_RETRIES)

custom_components/givenergy_local/manifest.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,5 +13,5 @@
1313
"requirements": [
1414
"givenergy-modbus>=2.13.0,<3"
1515
],
16-
"version": "2.5.2"
16+
"version": "2.5.3"
1717
}

custom_components/givenergy_local/services.py

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from givenergy_modbus.client import commands as ge_commands
1414
from givenergy_modbus.model import TimeSlot
15+
from givenergy_modbus.model.slot_map import SINGLE_PHASE_SLOTS
1516
from givenergy_modbus.pdu.transparent import TransparentRequest
1617

1718
from .const import DOMAIN, LOGGER
@@ -184,12 +185,19 @@ async def _async_enable_timed_charge(hass: HomeAssistant, data: dict[str, Any])
184185
Note that this isn't a battery mode like "Timed Discharge", "Eco", etc. It operates in
185186
parallel to those modes.
186187
"""
187-
commands = ge_commands.set_enable_charge(True)
188+
commands: list[TransparentRequest] = []
188189

189-
if _ATTR_START_TIME in data and _ATTR_END_TIME in data:
190+
if _ATTR_START_TIME in data:
190191
start_time = datetime.time.fromisoformat(data[_ATTR_START_TIME])
192+
commands.extend(
193+
ge_commands.set_charge_slot_start(1, start_time, SINGLE_PHASE_SLOTS)
194+
)
195+
196+
if _ATTR_END_TIME in data:
191197
end_time = datetime.time.fromisoformat(data[_ATTR_END_TIME])
192-
commands.extend(ge_commands.set_charge_slot_1(TimeSlot(start_time, end_time)))
198+
commands.extend(
199+
ge_commands.set_charge_slot_end(1, end_time, SINGLE_PHASE_SLOTS)
200+
)
193201

194202
if _ATTR_CHARGE_TARGET in data:
195203
target_soc = int(data[_ATTR_CHARGE_TARGET])
@@ -198,8 +206,13 @@ async def _async_enable_timed_charge(hass: HomeAssistant, data: dict[str, Any])
198206
# bounces between 99-100% in a charge/discharge cycle, so avoid this, matching
199207
# behaviour of GivEnergy logic. set_charge_target_enabled() applies exactly
200208
# this rule: it enables charging and, for a target of 100%, clears the charge
201-
# target rather than setting it.
209+
# target rather than setting it. It also enables charging itself, so it must
210+
# not be combined with a separate set_enable_charge() call below - the two
211+
# would write ENABLE_CHARGE (HR 96) twice in the same batch, and the client
212+
# cancels the first of any two same-register writes it sees in flight together.
202213
commands.extend(ge_commands.set_charge_target_enabled(target_soc))
214+
else:
215+
commands.extend(ge_commands.set_enable_charge(True))
203216

204217
LOGGER.debug("Activating timed charge mode")
205218
await _async_service_call(hass, data[ATTR_DEVICE_ID], commands)

requirements_test.txt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
1-
givenergy-modbus>=2.12.1,<3
1+
givenergy-modbus>=2.13.0,<3
22
pytest-homeassistant-custom-component

0 commit comments

Comments
 (0)