Skip to content

Commit 856f632

Browse files
authored
Zigpy application-level retries (#260)
* Simplify retrying * Fix unit tests * Bump minimum zigpy version * Switch to `asyncio.timeout` if possible * Re-run CI * Deal with packet sending requiring working after assoc removal * Fix test flakiness, attempt 3
1 parent b75c656 commit 856f632

File tree

10 files changed

+118
-375
lines changed

10 files changed

+118
-375
lines changed

pyproject.toml

+2-2
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,8 @@ readme = "README.md"
1414
license = {text = "GPL-3.0"}
1515
requires-python = ">=3.8"
1616
dependencies = [
17-
"zigpy>=0.70.0",
18-
"async_timeout",
17+
"zigpy>=0.78.0",
18+
'async-timeout; python_version<"3.11"',
1919
"voluptuous",
2020
"coloredlogs",
2121
"jsonschema",

tests/api/test_request.py

+9-4
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,13 @@
1+
import sys
12
import asyncio
23
import logging
34

45
import pytest
5-
import async_timeout
6+
7+
if sys.version_info[:2] < (3, 11):
8+
from async_timeout import timeout as asyncio_timeout # pragma: no cover
9+
else:
10+
from asyncio import timeout as asyncio_timeout # pragma: no cover
611

712
import zigpy_znp.types as t
813
import zigpy_znp.config as conf
@@ -66,7 +71,7 @@ async def test_cleanup_timeout_external(connected_znp):
6671

6772
# This request will timeout because we didn't send anything back
6873
with pytest.raises(asyncio.TimeoutError):
69-
async with async_timeout.timeout(0.1):
74+
async with asyncio_timeout(0.1):
7075
await znp.request(c.UTIL.TimeAlive.Req())
7176

7277
# We should be cleaned up
@@ -80,7 +85,7 @@ async def test_callback_rsp_cleanup_timeout_external(connected_znp):
8085

8186
# This request will timeout because we didn't send anything back
8287
with pytest.raises(asyncio.TimeoutError):
83-
async with async_timeout.timeout(0.1):
88+
async with asyncio_timeout(0.1):
8489
await znp.request_callback_rsp(
8590
request=c.UTIL.TimeAlive.Req(),
8691
callback=c.SYS.ResetInd.Callback(partial=True),
@@ -262,7 +267,7 @@ async def test_znp_sreq_srsp(connected_znp):
262267

263268
# Each SREQ must have a corresponding SRSP, so this will fail
264269
with pytest.raises(asyncio.TimeoutError):
265-
async with async_timeout.timeout(0.5):
270+
async with asyncio_timeout(0.5):
266271
await znp.request(c.SYS.Ping.Req())
267272

268273
# This will work

tests/api/test_response.py

+8-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import sys
12
import asyncio
23

34
import pytest
4-
import async_timeout
5+
6+
if sys.version_info[:2] < (3, 11):
7+
from async_timeout import timeout as asyncio_timeout # pragma: no cover
8+
else:
9+
from asyncio import timeout as asyncio_timeout # pragma: no cover
510

611
import zigpy_znp.types as t
712
import zigpy_znp.commands as c
@@ -61,7 +66,7 @@ async def send_soon(delay):
6166

6267
asyncio.create_task(send_soon(0.1))
6368

64-
async with async_timeout.timeout(0.5):
69+
async with asyncio_timeout(0.5):
6570
assert (await znp.wait_for_response(c.SYS.Ping.Rsp(partial=True))) == response
6671

6772
# The response was successfully received so we should have no outstanding listeners
@@ -71,7 +76,7 @@ async def send_soon(delay):
7176
asyncio.create_task(send_soon(0.6))
7277

7378
with pytest.raises(asyncio.TimeoutError):
74-
async with async_timeout.timeout(0.5):
79+
async with asyncio_timeout(0.5):
7580
assert (
7681
await znp.wait_for_response(c.SYS.Ping.Rsp(partial=True))
7782
) == response

tests/application/test_joining.py

+2-3
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,8 @@ async def test_on_zdo_device_join(device, make_application, mocker):
205205

206206
app.handle_join.assert_called_once_with(nwk=nwk, ieee=ieee, parent_nwk=0x0001)
207207

208+
app.get_device(nwk=0x1234).cancel_initialization()
209+
208210
await app.shutdown()
209211

210212

@@ -269,9 +271,6 @@ async def test_on_zdo_device_join_and_announce_fast(device, make_application, mo
269271
app.get_device(ieee=ieee).cancel_initialization()
270272
await app.shutdown()
271273

272-
with pytest.raises(asyncio.CancelledError):
273-
await app.get_device(ieee=ieee)._initialize_task
274-
275274

276275
@mock.patch("zigpy_znp.zigbee.application.DEVICE_JOIN_MAX_DELAY", new=0.1)
277276
@mock.patch(

tests/application/test_requests.py

+11-182
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@
1010
import zigpy_znp.types as t
1111
import zigpy_znp.config as conf
1212
import zigpy_znp.commands as c
13-
from zigpy_znp.exceptions import InvalidCommandResponse
1413

1514
from ..conftest import (
1615
FORMED_DEVICES,
@@ -145,10 +144,10 @@ async def test_zigpy_request_failure(device, make_application, mocker):
145144
mocker.spy(app, "send_packet")
146145

147146
# Fail to turn on the light
148-
with pytest.raises(InvalidCommandResponse):
147+
with pytest.raises(DeliveryError):
149148
await device.endpoints[1].on_off.on()
150149

151-
assert app.send_packet.call_count == 1
150+
assert app.send_packet.call_count >= 1
152151
await app.shutdown()
153152

154153

@@ -425,7 +424,7 @@ async def inner():
425424

426425
asyncio.create_task(inner())
427426

428-
data_req = znp_server.reply_once_to(
427+
znp_server.reply_to(
429428
c.AF.DataRequestExt.Req(partial=True),
430429
responses=[
431430
c.AF.DataRequestExt.Rsp(Status=t.Status.SUCCESS),
@@ -444,7 +443,6 @@ async def inner():
444443
data=b"\x00",
445444
)
446445

447-
await data_req
448446
await delayed_reply_sent
449447

450448
assert app._znp._unhandled_command.call_count == 0
@@ -532,9 +530,6 @@ def set_route_discovered(req):
532530
await was_route_discovered
533531
await zdo_req
534532

535-
# 6 accounts for the loopback requests
536-
assert sum(c.value for c in app.state.counters["Retry_NONE"].values()) == 6 + 1
537-
538533
await app.shutdown()
539534

540535

@@ -583,15 +578,6 @@ def set_route_discovered(req):
583578
],
584579
)
585580

586-
# Ignore the source routing request as well
587-
znp_server.reply_to(
588-
c.AF.DataRequestSrcRtg.Req(partial=True),
589-
responses=[
590-
c.AF.DataRequestSrcRtg.Rsp(Status=t.Status.SUCCESS),
591-
data_confirm_replier,
592-
],
593-
)
594-
595581
await app.request(
596582
device=device,
597583
profile=260,
@@ -603,75 +589,6 @@ def set_route_discovered(req):
603589
)
604590

605591
await was_route_discovered
606-
assert (
607-
sum(c.value for c in app.state.counters["Retry_RouteDiscovery"].values()) == 1
608-
)
609-
610-
await app.shutdown()
611-
612-
613-
@pytest.mark.parametrize("device", [FormedLaunchpadCC26X2R1])
614-
async def test_request_recovery_use_ieee_addr(device, make_application, mocker):
615-
app, znp_server = make_application(server_cls=device)
616-
617-
await app.startup(auto_form=False)
618-
619-
# The data confirm timeout must be shorter than the ARSP timeout
620-
mocker.patch("zigpy_znp.zigbee.application.DATA_CONFIRM_TIMEOUT", new=0.1)
621-
app._znp._config[conf.CONF_ZNP_CONFIG][conf.CONF_ARSP_TIMEOUT] = 1
622-
623-
device = app.add_initialized_device(ieee=t.EUI64(range(8)), nwk=0xABCD)
624-
625-
was_ieee_addr_used = False
626-
627-
def data_confirm_replier(req):
628-
nonlocal was_ieee_addr_used
629-
630-
if req.DstAddrModeAddress.mode == t.AddrMode.IEEE:
631-
status = t.Status.SUCCESS
632-
was_ieee_addr_used = True
633-
else:
634-
status = t.Status.MAC_NO_ACK
635-
636-
return c.AF.DataConfirm.Callback(Status=status, Endpoint=1, TSN=1)
637-
638-
znp_server.reply_once_to(
639-
c.ZDO.ExtRouteDisc.Req(
640-
Dst=device.nwk, Options=c.zdo.RouteDiscoveryOptions.UNICAST, partial=True
641-
),
642-
responses=[c.ZDO.ExtRouteDisc.Rsp(Status=t.Status.SUCCESS)],
643-
)
644-
645-
znp_server.reply_to(
646-
c.AF.DataRequestExt.Req(partial=True),
647-
responses=[
648-
c.AF.DataRequestExt.Rsp(Status=t.Status.SUCCESS),
649-
data_confirm_replier,
650-
],
651-
)
652-
653-
# Ignore the source routing request as well
654-
znp_server.reply_to(
655-
c.AF.DataRequestSrcRtg.Req(partial=True),
656-
responses=[
657-
c.AF.DataRequestSrcRtg.Rsp(Status=t.Status.SUCCESS),
658-
c.AF.DataConfirm.Callback(Status=t.Status.MAC_NO_ACK, Endpoint=1, TSN=1),
659-
],
660-
)
661-
662-
await app.request(
663-
device=device,
664-
profile=260,
665-
cluster=1,
666-
src_ep=1,
667-
dst_ep=1,
668-
sequence=1,
669-
data=b"\x00",
670-
)
671-
672-
assert was_ieee_addr_used
673-
assert sum(c.value for c in app.state.counters["Retry_IEEEAddress"].values()) == 1
674-
675592
await app.shutdown()
676593

677594

@@ -686,7 +603,6 @@ async def test_request_recovery_assoc_remove(
686603
await app.startup(auto_form=False)
687604

688605
mocker.patch("zigpy_znp.zigbee.application.DATA_CONFIRM_TIMEOUT", new=0.1)
689-
mocker.patch("zigpy_znp.zigbee.application.REQUEST_ERROR_RETRY_DELAY", new=0)
690606

691607
app._znp._config[conf.CONF_ZNP_CONFIG][conf.CONF_ARSP_TIMEOUT] = 1
692608

@@ -713,14 +629,6 @@ def data_confirm_replier(req):
713629
],
714630
)
715631

716-
znp_server.reply_to(
717-
c.AF.DataRequestSrcRtg.Req(partial=True),
718-
responses=[
719-
c.AF.DataRequestSrcRtg.Rsp(Status=t.Status.SUCCESS),
720-
data_confirm_replier,
721-
],
722-
)
723-
724632
def assoc_get_with_addr(req):
725633
nonlocal assoc_device
726634

@@ -730,7 +638,7 @@ def assoc_get_with_addr(req):
730638

731639
return c.UTIL.AssocGetWithAddress.Rsp(Device=assoc_device)
732640

733-
did_assoc_get = znp_server.reply_once_to(
641+
did_assoc_get = znp_server.reply_to(
734642
c.UTIL.AssocGetWithAddress.Req(IEEE=device.ieee, partial=True),
735643
responses=[assoc_get_with_addr],
736644
)
@@ -750,12 +658,12 @@ def assoc_remove(req):
750658
assoc_device = None
751659
return c.UTIL.AssocRemove.Rsp(Status=t.Status.SUCCESS)
752660

753-
did_assoc_remove = znp_server.reply_once_to(
661+
did_assoc_remove = znp_server.reply_to(
754662
c.UTIL.AssocRemove.Req(IEEE=device.ieee),
755663
responses=[assoc_remove],
756664
)
757665

758-
did_assoc_add = znp_server.reply_once_to(
666+
did_assoc_add = znp_server.reply_to(
759667
c.UTIL.AssocAdd.Req(
760668
NWK=device.nwk,
761669
IEEE=device.ieee,
@@ -791,102 +699,23 @@ def assoc_remove(req):
791699
await req
792700

793701
if fw_assoc_remove:
794-
await did_assoc_remove
702+
assert len(did_assoc_remove.mock_calls) >= 1
795703

796704
if final_status != t.Status.SUCCESS:
797705
# The association is re-added on failure
798-
await did_assoc_add
706+
assert len(did_assoc_add.mock_calls) >= 1
799707
else:
800-
assert not did_assoc_add.done()
708+
assert len(did_assoc_add.mock_calls) == 0
801709
elif issubclass(device_cls, FormedLaunchpadCC26X2R1):
802-
await did_assoc_get
803-
assert was_route_discovered.call_count >= 1
710+
assert len(did_assoc_get.mock_calls) >= 1
804711
else:
805712
# Don't even attempt this with older firmwares
806-
assert not did_assoc_get.done()
713+
assert len(did_assoc_get.mock_calls) == 0
807714
assert was_route_discovered.call_count == 0
808715

809716
await app.shutdown()
810717

811718

812-
@pytest.mark.parametrize("device", [FormedLaunchpadCC26X2R1])
813-
@pytest.mark.parametrize("succeed", [True, False])
814-
@pytest.mark.parametrize("relays", [[0x1111, 0x2222, 0x3333], []])
815-
async def test_request_recovery_manual_source_route(
816-
device, succeed, relays, make_application, mocker
817-
):
818-
app, znp_server = make_application(server_cls=device)
819-
820-
await app.startup(auto_form=False)
821-
822-
mocker.patch("zigpy_znp.zigbee.application.DATA_CONFIRM_TIMEOUT", new=0.1)
823-
mocker.patch("zigpy_znp.zigbee.application.REQUEST_ERROR_RETRY_DELAY", new=0)
824-
825-
app._znp._config[conf.CONF_ZNP_CONFIG][conf.CONF_ARSP_TIMEOUT] = 1
826-
827-
device = app.add_initialized_device(ieee=t.EUI64(range(8)), nwk=0xABCD)
828-
device.relays = relays
829-
830-
def data_confirm_replier(req):
831-
if isinstance(req, c.AF.DataRequestExt.Req) or not succeed:
832-
return c.AF.DataConfirm.Callback(
833-
Status=t.Status.MAC_NO_ACK,
834-
Endpoint=1,
835-
TSN=1,
836-
)
837-
else:
838-
return c.AF.DataConfirm.Callback(
839-
Status=t.Status.SUCCESS,
840-
Endpoint=1,
841-
TSN=1,
842-
)
843-
844-
normal_data_request = znp_server.reply_to(
845-
c.AF.DataRequestExt.Req(partial=True),
846-
responses=[
847-
c.AF.DataRequestExt.Rsp(Status=t.Status.SUCCESS),
848-
data_confirm_replier,
849-
],
850-
)
851-
852-
source_routing_data_request = znp_server.reply_to(
853-
c.AF.DataRequestSrcRtg.Req(partial=True),
854-
responses=[
855-
c.AF.DataRequestSrcRtg.Rsp(Status=t.Status.SUCCESS),
856-
data_confirm_replier,
857-
],
858-
)
859-
860-
znp_server.reply_to(
861-
c.ZDO.ExtRouteDisc.Req(
862-
Dst=device.nwk, Options=c.zdo.RouteDiscoveryOptions.UNICAST, partial=True
863-
),
864-
responses=[c.ZDO.ExtRouteDisc.Rsp(Status=t.Status.SUCCESS)],
865-
)
866-
867-
req = app.request(
868-
device=device,
869-
profile=260,
870-
cluster=1,
871-
src_ep=1,
872-
dst_ep=1,
873-
sequence=1,
874-
data=b"\x00",
875-
)
876-
877-
if succeed:
878-
await req
879-
else:
880-
with pytest.raises(DeliveryError):
881-
await req
882-
883-
# In either case only one source routing attempt is performed
884-
assert source_routing_data_request.call_count == 1
885-
assert normal_data_request.call_count >= 1
886-
887-
await app.shutdown()
888-
889-
890719
@pytest.mark.parametrize("device", [FormedLaunchpadCC26X2R1])
891720
async def test_route_discovery_concurrency(device, make_application):
892721
app, znp_server = make_application(server_cls=device)

0 commit comments

Comments
 (0)