Skip to content

Commit df9c0c6

Browse files
authored
Allow delayed duplicate responses (#244)
* Demote duplicate or delayed response to debug, not warning * Log the waiting response queue * Delay `MismatchedResponseError` in case the firmware eventually responds * Add some logging * Make sure we do not resolve the future right away * Add a unit test
1 parent 76cdef5 commit df9c0c6

File tree

2 files changed

+94
-7
lines changed

2 files changed

+94
-7
lines changed

tests/test_api.py

Lines changed: 67 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1024,7 +1024,8 @@ async def test_firmware_responding_with_wrong_type_with_correct_seq(
10241024

10251025
with caplog.at_level(logging.DEBUG):
10261026
with pytest.raises(asyncio.TimeoutError):
1027-
async with asyncio_timeout(0.5):
1027+
# We wait beyond 500ms to make sure it triggers
1028+
async with asyncio_timeout(0.6):
10281029
await api.send_command(deconz_api.CommandId.aps_data_confirm)
10291030

10301031
assert (
@@ -1033,6 +1034,71 @@ async def test_firmware_responding_with_wrong_type_with_correct_seq(
10331034
) in caplog.text
10341035

10351036

1037+
async def test_firmware_responding_with_wrong_type_with_correct_seq_eventual_response(
1038+
api, mock_command_rsp, caplog
1039+
):
1040+
await api.connect()
1041+
1042+
mock_command_rsp(
1043+
command_id=deconz_api.CommandId.aps_data_confirm,
1044+
params={},
1045+
# Completely different response
1046+
rsp_command=deconz_api.CommandId.version,
1047+
rsp={
1048+
"status": deconz_api.Status.SUCCESS,
1049+
"frame_length": t.uint16_t(9),
1050+
"version": deconz_api.FirmwareVersion(0x26450900),
1051+
},
1052+
)
1053+
1054+
with caplog.at_level(logging.DEBUG):
1055+
_, rx_schema = deconz_api.COMMAND_SCHEMAS[deconz_api.CommandId.aps_data_confirm]
1056+
1057+
asyncio.get_running_loop().call_later(
1058+
0.1,
1059+
api.data_received,
1060+
deconz_api.Command(
1061+
command_id=deconz_api.CommandId.aps_data_confirm,
1062+
seq=api._seq,
1063+
payload=t.serialize_dict(
1064+
{
1065+
"status": deconz_api.Status.SUCCESS,
1066+
"frame_length": t.uint16_t(19),
1067+
"payload_length": t.uint16_t(12),
1068+
"device_state": deconz_api.DeviceState(
1069+
network_state=deconz_api.NetworkState2.CONNECTED,
1070+
device_state=(
1071+
deconz_api.DeviceStateFlags.APSDE_DATA_REQUEST_FREE_SLOTS_AVAILABLE
1072+
| deconz_api.DeviceStateFlags.APSDE_DATA_INDICATION
1073+
),
1074+
),
1075+
"request_id": t.uint8_t(16),
1076+
"dst_addr": t.DeconzAddressEndpoint.deserialize(
1077+
b"\x02\xaa\x55\x01"
1078+
)[0],
1079+
"src_ep": t.uint8_t(0),
1080+
"confirm_status": deconz_api.TXStatus.SUCCESS,
1081+
"reserved1": t.uint8_t(0),
1082+
"reserved2": t.uint8_t(0),
1083+
"reserved3": t.uint8_t(0),
1084+
"reserved4": t.uint8_t(0),
1085+
},
1086+
rx_schema,
1087+
),
1088+
).serialize(),
1089+
)
1090+
1091+
async with asyncio_timeout(0.2):
1092+
rsp = await api.send_command(deconz_api.CommandId.aps_data_confirm)
1093+
1094+
assert rsp["request_id"] == 16
1095+
1096+
assert (
1097+
"Firmware responded incorrectly (Response is mismatched! Sent"
1098+
" <CommandId.aps_data_confirm: 4>, received <CommandId.version: 13>), retrying"
1099+
) not in caplog.text
1100+
1101+
10361102
def test_get_command_priority(api):
10371103
assert (
10381104
api._get_command_priority(

zigpy_deconz/api.py

Lines changed: 27 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434

3535
LOGGER = logging.getLogger(__name__)
3636

37+
MISMATCHED_RESPONSE_TIMEOUT = 0.5
3738
COMMAND_TIMEOUT = 1.8
3839
PROBE_TIMEOUT = 2
3940
REQUEST_RETRY_DELAYS = (0.5, 1.0, 1.5, None)
@@ -420,6 +421,7 @@ def __init__(self, app: Callable, device_config: dict[str, Any]):
420421

421422
# [seq][cmd_id] = [fut1, fut2, ...]
422423
self._awaiting = collections.defaultdict(lambda: collections.defaultdict(list))
424+
self._mismatched_response_timers: dict[int, asyncio.TimerHandle] = {}
423425
self._command_lock = PriorityLock()
424426
self._config = device_config
425427
self._device_state = DeviceState(
@@ -646,15 +648,34 @@ def data_received(self, data: bytes) -> None:
646648

647649
exc = None
648650

651+
# Make sure to clear any pending mismatched response timers
652+
if command.seq in self._mismatched_response_timers:
653+
LOGGER.debug("Clearing existing mismatched response timer")
654+
self._mismatched_response_timers.pop(command.seq).cancel()
655+
649656
if wrong_fut_cmd_id is not None:
650-
exc = MismatchedResponseError(
651-
command.command_id,
652-
params,
653-
(
654-
f"Response is mismatched! Sent {wrong_fut_cmd_id},"
655-
f" received {command.command_id}"
657+
LOGGER.debug(
658+
"Mismatched response, triggering error in %0.2fs",
659+
MISMATCHED_RESPONSE_TIMEOUT,
660+
)
661+
# The firmware *sometimes* responds with the correct response later
662+
self._mismatched_response_timers[
663+
command.seq
664+
] = asyncio.get_event_loop().call_later(
665+
MISMATCHED_RESPONSE_TIMEOUT,
666+
fut.set_exception,
667+
MismatchedResponseError(
668+
command.command_id,
669+
params,
670+
(
671+
f"Response is mismatched! Sent {wrong_fut_cmd_id},"
672+
f" received {command.command_id}"
673+
),
656674
),
657675
)
676+
677+
# Make sure we do not resolve the future
678+
fut = None
658679
elif status != Status.SUCCESS:
659680
exc = CommandError(status, f"{command.command_id}, status: {status}")
660681

0 commit comments

Comments
 (0)