Skip to content

Commit 06eb3d9

Browse files
authored
Support firmware extensions (#611)
* Support firmware extensions * Fix startup * Expand the custom command protocol * Fix existing unit tests * Handle empty `customFrame` response * Support setting source routes as well * Rename custom commands to XNCP and support board string overrides * Use a generic interface to override manufacturing tokens * Hide firmware-level manual source routing behind `manual_source_routing` * Fix ruff issue * Remove erroneous `getMulticastTableEntry(0)` * Fix unit tests * Support `GET_BUILD_STRING_REQ` * Fix up after rebase * Add support for the `GET_FLOW_CONTROL_TYPE` XNCP command * Fix up after rebase * Coverage * Unit test XNCP commands * Move XNCP tests into their own module * Get coverage up to 100%
1 parent ecce1ba commit 06eb3d9

File tree

8 files changed

+569
-47
lines changed

8 files changed

+569
-47
lines changed

bellows/config/__init__.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
cv_boolean,
1919
)
2020

21+
CONF_BELLOWS_CONFIG = "bellows_config"
22+
CONF_MANUAL_SOURCE_ROUTING = "manual_source_routing"
23+
2124
CONF_USE_THREAD = "use_thread"
2225
CONF_EZSP_CONFIG = "ezsp_config"
2326
CONF_EZSP_POLICIES = "ezsp_policies"
@@ -31,6 +34,12 @@
3134
{vol.Optional(str): int}
3235
),
3336
vol.Optional(CONF_USE_THREAD, default=True): cv_boolean,
37+
# The above config really should belong in here
38+
vol.Optional(CONF_BELLOWS_CONFIG, default={}): vol.Schema(
39+
{
40+
vol.Optional(CONF_MANUAL_SOURCE_ROUTING, default=False): bool,
41+
}
42+
),
3443
}
3544
)
3645

bellows/ezsp/__init__.py

Lines changed: 91 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,9 @@
2323

2424
import bellows.config as conf
2525
from bellows.exception import EzspError, InvalidCommandError
26+
from bellows.ezsp import xncp
2627
from bellows.ezsp.config import DEFAULT_CONFIG, RuntimeConfig, ValueConfig
28+
from bellows.ezsp.xncp import FirmwareFeatures, FlowControlType
2729
import bellows.types as t
2830
import bellows.uart
2931

@@ -62,6 +64,7 @@ def __init__(self, device_config: dict, application: Any | None = None):
6264
self._callbacks = {}
6365
self._ezsp_event = asyncio.Event()
6466
self._ezsp_version = v4.EZSPv4.VERSION
67+
self._xncp_features = FirmwareFeatures.NONE
6568
self._gw = None
6669
self._protocol = None
6770
self._application = application
@@ -124,6 +127,7 @@ async def startup_reset(self) -> None:
124127
await self.reset()
125128

126129
await self.version()
130+
await self.get_xncp_features()
127131

128132
async def connect(self, *, use_thread: bool = True) -> None:
129133
assert self._gw is None
@@ -167,13 +171,22 @@ async def version(self):
167171
if ver != self.ezsp_version:
168172
self._switch_protocol_version(ver)
169173
await self._command("version", desiredProtocolVersion=ver)
174+
170175
LOGGER.debug(
171-
"EZSP Stack Type: %s, Stack Version: %04x, Protocol version: %s",
176+
("EZSP Stack Type: %s" ", Stack Version: %04x" ", Protocol version: %s"),
172177
stack_type,
173178
stack_version,
174179
ver,
175180
)
176181

182+
async def get_xncp_features(self) -> None:
183+
try:
184+
self._xncp_features = await self.xncp_get_supported_firmware_features()
185+
except InvalidCommandError:
186+
self._xncp_features = xncp.FirmwareFeatures.NONE
187+
188+
LOGGER.debug("XNCP features: %s", self._xncp_features)
189+
177190
async def disconnect(self):
178191
self.stop_ezsp()
179192
if self._gw:
@@ -308,11 +321,10 @@ async def get_board_info(
308321
) -> tuple[str, str, str | None] | tuple[None, None, str | None]:
309322
"""Return board info."""
310323

311-
tokens = {}
324+
tokens: dict[t.EzspMfgTokenId, str | None] = {}
312325

313-
for token in (t.EzspMfgTokenId.MFG_STRING, t.EzspMfgTokenId.MFG_BOARD_NAME):
314-
(value,) = await self.getMfgToken(tokenId=token)
315-
LOGGER.debug("Read %s token: %s", token.name, value)
326+
for token_id in (t.EzspMfgTokenId.MFG_STRING, t.EzspMfgTokenId.MFG_BOARD_NAME):
327+
value = await self.get_mfg_token(token_id)
316328

317329
# Tokens are fixed-length and initially filled with \xFF but also can end
318330
# with \x00
@@ -324,10 +336,7 @@ async def get_board_info(
324336
except UnicodeDecodeError:
325337
result = "0x" + value.hex().upper()
326338

327-
if not result:
328-
result = None
329-
330-
tokens[token] = result
339+
tokens[token_id] = result or None
331340

332341
(status, ver_info_bytes) = await self.getValue(
333342
valueId=t.EzspValueId.VALUE_VERSION_INFO
@@ -342,6 +351,14 @@ async def get_board_info(
342351
special, ver_info_bytes = t.uint8_t.deserialize(ver_info_bytes)
343352
version = f"{major}.{minor}.{patch}.{special} build {build}"
344353

354+
try:
355+
build_string = await self.xncp_get_build_string()
356+
except InvalidCommandError:
357+
build_string = None
358+
359+
if build_string:
360+
version = f"{version} ({build_string})"
361+
345362
return (
346363
tokens[t.EzspMfgTokenId.MFG_STRING],
347364
tokens[t.EzspMfgTokenId.MFG_BOARD_NAME],
@@ -369,9 +386,23 @@ async def _get_nv3_restored_eui64_key(self) -> t.NV3KeyId | None:
369386

370387
return None
371388

389+
async def get_mfg_token(self, token: t.EzspMfgTokenId) -> bytes:
390+
(value,) = await self.getMfgToken(tokenId=token)
391+
LOGGER.debug("Read manufacturing token %s: %s", token.name, value)
392+
393+
override_value = None
394+
395+
if FirmwareFeatures.MFG_TOKEN_OVERRIDES in self._xncp_features:
396+
with contextlib.suppress(InvalidCommandError):
397+
override_value = await self.xncp_get_mfg_token_override(token)
398+
399+
LOGGER.debug("XNCP override token %s: %s", token.name, override_value)
400+
401+
return override_value or value
402+
372403
async def _get_mfg_custom_eui_64(self) -> t.EUI64 | None:
373404
"""Get the custom EUI 64 manufacturing token, if it has a valid value."""
374-
(data,) = await self.getMfgToken(tokenId=t.EzspMfgTokenId.MFG_CUSTOM_EUI_64)
405+
data = await self.get_mfg_token(t.EzspMfgTokenId.MFG_CUSTOM_EUI_64)
375406

376407
# Manufacturing tokens do not exist in RCP firmware: all reads are empty
377408
if not data:
@@ -616,3 +647,53 @@ async def write_config(self, config: dict) -> None:
616647
status,
617648
)
618649
continue
650+
651+
async def send_xncp_frame(
652+
self, payload: xncp.XncpCommandPayload
653+
) -> xncp.XncpCommandPayload:
654+
"""Send an XNCP frame."""
655+
req_frame = xncp.XncpCommand.from_payload(payload)
656+
LOGGER.debug("Sending XNCP frame: %s", req_frame)
657+
status, data = await self.customFrame(req_frame.serialize())
658+
659+
if status != t.EmberStatus.SUCCESS:
660+
raise InvalidCommandError("XNCP is not supported")
661+
662+
rsp_frame = xncp.XncpCommand.from_bytes(data)
663+
LOGGER.debug("Received XNCP frame: %s", rsp_frame)
664+
665+
if rsp_frame.status != t.EmberStatus.SUCCESS:
666+
raise InvalidCommandError(f"XNCP response error: {rsp_frame.status}")
667+
668+
return rsp_frame.payload
669+
670+
async def xncp_get_supported_firmware_features(self) -> xncp.FirmwareFeatures:
671+
"""Get supported firmware extensions."""
672+
rsp = await self.send_xncp_frame(xncp.GetSupportedFeaturesReq())
673+
return rsp.features
674+
675+
async def xncp_set_manual_source_route(
676+
self, destination: t.NWK, route: list[t.NWK]
677+
) -> None:
678+
"""Set a manual source route."""
679+
await self.send_xncp_frame(
680+
xncp.SetSourceRouteReq(
681+
destination=destination,
682+
source_route=route,
683+
)
684+
)
685+
686+
async def xncp_get_mfg_token_override(self, token: t.EzspMfgTokenId) -> bytes:
687+
"""Get manufacturing token override."""
688+
rsp = await self.send_xncp_frame(xncp.GetMfgTokenOverrideReq(token=token))
689+
return rsp.value
690+
691+
async def xncp_get_build_string(self) -> str:
692+
"""Get build string."""
693+
rsp = await self.send_xncp_frame(xncp.GetBuildStringReq())
694+
return rsp.build_string.decode("utf-8")
695+
696+
async def xncp_get_flow_control_type(self) -> FlowControlType:
697+
"""Get flow control type."""
698+
rsp = await self.send_xncp_frame(xncp.GetFlowControlTypeReq())
699+
return rsp.flow_control_type

bellows/ezsp/xncp.py

Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
"""Custom EZSP commands."""
2+
from __future__ import annotations
3+
4+
import dataclasses
5+
import logging
6+
from typing import Callable
7+
8+
import zigpy.types as t
9+
10+
from bellows.types import EmberStatus, EzspMfgTokenId
11+
12+
_LOGGER = logging.getLogger(__name__)
13+
14+
COMMANDS: dict[XncpCommandId, type[XncpCommandPayload]] = {}
15+
REV_COMMANDS: dict[type[XncpCommandPayload], XncpCommandId] = {}
16+
17+
18+
def register_command(command_id: XncpCommandId) -> Callable[[type], type]:
19+
def decorator(cls: type) -> type:
20+
COMMANDS[command_id] = cls
21+
REV_COMMANDS[cls] = command_id
22+
return cls
23+
24+
return decorator
25+
26+
27+
class Bytes(bytes):
28+
def serialize(self) -> Bytes:
29+
return self
30+
31+
@classmethod
32+
def deserialize(cls, data: bytes) -> tuple[Bytes, bytes]:
33+
return cls(data), b""
34+
35+
36+
class XncpCommandId(t.enum16):
37+
GET_SUPPORTED_FEATURES_REQ = 0x0000
38+
SET_SOURCE_ROUTE_REQ = 0x0001
39+
GET_MFG_TOKEN_OVERRIDE_REQ = 0x0002
40+
GET_BUILD_STRING_REQ = 0x0003
41+
GET_FLOW_CONTROL_TYPE_REQ = 0x0004
42+
43+
GET_SUPPORTED_FEATURES_RSP = GET_SUPPORTED_FEATURES_REQ | 0x8000
44+
SET_SOURCE_ROUTE_RSP = SET_SOURCE_ROUTE_REQ | 0x8000
45+
GET_MFG_TOKEN_OVERRIDE_RSP = GET_MFG_TOKEN_OVERRIDE_REQ | 0x8000
46+
GET_BUILD_STRING_RSP = GET_BUILD_STRING_REQ | 0x8000
47+
GET_FLOW_CONTROL_TYPE_RSP = GET_FLOW_CONTROL_TYPE_REQ | 0x8000
48+
49+
UNKNOWN = 0xFFFF
50+
51+
52+
@dataclasses.dataclass
53+
class XncpCommand:
54+
command_id: XncpCommandId
55+
status: EmberStatus
56+
payload: XncpCommandPayload
57+
58+
@classmethod
59+
def from_payload(cls, payload: XncpCommandPayload) -> XncpCommand:
60+
return cls(
61+
command_id=REV_COMMANDS[type(payload)],
62+
status=EmberStatus.SUCCESS,
63+
payload=payload,
64+
)
65+
66+
@classmethod
67+
def from_bytes(cls, data: bytes) -> XncpCommand:
68+
command_id, data = XncpCommandId.deserialize(data)
69+
status, data = EmberStatus.deserialize(data)
70+
payload, rest = COMMANDS[command_id].deserialize(data)
71+
72+
if rest:
73+
_LOGGER.debug("Unparsed data remains after %s frame: %s", payload, rest)
74+
75+
return cls(command_id=command_id, status=status, payload=payload)
76+
77+
def serialize(self) -> Bytes:
78+
return (
79+
self.command_id.serialize()
80+
+ self.status.serialize()
81+
+ self.payload.serialize()
82+
)
83+
84+
85+
class FirmwareFeatures(t.bitmap32):
86+
NONE = 0
87+
88+
# The firmware passes through all group traffic, regardless of group membership
89+
MEMBER_OF_ALL_GROUPS = 1 << 0
90+
91+
# Source routes can be overridden by the application
92+
MANUAL_SOURCE_ROUTE = 1 << 1
93+
94+
# The firmware supports overriding some manufacturing tokens
95+
MFG_TOKEN_OVERRIDES = 1 << 2
96+
97+
# The firmware contains a free-form build string
98+
BUILD_STRING = 1 << 3
99+
100+
# The flow control type (software or hardware) can be queried
101+
FLOW_CONTROL_TYPE = 1 << 4
102+
103+
104+
class XncpCommandPayload(t.Struct):
105+
pass
106+
107+
108+
class FlowControlType(t.enum8):
109+
Software = 0x00
110+
Hardware = 0x01
111+
112+
113+
@register_command(XncpCommandId.GET_SUPPORTED_FEATURES_REQ)
114+
class GetSupportedFeaturesReq(XncpCommandPayload):
115+
pass
116+
117+
118+
@register_command(XncpCommandId.GET_SUPPORTED_FEATURES_RSP)
119+
class GetSupportedFeaturesRsp(XncpCommandPayload):
120+
features: FirmwareFeatures
121+
122+
123+
@register_command(XncpCommandId.SET_SOURCE_ROUTE_REQ)
124+
class SetSourceRouteReq(XncpCommandPayload):
125+
destination: t.NWK
126+
source_route: t.List[t.NWK]
127+
128+
129+
@register_command(XncpCommandId.SET_SOURCE_ROUTE_RSP)
130+
class SetSourceRouteRsp(XncpCommandPayload):
131+
pass
132+
133+
134+
@register_command(XncpCommandId.GET_MFG_TOKEN_OVERRIDE_REQ)
135+
class GetMfgTokenOverrideReq(XncpCommandPayload):
136+
token: EzspMfgTokenId
137+
138+
139+
@register_command(XncpCommandId.GET_MFG_TOKEN_OVERRIDE_RSP)
140+
class GetMfgTokenOverrideRsp(XncpCommandPayload):
141+
value: Bytes
142+
143+
144+
@register_command(XncpCommandId.GET_BUILD_STRING_REQ)
145+
class GetBuildStringReq(XncpCommandPayload):
146+
pass
147+
148+
149+
@register_command(XncpCommandId.GET_BUILD_STRING_RSP)
150+
class GetBuildStringRsp(XncpCommandPayload):
151+
build_string: Bytes
152+
153+
154+
@register_command(XncpCommandId.GET_FLOW_CONTROL_TYPE_REQ)
155+
class GetFlowControlTypeReq(XncpCommandPayload):
156+
pass
157+
158+
159+
@register_command(XncpCommandId.GET_FLOW_CONTROL_TYPE_RSP)
160+
class GetFlowControlTypeRsp(XncpCommandPayload):
161+
flow_control_type: FlowControlType

0 commit comments

Comments
 (0)