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
- 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).
- 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.
- 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.
On this coordinator — EmberZNet 8.0.2.0, where bellows is using its EZSP v14-or-later command table —
getExtendedTimeoutanswers with ansl_status_t, andlookupNodeIdByEui64answers with ansl_status_tfollowed 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 runslookupNodeIdByEui64+setExtendedTimeoutbefore 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:
bellows/bellows/ezsp/v4/commands.py
Lines 913 to 945 in 8f9d87b
v14 copies every v13 command that it does not override and only swaps
EmberStatus/EzspStatusforsl_Status. Neither response contains those types, so both layouts pass through unchanged.setExtendedTimeoutis overridden; these two are not:bellows/bellows/ezsp/v14/commands.py
Lines 167 to 177 in 8f9d87b
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:bellows/bellows/ezsp/v4/__init__.py
Lines 198 to 213 in 8f9d87b
bellows/bellows/zigbee/application.py
Lines 1076 to 1085 in 8f9d87b
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
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_timeoutreturnsSL_STATUS_OKif the retry interval will be extended, andSL_STATUS_FAILif the normal interval is used.zigbee-herdsman's ember driver decodes it the same way: a
uint8below v14, and auint32status 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:
setExtendedTimeoutreplies with astatusfield, which exists only in the v14+ schema, andsendUnicastandmessageSentHandlercarrysl_Statusvalues. 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 dataline shown:getExtendedTimeout:01 00 00 00isSL_STATUS_FAIL, meaning the normal interval is in use. bellows reads the first byte asTrue, which is the opposite.lookupNodeIdByEui64(OK):00 00 00 00 | f5 acisSL_STATUS_OKfollowed by the real node id0xACF5. bellows reads0x0000.lookupNodeIdByEui64(not found):2d 00 00 00 | ff ffisSL_STATUS_NOT_FOUNDfollowed by0xFFFF. bellows reads0x002D. All 1090x002Dreplies in the window carried exactly this remainder.Over a two-minute window of scene activations there were 256 unicasts. Each one produced exactly:
The next unicast to the same device repeats the whole sequence. Totals over the window:
getExtendedTimeout, allBool.truelookupNodeIdByEui64: 147 ×0x0000, 109 ×0x002D, never a real node id and never0xFFFFsetExtendedTimeout, all OKsendUnicastImpact
sendUnicast).False, because bellows clearsextended_timeoutwhenever APS ACKs are requested (bellows/bellows/zigbee/application.py
Lines 1011 to 1017 in 8f9d87b
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 whereTrueis wanted, I have not tested.set_extended_timeout()is unreachable, becauselookupNodeIdByEui64can never return0xFFFF.Environment
zha==2.2.2, which pinsbellows==1.0.1andzigpy==2.2.0. I read the pins, not the installed dist-info.socket://…:6638. SMLIGHT release Zigbee firmware revision 20250220, published in their OTA list asslzb06m_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.Related
gpepIncomingMessageHandler: a v4-era schema that later versions kept inheriting after the wire format changed. Its fix redefines the command in the version where the layout changed, which is the shape suggested below.set_extended_timeout, but neither is about the response layouts.Suggested fix (untested sketch)
Override both responses for v14:
Then give
EZSPv14its ownset_extended_timeout()that treatsstatus == sl_Status.OKas "extended timeout is set" and takes the node id from the second field, with a non-OK status or0xFFFFmeaning "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 datalines 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_twithout containing anEmberStatusfield 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.