Repository navigation
EZSP v14+: getExtendedTimeout / lookupNodeIdByEui64 responses decoded with pre-v14 layouts, so set_extended_timeout() never short-circuits #747
Description
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:
bellows/bellows/ezsp/v4/commands.py
Lines 913 to 945 in 8f9d87b
| "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:
bellows/bellows/ezsp/v14/commands.py
Lines 167 to 177 in 8f9d87b
| 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:
bellows/bellows/ezsp/v4/__init__.py
Lines 198 to 213 in 8f9d87b
| 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 |
bellows/bellows/zigbee/application.py
Lines 1076 to 1085 in 8f9d87b
| 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 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:
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, allBool.true - 256 ×
lookupNodeIdByEui64: 147 ×0x0000, 109 ×0x002D, never a real node id and never0xFFFF - 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 clearsextended_timeoutwhenever APS ACKs are requested ().bellows/bellows/zigbee/application.py
Lines 1011 to 1017 in 8f9d87b
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 whereTrueis wanted, I have not tested. - The address-table branch in
set_extended_timeout()is unreachable, becauselookupNodeIdByEui64can never return0xFFFF.
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 pinsbellows==1.0.1andzigpy==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 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. - Network: 40 devices, 35 of them routers.
Related
- Fix gpepIncomingMessageHandler schema for EZSP v13+ #746 (open) fixes the same class of problem for
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. - Persistent ERROR_EXCEEDED_MAXIMUM_ACK_TIMEOUT_COUNT — reproduces on two different EZSP radios (ZBT-1 and ZBT-2 #735 and Possible race in send_packet: AttributeError on _ezsp.set_extended_timeout when disconnect() runs concurrently #721 both touch
set_extended_timeout, but neither is about the response layouts.
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.
Metadata
Metadata
Assignees
Labels
Type
Fields
Priority
Activity
TheJulianJES commentedon Sep 13, 2026
@zigpy-review-bot Investigate, possibly also with second opinions from Copilot: GPT 6 and Fable 5.1 agent. Also consider firmware builder repo and memories there.
zigpy-review-bot commentedon Sep 13, 2026
I checked the report against bellows dev at 8f9d87b, which matches 1.0.1 for every file involved. I compared it with the Silicon Labs EZSP host and NCP sources (GSDK 4.4.6 = v13, SiSDK 2024.6.0 = v14, 2025.12.3 = v18, 2026.6.1 = v19) and read the closed stack functions from their LTO dumps. Copilot (GPT-6 Astra) and a Fable 5.1 agent gave second opinions, and all three agree on the core finding.
The report is correct. Both responses changed at exactly EZSP v14, and bellows never overrode them:
getExtendedTimeout(0x7F): v13 reads oneboolbyte (fetchInt8u()). v14+ reads a 4-bytesl_status_t(fetchInt32u()), and the NCP sends it withappendInt32u(status).lookupNodeIdByEui64(0x60): v13 readsnodeId(fetchInt16u()). v14+ reads a 4-byte status followed bynodeId.bellows/ezsp/v4/commands.py. The_REPLACEMENTSloop inv14/commands.pyonly swapsEmberStatus/EzspStatus, so both schemas pass through unchanged into v14, and through**COMMANDS_v14into v16–v19. Decoding the reported bytes with bellows' own types gives exactly the logged values and trailing data. The proposed layouts decode the same bytes with nothing left over:FAIL;OK, 0xACF5;NOT_FOUND, 0xFFFF. bellows logs the leftover bytes only at DEBUG (protocol.py:180), so the misparse goes unnoticed.One correction to the impact section.
SL_STATUS_OK(0) means "extended" andSL_STATUS_FAIL(1) means "normal", so the misread value is always the opposite of the real state. As a result, the early return inset_extended_timeout()fires exactly when the setting is wrong:Falseand the setting is normal, it doesn't return early. It runs lookup plussetExtendedTimeout(False)on every unicast. This is the measured cost; the end state is correct.Trueand the setting is normal, it returns early, so it never turns the extended timeout on.Trueis often wanted. zigpy setsextended_timeout=Truefor end devices and for devices with no node descriptor yet (zigpy/device.py:632-637). bellows only clears it when an APS ACK is requested (application.py:1010-1017), and zigpy requests one only forask_for_ack=Trueorexpect_reply=False. So the missingsetExtendedTimeout(..., True)in the reporter's logs is caused by this bug, not evidence thatTrueis never wanted.The practical effect is small, though. The flag only lengthens the gap between APS retries. bellows sets
APS_OPTION_RETRYin two cases: with an ACK (where it forces the flag toFalse), and withFORCE_ROUTE_DISCOVERY, which zigpy uses for its own retry attempts. So the actual loss is that zigpy's retries of reply-expecting requests to sleepy end devices use normal retry spacing, unless the stack already extended the timeout itself (for direct children, or after an end-device announce from a sleepy node).Which firmware is affected
COMBINED_SEND(Consolidated unicast sending API NabuCasa/silabs-firmware-builder#209), so they skip this path entirely.Fix. The suggested fix is right, and both parts are needed:
bellows/ezsp/v14/commands.py. This covers v16–v19 through inheritance.set_extended_timeout()onEZSPv14, which v16–v19 inherit. Extended meansstatus == sl_Status.OK. An entry exists whenstatus == OK and nodeId != 0xFFFF.set_extended_timeouttests intests/test_ezsp_v4.py, which currently mock at.Boolresponse.Two NCP details from the LTO dumps:
lookupNodeIdByEui64searches the child, neighbor, and address tables, and returnsNOT_FOUNDwith0xFFFFonly when all three miss. The "address table entry" check inset_extended_timeout()has therefore always meant "known to any table".set_extended_timeouton the NCP always returns OK, even when it stored nothing.After the fix, unicasts to direct sleepy children that want
Falsewill still take the 3-command path every time. The NCP derives their extended state from the child table, andsetExtendedTimeout(False)can't change that. A cache or a comment may be worth adding.Other outdated v14+ schemas. Fable ran a scripted comparison of every bellows v14–v19 command and callback against the SDK host decoders. Of the commands bellows actually calls, one more mismatch affects behavior:
leaveNetwork(0x20): since v14, the command takes auint8optionsargument, and the NCP reads it withoptions = fetchInt8u();. bellows sends no argument, so the NCP reads one byte past the end of the frame. The stack only acts on bit 0x20 (leave with rejoin). I haven't verified the real-world impact, but bellows should send the byte; zigbee-herdsman does for v14+.setManufacturerCodegained ansl_status_tresponse. The 4 leftover bytes are harmless and likely explain some of the report's 48 other trailing-data lines.Outdated definitions bellows never calls (they would misparse if used)
exportTransientKeyByIndex/ByEui,exportLinkKeyByEui,getApsKeyInfo,importKey,checkKeyContext,findAndRejoinNetwork,setAddressTableRemoteEui64(nowsetAddressTableInfo)._REPLACEMENTS: the SDK keeps a 1-byte status, but bellows reads a 4-bytesl_Status. AffectssendPanIdUpdate,readAttribute,writeAttribute.setPowerDescriptorandclearStoredBeaconsgained ansl_status_tresponse.proxyBroadcast0x37,sendMulticastWithAlias0x3A,getFirstBeacon/getNextBeacon,sendRawMessage0x96,incomingSenderEui64Handler0x62, 0xE3–0xE7/0xF0.lastHopLqi/lastHopRssiwithsl_zigbee_rx_packet_info_t:macFilterMatchMessageHandler,incomingBootloadMessageHandler,macPassthroughMessageHandler, the ZLL handlers; alsorawTransmitCompleteHandler.getTokenCount/getTokenInfowidened fromuint8touint32at v18.applicationId, an 8-byte union,endpoint), but bellows'EmberGpAddressis 14 bytes. bellows' own v17gpepIncomingMessageHandleroverride uses that struct, so Fix gpepIncomingMessageHandler schema for EZSP v13+ #746, which fixes the v13 schema, doesn't cover v17 and later. Fix and forward gpepIncomingMessageHandler for Green Power support #713 changes the struct itself.Overlap: no existing PR touches 0x7F or 0x60. #631, which added v14 support, overrode the
setExtendedTimeoutresponse but not these two. #646 added the early return a month later, so on v14 and later it has never worked. Also, the report says zigbee-herdsman decodes both commands the same way, but that's only true for 0x7F. It still reads 0x60 as a bareuint16(ezsp.ts:5753), so it has the same lookup bug.If you want a PR with the fix (optionally including the
leaveNetworkbyte), mention the bot again.TheJulianJES commentedon Sep 13, 2026
@zigpy-review-bot Open a PR, do review rounds before opening.
zigpy-review-bot commentedon Sep 13, 2026
Opened #748. It overrides both response schemas for v14 (v16–v19 inherit them) and moves the version-specific decoding in
set_extended_timeout()into two helpers thatEZSPv14overrides. It also guards the address table fallback, which is now reachable on v14+, against a table size of zero. New tests decode the bytes captured in this issue through every v14+ table. They fail ondev, and the full suite passes.Not included: the
leaveNetworkoptionsbyte and the other drift from the audit above. Mention the bot again if you want those as a follow-up PR. The branch iszigpy-bot/v14-extended-timeout-schemas, if the reporter wants to test it against the SLZB-06M.Review rounds
CONFIG_ADDRESS_TABLE_SIZE: 0(which the config schema allows) the newly reachable fallback callsrandom.randint(0, -1)and aborts the unicast. I confirmed this with a test that fails without the fix, then fixed it and added all three test cases.getConfigurationValue(last-ditch, not cached, re-queried) and for a cached size. Copilot raised one test nit (assert caching across a second call), which I folded in.getConfigurationValueandreplaceAddressTableEntryschemas the fallback now uses.TheJulianJES commentedon Sep 13, 2026
@zigpy-review-bot Open PR(s) for the other issues as well. Do review rounds again.
zigpy-review-bot commentedon Sep 13, 2026
Opened two more PRs for the rest of the v14+ schema drift:
leaveNetworkandsetManufacturerCodeschemas #749 covers the two remaining commands bellows calls. On v14+,leaveNetworknow sends its one-byteoptionsargument (0x00, no option), which the NCP reads whether or not it's sent. ThesetManufacturerCodestatus is now parsed instead of being left over as trailing data.findAndRejoinNetworkarguments,setAddressTableInfo, statuses that were added or widened by mistake, callbacks that now carrypacketInfo, thegetTokenCount/getTokenInfowidening, and deleting commands whose frame IDs the SDK removed or reused.Re-deriving the audit for #750 found some things my earlier comment missed or got wrong:
getAddressTableRemoteNodeId()on v17+ would have sentgpClearProxyTable's frame ID.checkKeyContextdidn't change at v14. It has been defined withsetPassiveAckConfig's arguments since v12.getTokenInfosize was widened in SiSDK 2025.12 without an EZSP version bump, so v18 firmware sends either width. Fix stale EZSP v14+ command and callback schemas #750 changes it for v19 only.Not included: the Green Power address struct, which #746 and #713 cover, and two harmless differences in called commands (
importTransientKeystill sends a flags byte the NCP ignores, andlaunchStandaloneBootloaderreads only the low byte of the 4-byte status).The full suite passes on both PRs, and they merge cleanly with #748 and with each other.
Review rounds
leaveNetworkandsetManufacturerCodeschemas #749 at ddc25f0: a Fable 5.1 agent reported it ready after checking the layouts against the v13, v14 and v19 host and NCP code, every caller, and that the version gate always matches the protocol table in use. Copilot (GPT-5.6 Sol) had no findings.dev, and that nothing reachable on v14+ still uses a deleted or changed definition. Copilot (GPT-5.6 Sol) had no findings. One nit was not taken:readAttribute/writeAttributekeep typing their 1-byte ZCL status asEmberStatus, as in v13.