Skip to content

Commit bc7e960

Browse files
authored
Detect when the firmware responds with the wrong response (#241)
* Handle mismatched `seq` commands * Globally retry all commands in case of `MismatchedResponseError` * Add a unit test and make sure command futures are always removed * Exclude `if TYPE_CHECKING` from coverage
1 parent cc1f018 commit bc7e960

File tree

4 files changed

+120
-25
lines changed

4 files changed

+120
-25
lines changed

pyproject.toml

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,4 +60,12 @@ ignore = [
6060
"W503", "E203", "E501", "D202",
6161
"D103", "D102", "D101", # TODO: remove these once docstrings are added
6262
]
63-
per-file-ignores = ["tests/*:F811,F401,F403"]
63+
per-file-ignores = ["tests/*:F811,F401,F403"]
64+
65+
[tool.coverage.report]
66+
exclude_also = [
67+
"raise AssertionError",
68+
"raise NotImplementedError",
69+
"if (typing\\.)?TYPE_CHECKING:",
70+
"@(abc\\.)?abstractmethod",
71+
]

tests/test_api.py

Lines changed: 43 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@
1010
import zigpy.config
1111
import zigpy.types as zigpy_t
1212

13+
if sys.version_info[:2] < (3, 11):
14+
from async_timeout import timeout as asyncio_timeout
15+
else:
16+
from asyncio import timeout as asyncio_timeout
17+
1318
from zigpy_deconz import api as deconz_api, types as t, uart
1419
import zigpy_deconz.exception
1520
import zigpy_deconz.zigbee.application
@@ -86,7 +91,7 @@ async def mock_connect(config, api):
8691

8792
@pytest.fixture
8893
async def mock_command_rsp(gateway):
89-
def inner(command_id, params, rsp, *, replace=False):
94+
def inner(command_id, params, rsp, *, rsp_command=None, replace=False):
9095
if (
9196
getattr(getattr(gateway.send, "side_effect", None), "_handlers", None)
9297
is None
@@ -107,15 +112,18 @@ def receiver(data):
107112

108113
kwargs, rest = t.deserialize_dict(command.payload, schema)
109114

110-
for params, mock in receiver._handlers[command.command_id]:
115+
for params, rsp_command, mock in receiver._handlers[command.command_id]:
116+
if rsp_command is None:
117+
rsp_command = command.command_id
118+
111119
if all(kwargs[k] == v for k, v in params.items()):
112-
_, rx_schema = deconz_api.COMMAND_SCHEMAS[command.command_id]
120+
_, rx_schema = deconz_api.COMMAND_SCHEMAS[rsp_command]
113121
ret = mock(**kwargs)
114122

115123
asyncio.get_running_loop().call_soon(
116124
gateway._api.data_received,
117125
deconz_api.Command(
118-
command_id=command.command_id,
126+
command_id=rsp_command,
119127
seq=command.seq,
120128
payload=t.serialize_dict(ret, rx_schema),
121129
).serialize(),
@@ -128,7 +136,9 @@ def receiver(data):
128136
gateway.send.side_effect._handlers[command_id].clear()
129137

130138
mock = MagicMock(return_value=rsp)
131-
gateway.send.side_effect._handlers[command_id].append((params, mock))
139+
gateway.send.side_effect._handlers[command_id].append(
140+
(params, rsp_command, mock)
141+
)
132142

133143
return mock
134144

@@ -993,3 +1003,31 @@ async def test_cb3_device_state_callback_bug(api, mock_command_rsp):
9931003
await asyncio.sleep(0.01)
9941004

9951005
assert api._device_state == device_state
1006+
1007+
1008+
async def test_firmware_responding_with_wrong_type_with_correct_seq(
1009+
api, mock_command_rsp, caplog
1010+
):
1011+
await api.connect()
1012+
1013+
mock_command_rsp(
1014+
command_id=deconz_api.CommandId.aps_data_confirm,
1015+
params={},
1016+
# Completely different response
1017+
rsp_command=deconz_api.CommandId.version,
1018+
rsp={
1019+
"status": deconz_api.Status.SUCCESS,
1020+
"frame_length": t.uint16_t(9),
1021+
"version": deconz_api.FirmwareVersion(0x26450900),
1022+
},
1023+
)
1024+
1025+
with caplog.at_level(logging.DEBUG):
1026+
with pytest.raises(asyncio.TimeoutError):
1027+
async with asyncio_timeout(0.5):
1028+
await api.send_command(deconz_api.CommandId.aps_data_confirm)
1029+
1030+
assert (
1031+
"Firmware responded incorrectly (Response is mismatched! Sent"
1032+
" <CommandId.aps_data_confirm: 4>, received <CommandId.version: 13>), retrying"
1033+
) in caplog.text

zigpy_deconz/api.py

Lines changed: 51 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from __future__ import annotations
44

55
import asyncio
6+
import collections
67
import itertools
78
import logging
89
import sys
@@ -25,7 +26,7 @@
2526
)
2627
from zigpy.zdo.types import SimpleDescriptor
2728

28-
from zigpy_deconz.exception import APIException, CommandError
29+
from zigpy_deconz.exception import APIException, CommandError, MismatchedResponseError
2930
import zigpy_deconz.types as t
3031
import zigpy_deconz.uart
3132
from zigpy_deconz.utils import restart_forever
@@ -415,7 +416,9 @@ class Deconz:
415416
def __init__(self, app: Callable, device_config: dict[str, Any]):
416417
"""Init instance."""
417418
self._app = app
418-
self._awaiting = {}
419+
420+
# [seq][cmd_id] = [fut1, fut2, ...]
421+
self._awaiting = collections.defaultdict(lambda: collections.defaultdict(list))
419422
self._command_lock = asyncio.Lock()
420423
self._config = device_config
421424
self._device_state = DeviceState(
@@ -459,7 +462,7 @@ async def connect(self) -> None:
459462

460463
await self.version()
461464

462-
device_state_rsp = await self._command(CommandId.device_state)
465+
device_state_rsp = await self.send_command(CommandId.device_state)
463466
self._device_state = device_state_rsp["device_state"]
464467

465468
self._data_poller_task = asyncio.create_task(self._data_poller())
@@ -486,6 +489,13 @@ def close(self):
486489
self._uart.close()
487490
self._uart = None
488491

492+
async def send_command(self, cmd, **kwargs) -> Any:
493+
while True:
494+
try:
495+
return await self._command(cmd, **kwargs)
496+
except MismatchedResponseError as exc:
497+
LOGGER.debug("Firmware responded incorrectly (%s), retrying", exc)
498+
489499
async def _command(self, cmd, **kwargs):
490500
payload = []
491501
tx_schema, _ = COMMAND_SCHEMAS[cmd]
@@ -556,17 +566,16 @@ async def _command(self, cmd, **kwargs):
556566
self._seq = (self._seq % 255) + 1
557567

558568
fut = asyncio.Future()
559-
self._awaiting[seq, cmd] = fut
569+
self._awaiting[seq][cmd].append(fut)
560570

561571
try:
562572
async with asyncio_timeout(COMMAND_TIMEOUT):
563573
return await fut
564574
except asyncio.TimeoutError:
565-
LOGGER.warning(
566-
"No response to '%s' command with seq id '0x%02x'", cmd, seq
567-
)
568-
self._awaiting.pop((seq, cmd), None)
575+
LOGGER.debug("No response to '%s' command with seq %d", cmd, seq)
569576
raise
577+
finally:
578+
self._awaiting[seq][cmd].remove(fut)
570579

571580
def data_received(self, data: bytes) -> None:
572581
command, _ = Command.deserialize(data)
@@ -577,7 +586,19 @@ def data_received(self, data: bytes) -> None:
577586

578587
_, rx_schema = COMMAND_SCHEMAS[command.command_id]
579588

580-
fut = self._awaiting.pop((command.seq, command.command_id), None)
589+
fut = None
590+
wrong_fut_cmd_id = None
591+
592+
try:
593+
fut = self._awaiting[command.seq][command.command_id][0]
594+
except IndexError:
595+
# XXX: The firmware can sometimes respond with the wrong response. Find the
596+
# future associated with it so we can throw an appropriate error.
597+
for cmd_id, futs in self._awaiting[command.seq].items():
598+
if futs:
599+
fut = futs[0]
600+
wrong_fut_cmd_id = cmd_id
601+
break
581602

582603
try:
583604
params, rest = t.deserialize_dict(command.payload, rx_schema)
@@ -614,7 +635,16 @@ def data_received(self, data: bytes) -> None:
614635

615636
exc = None
616637

617-
if status != Status.SUCCESS:
638+
if wrong_fut_cmd_id is not None:
639+
exc = MismatchedResponseError(
640+
command.command_id,
641+
params,
642+
(
643+
f"Response is mismatched! Sent {wrong_fut_cmd_id},"
644+
f" received {command.command_id}"
645+
),
646+
)
647+
elif status != Status.SUCCESS:
618648
exc = CommandError(status, f"{command.command_id}, status: {status}")
619649

620650
if fut is not None:
@@ -665,7 +695,9 @@ async def _data_poller(self):
665695
else:
666696
flags = t.DataIndicationFlags.Always_Use_NWK_Source_Addr
667697

668-
rsp = await self._command(CommandId.aps_data_indication, flags=flags)
698+
rsp = await self.send_command(
699+
CommandId.aps_data_indication, flags=flags
700+
)
669701
self._handle_device_state_changed(
670702
status=rsp["status"], device_state=rsp["device_state"]
671703
)
@@ -687,7 +719,7 @@ async def _data_poller(self):
687719

688720
# Poll data confirm
689721
if DeviceStateFlags.APSDE_DATA_CONFIRM in self._device_state.device_state:
690-
rsp = await self._command(CommandId.aps_data_confirm)
722+
rsp = await self.send_command(CommandId.aps_data_confirm)
691723

692724
self._app.handle_tx_confirm(rsp["request_id"], rsp["confirm_status"])
693725
self._handle_device_state_changed(
@@ -738,7 +770,7 @@ async def version(self):
738770
NetworkParameter.protocol_version
739771
)
740772

741-
version_rsp = await self._command(CommandId.version, reserved=0)
773+
version_rsp = await self.send_command(CommandId.version, reserved=0)
742774
self._firmware_version = version_rsp["version"]
743775

744776
return self.firmware_version
@@ -753,7 +785,7 @@ async def read_parameter(
753785
else:
754786
value = read_param_type(parameter).serialize()
755787

756-
rsp = await self._command(
788+
rsp = await self.send_command(
757789
CommandId.read_parameter,
758790
parameter_id=parameter_id,
759791
parameter=value,
@@ -770,7 +802,7 @@ async def write_parameter(
770802
self, parameter_id: NetworkParameter, parameter: Any
771803
) -> None:
772804
read_param_type, write_param_type = NETWORK_PARAMETER_TYPES[parameter_id]
773-
await self._command(
805+
await self.send_command(
774806
CommandId.write_parameter,
775807
parameter_id=parameter_id,
776808
parameter=write_param_type(parameter).serialize(),
@@ -803,7 +835,7 @@ async def aps_data_request(
803835
await self._free_slots_available_event.wait()
804836

805837
try:
806-
rsp = await self._command(
838+
rsp = await self.send_command(
807839
CommandId.aps_data_request,
808840
request_id=req_id,
809841
flags=flags,
@@ -830,17 +862,17 @@ async def aps_data_request(
830862
return
831863

832864
async def get_device_state(self) -> DeviceState:
833-
rsp = await self._command(CommandId.device_state)
865+
rsp = await self.send_command(CommandId.device_state)
834866

835867
return rsp["device_state"]
836868

837869
async def change_network_state(self, new_state: NetworkState) -> None:
838-
await self._command(CommandId.change_network_state, network_state=new_state)
870+
await self.send_command(CommandId.change_network_state, network_state=new_state)
839871

840872
async def add_neighbour(
841873
self, nwk: t.NWK, ieee: t.EUI64, mac_capability_flags: t.uint8_t
842874
) -> None:
843-
await self._command(
875+
await self.send_command(
844876
CommandId.update_neighbor,
845877
action=UpdateNeighborAction.ADD,
846878
nwk=nwk,

zigpy_deconz/exception.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,14 @@
11
"""Zigpy-deconz exceptions."""
22

3+
from __future__ import annotations
4+
5+
import typing
6+
37
from zigpy.exceptions import APIException
48

9+
if typing.TYPE_CHECKING:
10+
from zigpy_deconz.api import CommandId
11+
512

613
class CommandError(APIException):
714
def __init__(self, status=1, *args, **kwargs):
@@ -12,3 +19,13 @@ def __init__(self, status=1, *args, **kwargs):
1219
@property
1320
def status(self):
1421
return self._status
22+
23+
24+
class MismatchedResponseError(APIException):
25+
def __init__(
26+
self, command_id: CommandId, params: dict[str, typing.Any], *args, **kwargs
27+
) -> None:
28+
"""Initialize instance."""
29+
super().__init__(*args, **kwargs)
30+
self.command_id = command_id
31+
self.params = params

0 commit comments

Comments
 (0)