diff --git a/pyproject.toml b/pyproject.toml index d3153d2..3e4067d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,7 +14,7 @@ readme = "README.md" license = {text = "GPL-3.0"} requires-python = ">=3.11" dependencies = [ - "zigpy>=1.5.0", + "zigpy>=2.4.0", "voluptuous", "coloredlogs", "jsonschema", diff --git a/tests/application/test_joining.py b/tests/application/test_joining.py index 886a92d..4565e9d 100644 --- a/tests/application/test_joining.py +++ b/tests/application/test_joining.py @@ -39,7 +39,7 @@ async def test_permit_join(device, mocker, make_application): request=zdo_request_matcher( dst_addr=t.AddrModeAddress(t.AddrMode.Broadcast, 0xFFFC), command_id=zdo_t.ZDOCmd.Mgmt_Permit_Joining_req, - TSN=2, + TSN=4, zdo_PermitDuration=10, zdo_TC_Significant=0, ), diff --git a/tests/application/test_requests.py b/tests/application/test_requests.py index f073cf6..b0072aa 100644 --- a/tests/application/test_requests.py +++ b/tests/application/test_requests.py @@ -13,8 +13,10 @@ from ..conftest import ( FORMED_DEVICES, + BaseLaunchpadCC26X2R1, CoroutineMock, FormedLaunchpadCC26X2R1, + FormedLaunchpadCC26X2R1WithoutAssocCommands, serialize_zdo_command, zdo_request_matcher, ) @@ -608,9 +610,11 @@ async def test_request_recovery_route_rediscovery_then_assoc_failure( device = app.add_initialized_device(ieee=t.EUI64(range(8)), nwk=0xABCD) - assoc_device, _ = c.util.Device.deserialize(b"\xff" * 100) - assoc_device.shortAddr = device.nwk - assoc_device.nodeRelation = c.util.NodeRelation.CHILD_FFD_RX_IDLE + znp_server.assoc_add_new( + nwk=device.nwk, + ieee=device.ieee, + node_relation=c.util.NodeRelation.CHILD_FFD_RX_IDLE, + ) # First attempt fails with NWK_NO_ROUTE, the retried attempt fails with a # first-time MAC_TRANSACTION_EXPIRED association failure @@ -633,24 +637,9 @@ def data_confirm_replier(req): ], ) - znp_server.reply_to( - c.UTIL.AssocGetWithAddress.Req(IEEE=device.ieee, partial=True), - responses=[c.UTIL.AssocGetWithAddress.Rsp(Device=assoc_device)], - ) - - did_assoc_remove = znp_server.reply_to( - c.UTIL.AssocRemove.Req(IEEE=device.ieee), - responses=[c.UTIL.AssocRemove.Rsp(Status=t.Status.SUCCESS)], - ) - - did_assoc_add = znp_server.reply_to( - c.UTIL.AssocAdd.Req( - NWK=device.nwk, - IEEE=device.ieee, - NodeRelation=c.util.NodeRelation.CHILD_FFD_RX_IDLE, - ), - responses=[c.UTIL.AssocAdd.Rsp(Status=t.Status.SUCCESS)], - ) + # The association table responds on its own; just watch the mutations + did_assoc_remove = mocker.spy(znp_server, "assoc_remove") + did_assoc_add = mocker.spy(znp_server, "assoc_add_new") was_route_discovered = znp_server.reply_to( c.ZDO.ExtRouteDisc.Req( @@ -681,12 +670,18 @@ def data_confirm_replier(req): await app.shutdown() -@pytest.mark.parametrize("device_cls", FORMED_DEVICES) -@pytest.mark.parametrize("fw_assoc_remove", [True, False]) +@pytest.mark.parametrize( + "device_cls", FORMED_DEVICES + [FormedLaunchpadCC26X2R1WithoutAssocCommands] +) @pytest.mark.parametrize("final_status", [t.Status.SUCCESS, t.Status.APS_NO_ACK]) async def test_request_recovery_assoc_remove( - device_cls, fw_assoc_remove, final_status, make_application, mocker + device_cls, final_status, make_application, mocker ): + # Only the newer Z-Stack 3 firmware builds expose the custom AssocAdd/AssocRemove + # commands that back the child-association recovery flow + is_launchpad = issubclass(device_cls, BaseLaunchpadCC26X2R1) + fw_assoc_remove = issubclass(device_cls, FormedLaunchpadCC26X2R1) + app, znp_server = make_application(server_cls=device_cls) await app.startup(auto_form=False) @@ -697,12 +692,20 @@ async def test_request_recovery_assoc_remove( device = app.add_initialized_device(ieee=t.EUI64(range(8)), nwk=0xABCD) - assoc_device, _ = c.util.Device.deserialize(b"\xff" * 100) - assoc_device.shortAddr = device.nwk - assoc_device.nodeRelation = c.util.NodeRelation.CHILD_FFD_RX_IDLE + znp_server.assoc_add_new( + nwk=device.nwk, + ieee=device.ieee, + node_relation=c.util.NodeRelation.CHILD_FFD_RX_IDLE, + ) + + # The association table responds on its own; just watch the mutations + did_assoc_get = mocker.spy(znp_server, "assoc_get") + did_assoc_remove = mocker.spy(znp_server, "assoc_remove") + did_assoc_add = mocker.spy(znp_server, "assoc_add_new") def data_confirm_replier(req): - bad_assoc = assoc_device + # The child stops expiring transactions once its association is dropped + bad_assoc = len(did_assoc_remove.mock_calls) == 0 return c.AF.DataConfirm.Callback( Status=t.Status.MAC_TRANSACTION_EXPIRED if bad_assoc else final_status, @@ -718,52 +721,6 @@ def data_confirm_replier(req): ], ) - def assoc_get_with_addr(req): - nonlocal assoc_device - - if assoc_device is None: - dev, _ = c.util.Device.deserialize(b"\xff" * 100) - return c.UTIL.AssocGetWithAddress.Rsp(Device=dev) - - return c.UTIL.AssocGetWithAddress.Rsp(Device=assoc_device) - - did_assoc_get = znp_server.reply_to( - c.UTIL.AssocGetWithAddress.Req(IEEE=device.ieee, partial=True), - responses=[assoc_get_with_addr], - ) - - if not issubclass(device_cls, FormedLaunchpadCC26X2R1): - fw_assoc_remove = False - - # Not all firmwares support Add/Remove - if fw_assoc_remove: - - def assoc_remove(req): - nonlocal assoc_device - - if assoc_device is None: - return c.UTIL.AssocRemove.Rsp(Status=t.Status.FAILURE) - - assoc_device = None - return c.UTIL.AssocRemove.Rsp(Status=t.Status.SUCCESS) - - did_assoc_remove = znp_server.reply_to( - c.UTIL.AssocRemove.Req(IEEE=device.ieee), - responses=[assoc_remove], - ) - - did_assoc_add = znp_server.reply_to( - c.UTIL.AssocAdd.Req( - NWK=device.nwk, - IEEE=device.ieee, - NodeRelation=c.util.NodeRelation.CHILD_FFD_RX_IDLE, - ), - responses=[c.UTIL.AssocAdd.Rsp(Status=t.Status.SUCCESS)], - ) - else: - did_assoc_remove = None - did_assoc_add = None - was_route_discovered = znp_server.reply_to( c.ZDO.ExtRouteDisc.Req( Dst=device.nwk, Options=c.zdo.RouteDiscoveryOptions.UNICAST, partial=True @@ -795,7 +752,7 @@ def assoc_remove(req): assert len(did_assoc_add.mock_calls) >= 1 else: assert len(did_assoc_add.mock_calls) == 0 - elif issubclass(device_cls, FormedLaunchpadCC26X2R1): + elif is_launchpad: assert len(did_assoc_get.mock_calls) >= 1 else: # Don't even attempt this with older firmwares @@ -805,6 +762,267 @@ def assoc_remove(req): await app.shutdown() +def absent_lookup_with_address_0xfffe(znp_server, relation=c.util.NodeRelation.PARENT): + # Real miss: 0xFFFE, then zeros on Koenkk's builds (`PARENT`) or garbage on stock + # Z-Stack, which may read as a child + def lookup(req): + device = znp_server.assoc_get(ieee=req.IEEE, nwk=req.NWK) + + if device is None: + device, _ = c.util.Device.deserialize(b"\x00" * 100) + device.shortAddr = t.NWK(0xFFFE) + device.nodeRelation = relation + + return c.UTIL.AssocGetWithAddress.Rsp(Device=device) + + znp_server.reply_to( + c.UTIL.AssocGetWithAddress.Req(partial=True), responses=[lookup], override=True + ) + + +@pytest.mark.parametrize("device_cls", [FormedLaunchpadCC26X2R1]) +@pytest.mark.parametrize( + ("relation", "absent_relation", "retried"), + [ + # Miss as `NOTUSED` (model default): send ends at once + (None, None, False), + (None, c.util.NodeRelation.PARENT, True), + (None, c.util.NodeRelation.CHILD_RFD, True), + # Found, not a direct child + (c.util.NodeRelation.PARENT, None, True), + (c.util.NodeRelation.NEIGHBOR, None, True), + (c.util.NodeRelation.OTHER, None, True), + ], +) +async def test_request_recovery_assoc_not_a_child( + device_cls, relation, absent_relation, retried, make_application, mocker +): + app, znp_server = make_application(server_cls=device_cls) + + await app.startup(auto_form=False) + + mocker.patch("zigpy_znp.zigbee.application.DATA_CONFIRM_TIMEOUT", new=0.1) + app._znp._config[conf.CONF_ZNP_CONFIG][conf.CONF_ARSP_TIMEOUT] = 1 + + device = app.add_initialized_device(ieee=t.EUI64(range(8)), nwk=0xABCD) + + if relation is not None: + znp_server.assoc_add_new( + nwk=device.nwk, ieee=device.ieee, node_relation=relation + ) + + if absent_relation is not None: + absent_lookup_with_address_0xfffe(znp_server, absent_relation) + + did_assoc_remove = mocker.spy(znp_server, "assoc_remove") + did_assoc_add = mocker.spy(znp_server, "assoc_add_new") + + data_req = znp_server.reply_to( + c.AF.DataRequestExt.Req(partial=True), + responses=[ + c.AF.DataRequestExt.Rsp(Status=t.Status.SUCCESS), + c.AF.DataConfirm.Callback( + Status=t.Status.MAC_TRANSACTION_EXPIRED, Endpoint=1, TSN=1 + ), + ], + ) + + was_route_discovered = znp_server.reply_to( + c.ZDO.ExtRouteDisc.Req(Dst=device.nwk, partial=True), + responses=[c.ZDO.ExtRouteDisc.Rsp(Status=t.Status.SUCCESS)], + ) + + with pytest.raises(DeliveryError): + await app.request( + device=device, + profile=260, + cluster=1, + src_ep=1, + dst_ep=1, + sequence=1, + data=b"\x00", + ) + + # Table untouched, route rediscovered, send retried + assert data_req.call_count == (2 if retried else 1) + assert was_route_discovered.call_count == (1 if retried else 0) + assert did_assoc_remove.call_count == 0 + assert did_assoc_add.call_count == 0 + + await app.shutdown() + + +RFD = c.util.NodeRelation.CHILD_RFD + + +@pytest.mark.parametrize("device_cls", [FormedLaunchpadCC26X2R1]) +async def test_request_recovery_assoc_other_address( + device_cls, make_application, mocker +): + # The firmware holds the device as a child at an address other than device.nwk: + # dropping it cannot help, and the entry must survive + app, znp_server = make_application(server_cls=device_cls) + + await app.startup(auto_form=False) + + mocker.patch("zigpy_znp.zigbee.application.DATA_CONFIRM_TIMEOUT", new=0.1) + app._znp._config[conf.CONF_ZNP_CONFIG][conf.CONF_ARSP_TIMEOUT] = 1 + + device = app.add_initialized_device(ieee=t.EUI64(range(8)), nwk=0xABCD) + entry = znp_server.assoc_add_new(nwk=0x1234, ieee=device.ieee, node_relation=RFD) + + did_assoc_remove = mocker.spy(znp_server, "assoc_remove") + did_assoc_add = mocker.spy(znp_server, "assoc_add_new") + + data_req = znp_server.reply_to( + c.AF.DataRequestExt.Req(partial=True), + responses=[ + c.AF.DataRequestExt.Rsp(Status=t.Status.SUCCESS), + c.AF.DataConfirm.Callback( + Status=t.Status.MAC_TRANSACTION_EXPIRED, Endpoint=1, TSN=1 + ), + ], + ) + + was_route_discovered = znp_server.reply_to( + c.ZDO.ExtRouteDisc.Req(Dst=device.nwk, partial=True), + responses=[c.ZDO.ExtRouteDisc.Rsp(Status=t.Status.SUCCESS)], + ) + + with pytest.raises(DeliveryError): + await app.request( + device=device, + profile=260, + cluster=1, + src_ep=1, + dst_ep=1, + sequence=1, + data=b"\x00", + ) + + assert data_req.call_count == 2 + assert was_route_discovered.call_count == 1 + assert did_assoc_remove.call_count == 0 + assert did_assoc_add.call_count == 0 + assert znp_server.assoc_get(ieee=device.ieee) is entry + + await app.shutdown() + + +@pytest.mark.parametrize("device_cls", [FormedLaunchpadCC26X2R1]) +@pytest.mark.parametrize( + ("scenario", "relation", "restored"), + [ + # Each child relation: removed, restored as it was + ("unchanged", c.util.NodeRelation.CHILD_RFD, True), + ("unchanged", c.util.NodeRelation.CHILD_RFD_RX_IDLE, True), + ("unchanged", c.util.NodeRelation.CHILD_FFD, True), + ("unchanged", c.util.NodeRelation.CHILD_FFD_RX_IDLE, True), + ("koenkk_lookup", RFD, True), + # Host knows a new address: no restore + ("rejoin_during_retry", RFD, False), + ("rejoin_during_restore_lookup", RFD, False), + # Firmware has the device again: no overwrite + ("re_associated", RFD, False), + ("re_associated_new_nwk", RFD, False), + ], +) +async def test_request_recovery_assoc_restore( + device_cls, scenario, relation, restored, make_application, mocker +): + app, znp_server = make_application(server_cls=device_cls) + + await app.startup(auto_form=False) + + mocker.patch("zigpy_znp.zigbee.application.DATA_CONFIRM_TIMEOUT", new=0.1) + app._znp._config[conf.CONF_ZNP_CONFIG][conf.CONF_ARSP_TIMEOUT] = 1 + + device = app.add_initialized_device(ieee=t.EUI64(range(8)), nwk=0xABCD) + + # Rejoin only updates the device, no interview + mocker.patch.object(device, "schedule_initialize") + + assoc_add_new = znp_server.assoc_add_new + assoc_add_new(nwk=device.nwk, ieee=device.ieee, node_relation=relation) + + if scenario == "koenkk_lookup": + absent_lookup_with_address_0xfffe(znp_server) + + assoc_get = znp_server.assoc_get + + def rejoin_on_restore_lookup(**kwargs): + # Only the restore looks up after the removal + entry = assoc_get(**kwargs) + + if entry is None and scenario == "rejoin_during_restore_lookup": + app.handle_join(nwk=0x1234, ieee=device.ieee, parent_nwk=0x5678) + + return entry + + mocker.patch.object(znp_server, "assoc_get", side_effect=rejoin_on_restore_lookup) + did_assoc_remove = mocker.spy(znp_server, "assoc_remove") + did_assoc_add = mocker.spy(znp_server, "assoc_add_new") + + attempts = 0 + + def data_confirm_replier(req): + nonlocal attempts + attempts += 1 + + # Attempt 1 expires (recovery), retry: no route + if attempts == 2: + if scenario == "rejoin_during_retry": + app.handle_join(nwk=0x1234, ieee=device.ieee, parent_nwk=0x5678) + elif scenario.startswith("re_associated"): + nwk = 0x1234 if scenario.endswith("new_nwk") else device.nwk + assoc_add_new(nwk=nwk, ieee=device.ieee, node_relation=relation) + + return c.AF.DataConfirm.Callback( + Status=( + t.Status.MAC_TRANSACTION_EXPIRED + if attempts == 1 + else t.Status.NWK_NO_ROUTE + ), + Endpoint=1, + TSN=1, + ) + + znp_server.reply_to( + c.AF.DataRequestExt.Req(partial=True), + responses=[ + c.AF.DataRequestExt.Rsp(Status=t.Status.SUCCESS), + data_confirm_replier, + ], + ) + + znp_server.reply_to( + c.ZDO.ExtRouteDisc.Req(partial=True), + responses=[c.ZDO.ExtRouteDisc.Rsp(Status=t.Status.SUCCESS)], + ) + + with pytest.raises(DeliveryError): + await app.request( + device=device, + profile=260, + cluster=1, + src_ep=1, + dst_ep=1, + sequence=1, + data=b"\x00", + ) + + assert did_assoc_remove.call_count == 1 + + if restored: + did_assoc_add.assert_called_once_with( + nwk=0xABCD, ieee=device.ieee, node_relation=relation + ) + else: + did_assoc_add.assert_not_called() + + await app.shutdown() + + @pytest.mark.parametrize("device", [FormedLaunchpadCC26X2R1]) async def test_route_discovery_concurrency(device, make_application): app, znp_server = make_application(server_cls=device) diff --git a/tests/application/test_startup.py b/tests/application/test_startup.py index 8394a86..34ed927 100644 --- a/tests/application/test_startup.py +++ b/tests/application/test_startup.py @@ -1,6 +1,7 @@ import pytest import voluptuous as vol from zigpy.exceptions import NetworkNotFormed +import zigpy.zgp from zigpy_znp.api import ZNP import zigpy_znp.commands as c @@ -103,10 +104,10 @@ async def test_endpoints(device, make_application): await app.startup(auto_form=False) - # We currently just register two endpoints - assert len(endpoints) == 2 + assert len(endpoints) == 3 assert 1 in app._device.endpoints assert 2 in app._device.endpoints + assert zigpy.zgp.GP_ENDPOINT in app._device.endpoints await app.shutdown() diff --git a/tests/conftest.py b/tests/conftest.py index 58b2690..7aea6e8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -18,6 +18,7 @@ import zigpy.endpoint import zigpy.zdo.types as zdo_t +import zigpy.zgp from zigpy_znp.api import ZNP import zigpy_znp.commands as c @@ -431,6 +432,9 @@ def __init__(self, *args, **kwargs): self.device_state = t.DeviceState.InitializedNotStarted self.zdo_callbacks = set() + self._assoc_table = {} + self._assoc_next_addr_idx = 0 + # Handle the decorators for name in dir(self): func = getattr(self, name) @@ -594,6 +598,16 @@ def permit_join(self, request): c.ZDO.MgmtPermitJoinRsp.Callback(Src=0x0000, Status=t.ZDOStatus.SUCCESS), ] + @reply_to( + c.AF.DataRequestExt.Req( + partial=True, + DstEndpoint=zigpy.zgp.GP_ENDPOINT, + SrcEndpoint=zigpy.zgp.GP_ENDPOINT, + ) + ) + def on_gp_broadcast(self, req): + return c.AF.DataRequestExt.Rsp(Status=t.Status.SUCCESS) + @reply_to(c.AF.DataRequestExt.Req(partial=True, DstEndpoint=0)) def on_zdo_request(self, req): kwargs = deserialize_zdo_command(req.ClusterId, req.Data[1:]) @@ -916,6 +930,85 @@ def register_zdo_callback(self, request): def assoc_find_dev_responder(self, req): return req.Rsp(Device=t.Bytes(b"\xff" * (36 if self.align_structs else 28))) + # Simulated association table, modeled on the Z-Stack `AssocAddNew`/`AssocRemove`/ + # `AssocGetWithAddress` functions. Keyed by IEEE, like the address manager lookup. + def assoc_get(self, *, ieee=None, nwk=None): + # The IEEE takes priority; all-zeroes means "use the NWK address instead" + if ieee is not None and any(ieee): + return self._assoc_table.get(ieee) + + for device in self._assoc_table.values(): + if device.shortAddr == nwk: + return device + + return None + + def assoc_add_new(self, *, nwk, ieee, node_relation): + # `AssocAddNew` updates an existing entry in place (keeping its addrIdx) or + # allocates a new one when the IEEE is not already known. + node_relation = c.util.NodeRelation(node_relation) + existing = self._assoc_table.get(ieee) + + if existing is not None: + addr_idx = existing.addrIdx + else: + addr_idx = self._assoc_next_addr_idx + self._assoc_next_addr_idx += 1 + + is_child = ( + c.util.NodeRelation.CHILD_RFD + <= node_relation + <= c.util.NodeRelation.CHILD_FFD_RX_IDLE + ) + # Only RFD children age out and track keepalives; routers don't + is_aging_child = node_relation in ( + c.util.NodeRelation.CHILD_RFD, + c.util.NodeRelation.CHILD_RFD_RX_IDLE, + ) + + device = c.util.Device( + shortAddr=t.NWK(nwk), + addrIdx=addr_idx, + nodeRelation=node_relation, + devStatus=( + c.util.DeviceStatus.SEC_INIT if is_child else c.util.DeviceStatus.NONE + ), + assocCnt=0, + age=0, + linkInfo=c.util.LinkInfo( + txCounter=0, + txCost=1, + rxLqi=0xAA, + inKeySeqNum=0, + inFrmCntr=0, + txFailure=0, + ), + endDev=c.util.AgingEndDevice( + endDevCfg=0, + deviceTimeout=0 if is_aging_child else 0xFFFFFFFE, + ), + timeoutCounter=0 if is_aging_child else 0xFFFFFFFE, + keepaliveRcv=1 if is_aging_child else 0, + ctrl=0x80, # ASSOC_LIST_CTRL_SAVE + ) + self._assoc_table[ieee] = device + + return device + + def assoc_remove(self, ieee): + # `AssocRemove` frees the slot and returns whether it was present + return self._assoc_table.pop(ieee, None) is not None + + @reply_to(c.UTIL.AssocGetWithAddress.Req(partial=True)) + def on_util_assoc_get_with_address(self, req): + device = self.assoc_get(ieee=req.IEEE, nwk=req.NWK) + + if device is None: + # The firmware packs INVALID_NODE_ADDR / NOTUSED for an unknown device + device, _ = c.util.Device.deserialize(b"\xff" * 100) + + return c.UTIL.AssocGetWithAddress.Rsp(Device=device) + class BaseZStack1CC2531(BaseZStackDevice): align_structs = False @@ -1361,6 +1454,29 @@ def __init__(self, *args, **kwargs): self._nvram = load_nvram_json("CC2652R-ZStack4.formed.json") self._orig_nvram = simple_deepcopy(self._nvram) + # Koenkk Z-Stack 3.x firmware (post-20201017) adds the custom AssocAdd/AssocRemove + # MT commands. Both proxy into the association table but always reply with a single + # SUCCESS status byte, ignoring the proxied call's result. + @reply_to(c.UTIL.AssocRemove.Req(partial=True)) + def on_util_assoc_remove(self, req): + self.assoc_remove(req.IEEE) + + return c.UTIL.AssocRemove.Rsp(Status=t.Status.SUCCESS) + + @reply_to(c.UTIL.AssocAdd.Req(partial=True)) + def on_util_assoc_add(self, req): + self.assoc_add_new(nwk=req.NWK, ieee=req.IEEE, node_relation=req.NodeRelation) + + return c.UTIL.AssocAdd.Rsp(Status=t.Status.SUCCESS) + + +class FormedLaunchpadCC26X2R1WithoutAssocCommands(BaseLaunchpadCC26X2R1): + # Models an older firmware build that lacks the custom AssocAdd/AssocRemove commands + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self._nvram = load_nvram_json("CC2652R-ZStack4.formed.json") + self._orig_nvram = simple_deepcopy(self._nvram) + class ResetLaunchpadCC26X2R1(BaseLaunchpadCC26X2R1): def __init__(self, *args, **kwargs): diff --git a/zigpy_znp/commands/util.py b/zigpy_znp/commands/util.py index 8f5f0a0..a43779b 100644 --- a/zigpy_znp/commands/util.py +++ b/zigpy_znp/commands/util.py @@ -16,6 +16,19 @@ class NodeRelation(t.enum8): NOTUSED = 0xFF +class DeviceStatus(t.bitmap8): + # Bitmap of an associated device's status fields (AssocList.h) + NONE = 0x00 + LINK_STATUS = 0x01 # link is in-active + LINK_REPAIR = 0x02 # link repair in progress + SEC_INIT = 0x04 # security init + SEC_AUTH = 0x08 # security authenticated + SEC_AUTH_TC_REJOIN = 0x10 # assumed-secure device that performs a TC rejoin + SECURED_JOIN = 0x20 # device joined secure + REJOIN = 0x40 # device rejoined + HIGH_SEC = 0x80 # device joined as High Security + + class BindEntry(t.Struct): srcEP: t.uint8_t dstGroupMode: t.uint8_t # 0 - Normal address index, 1 - Group address @@ -44,7 +57,7 @@ class Device(t.CStruct): shortAddr: t.NWK addrIdx: t.uint16_t nodeRelation: NodeRelation - devStatus: t.uint8_t + devStatus: DeviceStatus assocCnt: t.uint8_t age: t.uint8_t linkInfo: LinkInfo diff --git a/zigpy_znp/zigbee/application.py b/zigpy_znp/zigbee/application.py index be95a9a..83504b3 100644 --- a/zigpy_znp/zigbee/application.py +++ b/zigpy_znp/zigbee/application.py @@ -16,6 +16,7 @@ import zigpy.zcl import zigpy.zdo import zigpy.zdo.types as zdo_t +import zigpy.zgp from zigpy_znp.api import ZNP import zigpy_znp.commands as c @@ -37,6 +38,16 @@ EXTENDED_DATA_CONFIRM_TIMEOUT = 30 DEVICE_JOIN_MAX_DELAY = 5 +# Child relations the recovery may drop and restore +CHILD_NODE_RELATIONS = frozenset( + { + c.util.NodeRelation.CHILD_RFD, + c.util.NodeRelation.CHILD_RFD_RX_IDLE, + c.util.NodeRelation.CHILD_FFD, + c.util.NodeRelation.CHILD_FFD_RX_IDLE, + } +) + LOGGER = logging.getLogger(__name__) @@ -659,7 +670,7 @@ def _find_endpoint(self, dst_ep: int, profile: int, cluster: int) -> int: return ZDO_ENDPOINT if profile == zigpy.profiles.zgp.PROFILE_ID: - return zigpy.profiles.zgp.GREENPOWER_ENDPOINT_ID + return zigpy.zgp.GP_ENDPOINT # Newer Z-Stack releases ignore profiles and will work properly with endpoint 1 if ( @@ -959,6 +970,16 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: ): raise + # Not a direct child, or not at device.nwk (a miss reads + # 0xFFFE, see Koenkk/zigbee-herdsman#685): retry only + if ( + child_association.Device.shortAddr != device.nwk + or child_association.Device.nodeRelation + not in CHILD_NODE_RELATIONS + ): + await self._discover_route(device.nwk) + continue + try: await self._znp.request( c.UTIL.AssocRemove.Req(IEEE=device.ieee) @@ -1005,10 +1026,26 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: # not be able to find it again. if not succeeded and tried_assoc_remove: assert child_association is not None - await self._znp.request( - c.UTIL.AssocAdd.Req( - NWK=child_association.Device.shortAddr, - IEEE=device.ieee, - NodeRelation=child_association.Device.nodeRelation, + removed = child_association.Device + + # `AssocAdd` overwrites in place: skip if the device rejoined + if device.nwk == removed.shortAddr: + current = await self._znp.request( + c.UTIL.AssocGetWithAddress.Req( + IEEE=device.ieee, + NWK=0x0000, # IEEE takes priority + ) ) - ) + + # Again: a join may have arrived during the lookup + if device.nwk == removed.shortAddr and ( + current.Device.shortAddr == 0xFFFE + or current.Device.nodeRelation == c.util.NodeRelation.NOTUSED + ): + await self._znp.request( + c.UTIL.AssocAdd.Req( + NWK=removed.shortAddr, + IEEE=device.ieee, + NodeRelation=removed.nodeRelation, + ) + )