From 7e3888251e190257d312c869f04e9988d2447ea6 Mon Sep 17 00:00:00 2001 From: David Escalona Date: Mon, 6 Jul 2026 13:38:36 +0200 Subject: [PATCH 1/3] zigbee: fix connections and LQI missing in one direction during neighbor discovery During a Zigbee network scan, some connections between same-level radios (for example two routers that can hear each other directly) showed correct signal quality in one direction but -9999 / status -1 in the other. In some cases the connection was missing entirely. Two related causes were found in the neighbor-table processing path: 1. SIBLING relationships were excluded from the neighbor-based connection path. A sibling appears in a radio's neighbor table with a measured LQI even when the radio holds no active route to it (Zigbee route tables are directional). Because siblings were excluded, the measured LQI was thrown away and the reverse direction of an existing connection stayed UNKNOWN. 2. A role-check guard rejected any connection when the neighbor node's cached role was UNKNOWN. This guard has no equivalent in the XCTU Java implementation, which creates a connection for every neighbor entry regardless of the reported device type. The guard was too conservative: ZDO neighbor entries sometimes report device_type=3 (UNKNOWN), which leaves the cached role as UNKNOWN even for a fully functioning router, so valid LQI measurements were silently discarded. Removing the guard entirely matches the XCTU Java behavior; the separate check that decides whether to queue a node for its own neighbor-table query is unaffected. While on it, guard against a reserved (undefined) value in the relationship field of a neighbor entry. An undefined value previously caused a crash that silently dropped all remaining neighbor entries for that radio. https://onedigi.atlassian.net/browse/XBPL-438 https://onedigi.atlassian.net/browse/XBPL-439 Signed-off-by: David Escalona --- digi/xbee/devices.py | 23 ++++++++++++++++------- digi/xbee/models/zdo.py | 3 ++- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/digi/xbee/devices.py b/digi/xbee/devices.py index e652bca..50ee143 100644 --- a/digi/xbee/devices.py +++ b/digi/xbee/devices.py @@ -11900,7 +11900,8 @@ def __process_discovered_neighbor_data(self, requester, routes, neighbor, nodes_ discover their neighbors are stored. """ self._log.debug(" o Discovered neighbor of %s: %s (%s)", - requester, neighbor.node, neighbor.relationship.name) + requester, neighbor.node, + neighbor.relationship.name if neighbor.relationship else None) # Requester node is clearly reachable self._set_node_reachable(requester, True) @@ -11940,18 +11941,25 @@ def __process_discovered_neighbor_data(self, requester, routes, neighbor, nodes_ lq_b2a=LinkQuality.UNKNOWN, status_a2b=route.status, status_b2a=RouteStatus.UNKNOWN) self._log.debug(" - Using route for the connection: %d", route.status.id) - elif (neighbor.node.get_role() != Role.UNKNOWN - and neighbor.relationship != NeighborRelationship.PREVIOUS_CHILD - and neighbor.relationship != NeighborRelationship.SIBLING): + elif neighbor.relationship not in (NeighborRelationship.PREVIOUS_CHILD, None): + # Build a connection from the neighbor entry even when no route exists. + # The XCTU Java implementation applies no role check here: connections + # are created for PARENT/CHILD/SIBLING/UNDETERMINED regardless of the + # neighbor node's cached role. This is important because ZDO neighbor + # entries sometimes report device_type=3 (UNKNOWN), which would leave + # the cached role as UNKNOWN even for a fully functioning router, and + # silently dropping the measured LQI causes the reverse direction of + # an existing connection to stay UNKNOWN (LQI -9999, status -1). self._log.debug( " - No route for this node, using relationship for the connection: %s", - neighbor.relationship.name) + neighbor.relationship.name if neighbor.relationship else None) if neighbor.relationship == NeighborRelationship.PARENT: connection = Connection(node, requester, lq_a2b=neighbor.lq, lq_b2a=LinkQuality.UNKNOWN, status_a2b=RouteStatus.ACTIVE, status_b2a=RouteStatus.UNKNOWN) elif neighbor.relationship in (NeighborRelationship.CHILD, - NeighborRelationship.UNDETERMINED): + NeighborRelationship.UNDETERMINED, + NeighborRelationship.SIBLING): connection = Connection(requester, node, lq_a2b=neighbor.lq, lq_b2a=LinkQuality.UNKNOWN, status_a2b=RouteStatus.ACTIVE, status_b2a=RouteStatus.UNKNOWN) @@ -12275,7 +12283,8 @@ def __process_discovered_neighbor_data(self, requester, neighbor, nodes_queue): discover their neighbors are stored. """ self._log.debug(" o Discovered neighbor of %s: %s (%s)", - requester, neighbor.node, neighbor.relationship.name) + requester, neighbor.node, + neighbor.relationship.name if neighbor.relationship else None) # Requester node is clearly reachable self._set_node_reachable(requester, True) diff --git a/digi/xbee/models/zdo.py b/digi/xbee/models/zdo.py index 1fbdb3f..9ffe941 100644 --- a/digi/xbee/models/zdo.py +++ b/digi/xbee/models/zdo.py @@ -1239,7 +1239,8 @@ def _parse_data(self, data): # The coordinator is already received with its real 64-bit address if neighbor and neighbor.node.get_64bit_addr() != XBee64BitAddress.COORDINATOR_ADDRESS: self._logger.debug("Neighbor of '%s': %s (relation: %s, depth: %s, lqi: %s)", - self._xbee, neighbor.node, neighbor.relationship.name, + self._xbee, neighbor.node, + neighbor.relationship.name if neighbor.relationship else None, neighbor.depth, neighbor.lq) self.__neighbors.append(neighbor) if self.__cb: From 934bf5b52a2b4c7c97f63643fe757b47befc0e71 Mon Sep 17 00:00:00 2001 From: David Escalona Date: Mon, 6 Jul 2026 11:37:03 +0200 Subject: [PATCH 2/3] digimesh: fix neighbor discovery clearing NO settings Fix an issue causing the discovery process to completely overwrite the NO setting of the radio. Before starting the scan, the library was setting a flag (0x04) on the local radio to include signal strength in neighbor responses. It was overwriting the entire setting instead of adding only that flag, which cleared any other options the user had configured. The fix now reads the current value and only adds the needed flag, leaving everything else untouched. While on it, avoid empty frames to be parsed. This has been seen in some tests and caused the discovery to crash. https://onedigi.atlassian.net/browse/XBPL-440 Signed-off-by: David Escalona --- digi/xbee/devices.py | 4 +++- digi/xbee/models/zdo.py | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/digi/xbee/devices.py b/digi/xbee/devices.py index 50ee143..3a3ad77 100644 --- a/digi/xbee/devices.py +++ b/digi/xbee/devices.py @@ -12126,7 +12126,9 @@ def _prepare_network_discovery(self): if utils.is_bit_enabled(self.__saved_no[0], 2): self.__saved_no = None else: - self.set_discovery_options({DiscoveryOptions.APPEND_RSSI}) + no_value = bytearray(self.__saved_no) + no_value[0] = no_value[0] | DiscoveryOptions.APPEND_RSSI + self._local_xbee.set_parameter(ATStringCommand.NO, no_value, apply=True) self._log.debug("[*] Preconfiguring %s", ATStringCommand.SO.command) self.__saved_so = self._local_xbee.get_parameter( diff --git a/digi/xbee/models/zdo.py b/digi/xbee/models/zdo.py index 9ffe941..ece5ce1 100644 --- a/digi/xbee/models/zdo.py +++ b/digi/xbee/models/zdo.py @@ -1771,4 +1771,6 @@ def __fn_packet_cb(self, frame): self.stop() return - self.__parse_data(frame.command_value) + # Parse message data only if the command has value + if frame.command_value: + self.__parse_data(frame.command_value) From cb051337bf866f7b979ca3d3a7c8fdba7ef5b577 Mon Sep 17 00:00:00 2001 From: David Escalona Date: Tue, 16 Jun 2026 12:33:18 +0200 Subject: [PATCH 3/3] digimesh: wait longer for a remote 'FN' answer during network discovery During a DigiMesh deep discovery, the library asks each node to list its neighbors with the 'FN' (find neighbors) command and waits 'N?' for the answer. If the answer does not arrive in time, the node is marked as non-reachable. 'N?' is the time the local node needs for its own neighbor discovery, and it was used as the wait time for every node, local and remote alike. A remote 'FN' needs more time than that: the request has to travel to the remote node, the remote then runs its own neighbor scan before it can answer, and the answer has to travel back. As a result, a remote node that was online but a little slow to answer could miss the deadline and be wrongly reported as non-reachable. Give remote requesters more time: wait the local timeout multiplied by _REMOTE_NEIGHBOR_TIMEOUT_FACTOR (2) instead of the plain local timeout. The local node is unaffected. The factor is a conservative upper bound (the remote does about the same work as the local node, ~'N?', plus the round trip, also bounded by ~'N?'), and using a multiplier rather than a fixed margin lets the extra time grow with network depth, since 'N?' is derived from the maximum hop count. See the constant's documentation for the full reasoning. https://onedigi.atlassian.net/browse/XBPL-436 Signed-off-by: David Escalona --- digi/xbee/devices.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/digi/xbee/devices.py b/digi/xbee/devices.py index 3a3ad77..1412af3 100644 --- a/digi/xbee/devices.py +++ b/digi/xbee/devices.py @@ -12044,6 +12044,33 @@ class DigiMeshNetwork(XBeeNetwork): local one and stores them. """ + _REMOTE_NEIGHBOR_TIMEOUT_FACTOR = 2 + """ + Multiplier applied to the local node timeout ('N?') when waiting for a + *remote* node's 'FN' (find neighbors) answer. + + 'N?' is the time the local node needs for its own neighbor discovery. A + remote 'FN' takes longer: the request must travel to the remote node, the + remote runs its own neighbor scan (up to its own discovery time) before it + can answer, and the answer must travel back. Waiting only 'N?' would time + out an online but slow-to-answer remote and wrongly mark it non-reachable, + so remote requesters are given this many times the local timeout. + + The value starts at 2, chosen as a safe upper limit. If we assume every node + is set up the same way ('NT'/'NN'/'NH'), as '_calculate_timeout' already + does, a remote 'FN' takes at most two things: the remote doing the same + neighbor scan the local node does (about 'N?'), plus the time for the request + to reach it and the answer to come back (also no more than about 'N?'). That + adds up to about 2 x 'N?', so 2 is a safe ceiling, not a tight figure. + + We multiply 'N?' instead of adding a fixed amount on purpose: the radio works + out 'N?' from the maximum number of hops ('NH'), so multiplying it makes the + extra time grow on its own as the network gets deeper. The downside is that a + bigger factor makes discovery slower when a node is really gone (most of all + in CASCADE mode, where each node's wait time adds up). The exact number is a + best guess and can be tuned later. + """ + def __init__(self, device): """ Class constructor. Instantiates a new `DigiMeshNetwork`. @@ -12195,8 +12222,18 @@ def modem_st_cb(modem_status): while not awake.wait(timeout=node_timeout): pass + # 'self.__real_node_timeout' is based on the local node's 'N?' and only + # covers a local neighbor discovery. A remote 'FN' also needs time to + # reach the remote node, for the remote to run its own neighbor scan, + # and for the answer to travel back, so give remote requesters extra + # time to avoid timing out (and wrongly marking as non-reachable) a node + # that is online but slow to answer. + finder_timeout = self.__real_node_timeout + if requester.is_remote(): + finder_timeout *= self._REMOTE_NEIGHBOR_TIMEOUT_FACTOR + from digi.xbee.models.zdo import NeighborFinder - finder = NeighborFinder(requester, timeout=self.__real_node_timeout) + finder = NeighborFinder(requester, timeout=finder_timeout) finder.get_neighbors(neighbor_cb=__new_neighbor_cb, finished_cb=__neighbor_discover_finished_cb)