Skip to content

EZSP v14+: getExtendedTimeout / lookupNodeIdByEui64 responses decoded with pre-v14 layouts, so set_extended_timeout() never short-circuits #747

Description

@SimonRubens

On this coordinator — EmberZNet 8.0.2.0, where bellows is using its EZSP v14-or-later command table — getExtendedTimeout answers with an sl_status_t, and lookupNodeIdByEui64 answers with an sl_status_t followed by the node id, exactly as Silicon Labs' own host code for EmberZNet 8.0 does. bellows decodes both with the EZSP v4 response layouts, which are inherited unchanged into the v14 table and from there into v16-v19. set_extended_timeout() therefore misreads the current state, never takes its early return, and runs lookupNodeIdByEui64 + setExtendedTimeout before every unicast to a known device.

I have not established in which EZSP version each of the two responses changed; what follows is what this adapter sends on v14.

Where

The v4 layouts:

"getExtendedTimeout": (
0x7F,
{
"remoteEui64": t.EUI64,
},
{
"extendedTimeout": t.Bool,
},
),
"replaceAddressTableEntry": (
0x82,
{
"addressTableIndex": t.uint8_t,
"newEui64": t.EUI64,
"newId": t.EmberNodeId,
"newExtendedTimeout": t.Bool,
},
{
"status": t.EmberStatus,
"oldEui64": t.EUI64,
"oldId": t.EmberNodeId,
"oldExtendedTimeout": t.Bool,
},
),
"lookupNodeIdByEui64": (
0x60,
{
"eui64": t.EUI64,
},
{
"nodeId": t.EmberNodeId,
},
),

"getExtendedTimeout": (0x7F, {"remoteEui64": t.EUI64}, {"extendedTimeout": t.Bool}),
...
"lookupNodeIdByEui64": (0x60, {"eui64": t.EUI64}, {"nodeId": t.EmberNodeId}),

v14 copies every v13 command that it does not override and only swaps EmberStatus/EzspStatus for sl_Status. Neither response contains those types, so both layouts pass through unchanged. setExtendedTimeout is overridden; these two are not:

for name, (command_id, tx_schema, rx_schema) in COMMANDS_v13.items():
if name in COMMANDS:
continue
if isinstance(tx_schema, dict):
tx_schema = {k: _REPLACEMENTS.get(v, v) for k, v in tx_schema.items()}
if isinstance(rx_schema, dict):
rx_schema = {k: _REPLACEMENTS.get(v, v) for k, v in rx_schema.items()}
COMMANDS[name] = (command_id, tx_schema, rx_schema)

v16, v17, v18 and v19 chain their tables from v14 and override neither command.

The consumer, called for every NWK unicast when the firmware lacks COMBINED_SEND:

async def set_extended_timeout(
self, nwk: t.NWK, ieee: t.EUI64, extended_timeout: bool = True
) -> None:
(curr_extended_timeout,) = await self.getExtendedTimeout(remoteEui64=ieee)
if curr_extended_timeout == extended_timeout:
return
(node_id,) = await self.lookupNodeIdByEui64(eui64=ieee)
# Check to see if we have an address table entry
if node_id != 0xFFFF:
await self.setExtendedTimeout(
remoteEui64=ieee, extendedTimeout=extended_timeout
)
return

if xncp_unicast is not None:
status, _ = await self._ezsp.xncp_send_unicast(xncp_unicast)
else:
if device is not None:
await self._ezsp.set_extended_timeout(
nwk=device.nwk,
ieee=device.ieee,
extended_timeout=extended_timeout,
)

What the NCP actually sends

Silicon Labs' EZSP host implementation for EmberZNet 8.0 (Simplicity SDK 2024.6):

https://github.com/SiliconLabs/simplicity_sdk/blob/82f49bac8d5573c52b1be9892ddd1c9e8ad7886a/protocol/zigbee/app/util/ezsp/command-functions.h#L2507-L2520
https://github.com/SiliconLabs/simplicity_sdk/blob/82f49bac8d5573c52b1be9892ddd1c9e8ad7886a/protocol/zigbee/app/util/ezsp/command-functions.h#L2549-L2564

sl_status_t sl_zigbee_ezsp_get_extended_timeout(sl_802154_long_addr_t remoteEui64)
  ...
    status = fetchInt32u();
    return status;

sl_status_t sl_zigbee_ezsp_lookup_node_id_by_eui64(sl_802154_long_addr_t eui64, sl_802154_short_addr_t *nodeId)
  ...
    status = fetchInt32u();
    *nodeId = fetchInt16u();

What the status means, per the EmberZNet 8.0 API reference (https://docs.silabs.com/d/zigbee-stack-api/8.0.0/message): sl_zigbee_get_extended_timeout returns SL_STATUS_OK if the retry interval will be extended, and SL_STATUS_FAIL if the normal interval is used.

zigbee-herdsman's ember driver decodes it the same way: a uint8 below v14, and a uint32 status from v14:
https://github.com/Koenkk/zigbee-herdsman/blob/8f461929aeca7fdcfdbf530f1922c5da0ee341d4/src/adapter/ember/ezsp/ezsp.ts#L5669-L5688

Observed

The adapter negotiated EZSP v14 or later: setExtendedTimeout replies with a status field, which exists only in the v14+ schema, and sendUnicast and messageSentHandler carry sl_Status values. The coordinator firmware is EmberZNet 8.0.2.0.

The unparsed remainder that bellows logs at DEBUG shows the misalignment byte for byte. Every reply below was followed by the Frame contains trailing data line shown:

Received command getExtendedTimeout: {'extendedTimeout': <Bool.true: 1>}   | Frame contains trailing data: b'\x00\x00\x00'
Received command lookupNodeIdByEui64: {'nodeId': 0x0000}                   | Frame contains trailing data: b'\x00\x00\xf5\xac'
Received command lookupNodeIdByEui64: {'nodeId': 0x0000}                   | Frame contains trailing data: b'\x00\x00K\x0b'
Received command lookupNodeIdByEui64: {'nodeId': 0x002D}                   | Frame contains trailing data: b'\x00\x00\xff\xff'
  • getExtendedTimeout: 01 00 00 00 is SL_STATUS_FAIL, meaning the normal interval is in use. bellows reads the first byte as True, which is the opposite.
  • lookupNodeIdByEui64 (OK): 00 00 00 00 | f5 ac is SL_STATUS_OK followed by the real node id 0xACF5. bellows reads 0x0000.
  • lookupNodeIdByEui64 (not found): 2d 00 00 00 | ff ff is SL_STATUS_NOT_FOUND followed by 0xFFFF. bellows reads 0x002D. All 109 0x002D replies in the window carried exactly this remainder.

Over a two-minute window of scene activations there were 256 unicasts. Each one produced exactly:

Sending command  getExtendedTimeout: () {'remoteEui64': <eui64>}
Received command getExtendedTimeout: {'extendedTimeout': <Bool.true: 1>}
Sending command  lookupNodeIdByEui64: () {'eui64': <eui64>}
Received command lookupNodeIdByEui64: {'nodeId': 0x0000}
Sending command  setExtendedTimeout: () {'remoteEui64': <eui64>, 'extendedTimeout': False}
Received command setExtendedTimeout: {'status': <sl_Status.OK: 0>}
Sending command  sendUnicast: ...

The next unicast to the same device repeats the whole sequence. Totals over the window:

  • 256 × getExtendedTimeout, all Bool.true
  • 256 × lookupNodeIdByEui64: 147 × 0x0000, 109 × 0x002D, never a real node id and never 0xFFFF
  • 256 × setExtendedTimeout, all OK
  • 256 × sendUnicast

Impact

  1. Two extra EZSP round trips for every unicast to a mains-powered device. On a networked coordinator, each EZSP round trip took a median 11–13 ms. The full per-unicast chain was a median 55 ms (p90 113 ms), against about 25 ms if the early return had worked. This is visible whenever HA sends a burst. A scene on 17 bulbs (34 unicasts: level, then colour) takes 2.5–2.9 s from the first lamp to the last, and the log shows the queue waiting on this serial path, not on the radio (the APS ack arrived a median 26 ms after sendUnicast).
  2. I can show the cost, not a functional consequence. In this network the wanted value is always False, because bellows clears extended_timeout whenever APS ACKs are requested (
    zigpy.types.TransmitOptions.ACK in packet.tx_options
    and packet.dst.addr_mode == zigpy.types.AddrMode.NWK
    ):
    aps_frame.options |= t.EmberApsOption.APS_OPTION_RETRY
    # We disable extended timeout if we enable ACKs
    extended_timeout = False
    ). setExtendedTimeout(..., True) does not appear once in seven days of logs (2026-09-06 to 2026-09-13), and the captured window contains no unicast to any of the four sleepy end devices. What the inverted reading does where True is wanted, I have not tested.
  3. The address-table branch in set_extended_timeout() is unreachable, because lookupNodeIdByEui64 can never return 0xFFFF.

Environment

  • Home Assistant OS 18.2, Home Assistant Core 2026.9.2. The library versions are the ones this HA release pins: the ZHA manifest requires zha==2.2.2, which pins bellows==1.0.1 and zigpy==2.2.0. I read the pins, not the installed dist-info.
  • Coordinator: SMLIGHT SLZB-06M (EFR32MG21), Ethernet, socket://…:6638. SMLIGHT release Zigbee firmware revision 20250220, published in their OTA list as slzb06m_zigbee_ncp_8.0.2.0_sw_flow_115200.gbl (EmberZNet 8.0.2.0). The firmware does not offer the XNCP combined send: bellows took the four-command path for every unicast.
  • Network: 40 devices, 35 of them routers.

Related

Suggested fix (untested sketch)

Override both responses for v14:

# bellows/ezsp/v14/commands.py
"getExtendedTimeout": (
    0x007F,
    {"remoteEui64": t.EUI64},
    {"status": t.sl_Status},
),
"lookupNodeIdByEui64": (
    0x0060,
    {"eui64": t.EUI64},
    {"status": t.sl_Status, "nodeId": t.EmberNodeId},
),

Then give EZSPv14 its own set_extended_timeout() that treats status == sl_Status.OK as "extended timeout is set" and takes the node id from the second field, with a non-OK status or 0xFFFF meaning "no entry".

Since v16-v19 chain from the v14 table, overriding it there covers them as well.

The same window also holds 48 Frame contains trailing data lines belonging to other commands (560 in total, 512 of them from these two), so other schemas may be out of date as well.

Other v4-era commands whose v14 response gained a leading sl_status_t without containing an EmberStatus field would be affected the same way; I have not audited them.

I can test a branch against this coordinator and provide full debug logs.

Written with the help of an AI assistant (Claude). Every number above comes from my own coordinator's journal and the linked sources, and I can re-run any of it on request.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions