diff --git a/bellows/config/__init__.py b/bellows/config/__init__.py index 29ffe647..74d91d9c 100644 --- a/bellows/config/__init__.py +++ b/bellows/config/__init__.py @@ -18,6 +18,9 @@ cv_boolean, ) +CONF_BELLOWS_CONFIG = "bellows_config" +CONF_MANUAL_SOURCE_ROUTING = "manual_source_routing" + CONF_USE_THREAD = "use_thread" CONF_EZSP_CONFIG = "ezsp_config" CONF_EZSP_POLICIES = "ezsp_policies" @@ -31,6 +34,12 @@ {vol.Optional(str): int} ), vol.Optional(CONF_USE_THREAD, default=True): cv_boolean, + # The above config really should belong in here + vol.Optional(CONF_BELLOWS_CONFIG, default={}): vol.Schema( + { + vol.Optional(CONF_MANUAL_SOURCE_ROUTING, default=False): bool, + } + ), } ) diff --git a/bellows/ezsp/__init__.py b/bellows/ezsp/__init__.py index 1ff511d3..bbd74280 100644 --- a/bellows/ezsp/__init__.py +++ b/bellows/ezsp/__init__.py @@ -21,7 +21,9 @@ import bellows.config as conf from bellows.exception import EzspError, InvalidCommandError +from bellows.ezsp import xncp from bellows.ezsp.config import DEFAULT_CONFIG, RuntimeConfig, ValueConfig +from bellows.ezsp.xncp import FirmwareFeatures, FlowControlType import bellows.types as t import bellows.uart @@ -60,6 +62,7 @@ def __init__(self, device_config: dict): self._callbacks = {} self._ezsp_event = asyncio.Event() self._ezsp_version = v4.EZSPv4.VERSION + self._xncp_features = FirmwareFeatures.NONE self._gw = None self._protocol = None @@ -121,6 +124,7 @@ async def startup_reset(self) -> None: await self.reset() await self.version() + await self.get_xncp_features() @classmethod async def initialize(cls, zigpy_config: dict) -> EZSP: @@ -172,13 +176,22 @@ async def version(self): if ver != self.ezsp_version: self._switch_protocol_version(ver) await self._command("version", desiredProtocolVersion=ver) + LOGGER.debug( - "EZSP Stack Type: %s, Stack Version: %04x, Protocol version: %s", + ("EZSP Stack Type: %s" ", Stack Version: %04x" ", Protocol version: %s"), stack_type, stack_version, ver, ) + async def get_xncp_features(self) -> None: + try: + self._xncp_features = await self.xncp_get_supported_firmware_features() + except InvalidCommandError: + self._xncp_features = xncp.FirmwareFeatures.NONE + + LOGGER.debug("XNCP features: %s", self._xncp_features) + def close(self): self.stop_ezsp() if self._gw: @@ -324,11 +337,10 @@ async def get_board_info( ) -> tuple[str, str, str | None] | tuple[None, None, str | None]: """Return board info.""" - tokens = {} + tokens: dict[t.EzspMfgTokenId, str | None] = {} - for token in (t.EzspMfgTokenId.MFG_STRING, t.EzspMfgTokenId.MFG_BOARD_NAME): - (value,) = await self.getMfgToken(tokenId=token) - LOGGER.debug("Read %s token: %s", token.name, value) + for token_id in (t.EzspMfgTokenId.MFG_STRING, t.EzspMfgTokenId.MFG_BOARD_NAME): + value = await self.get_mfg_token(token_id) # Tokens are fixed-length and initially filled with \xFF but also can end # with \x00 @@ -340,10 +352,7 @@ async def get_board_info( except UnicodeDecodeError: result = "0x" + value.hex().upper() - if not result: - result = None - - tokens[token] = result + tokens[token_id] = result or None (status, ver_info_bytes) = await self.getValue( valueId=t.EzspValueId.VALUE_VERSION_INFO @@ -358,6 +367,15 @@ async def get_board_info( special, ver_info_bytes = t.uint8_t.deserialize(ver_info_bytes) version = f"{major}.{minor}.{patch}.{special} build {build}" + if xncp.FirmwareFeatures.BUILD_STRING in self._xncp_features: + try: + build_string = await self.xncp_get_build_string() + except InvalidCommandError: + build_string = None + + if build_string: + version = f"{version} ({build_string})" + return ( tokens[t.EzspMfgTokenId.MFG_STRING], tokens[t.EzspMfgTokenId.MFG_BOARD_NAME], @@ -385,9 +403,23 @@ async def _get_nv3_restored_eui64_key(self) -> t.NV3KeyId | None: return None + async def get_mfg_token(self, token: t.EzspMfgTokenId) -> bytes: + (value,) = await self.getMfgToken(tokenId=token) + LOGGER.debug("Read manufacturing token %s: %s", token.name, value) + + override_value = None + + if FirmwareFeatures.MFG_TOKEN_OVERRIDES in self._xncp_features: + with contextlib.suppress(InvalidCommandError): + override_value = await self.xncp_get_mfg_token_override(token) + + LOGGER.debug("XNCP override token %s: %s", token.name, override_value) + + return override_value or value + async def _get_mfg_custom_eui_64(self) -> t.EUI64 | None: """Get the custom EUI 64 manufacturing token, if it has a valid value.""" - (data,) = await self.getMfgToken(tokenId=t.EzspMfgTokenId.MFG_CUSTOM_EUI_64) + data = await self.get_mfg_token(t.EzspMfgTokenId.MFG_CUSTOM_EUI_64) # Manufacturing tokens do not exist in RCP firmware: all reads are empty if not data: @@ -632,3 +664,53 @@ async def write_config(self, config: dict) -> None: status, ) continue + + async def send_xncp_frame( + self, payload: xncp.XncpCommandPayload + ) -> xncp.XncpCommandPayload: + """Send an XNCP frame.""" + req_frame = xncp.XncpCommand.from_payload(payload) + LOGGER.debug("Sending XNCP frame: %s", req_frame) + status, data = await self.customFrame(req_frame.serialize()) + + if status != t.EmberStatus.SUCCESS: + raise InvalidCommandError("XNCP is not supported") + + rsp_frame = xncp.XncpCommand.from_bytes(data) + LOGGER.debug("Received XNCP frame: %s", rsp_frame) + + if rsp_frame.status != t.EmberStatus.SUCCESS: + raise InvalidCommandError(f"XNCP response error: {rsp_frame.status}") + + return rsp_frame.payload + + async def xncp_get_supported_firmware_features(self) -> xncp.FirmwareFeatures: + """Get supported firmware extensions.""" + rsp = await self.send_xncp_frame(xncp.GetSupportedFeaturesReq()) + return rsp.features + + async def xncp_set_manual_source_route( + self, destination: t.NWK, route: list[t.NWK] + ) -> None: + """Set a manual source route.""" + await self.send_xncp_frame( + xncp.SetSourceRouteReq( + destination=destination, + source_route=route, + ) + ) + + async def xncp_get_mfg_token_override(self, token: t.EzspMfgTokenId) -> bytes: + """Get manufacturing token override.""" + rsp = await self.send_xncp_frame(xncp.GetMfgTokenOverrideReq(token=token)) + return rsp.value + + async def xncp_get_build_string(self) -> bytes: + """Get build string.""" + rsp = await self.send_xncp_frame(xncp.GetBuildStringReq()) + return rsp.build_string.decode("utf-8") + + async def xncp_get_flow_control_type(self) -> FlowControlType: + """Get flow control type.""" + rsp = await self.send_xncp_frame(xncp.GetFlowControlTypeReq()) + return rsp.flow_control_type diff --git a/bellows/ezsp/xncp.py b/bellows/ezsp/xncp.py new file mode 100644 index 00000000..f6dc52ce --- /dev/null +++ b/bellows/ezsp/xncp.py @@ -0,0 +1,170 @@ +"""Custom EZSP commands.""" +from __future__ import annotations + +import dataclasses +import logging +from typing import Callable + +import zigpy.types as t + +from bellows.types import EmberStatus, EzspMfgTokenId + +_LOGGER = logging.getLogger(__name__) + +COMMANDS: dict[XncpCommandId, type[XncpCommandPayload]] = {} +REV_COMMANDS: dict[type[XncpCommandPayload], XncpCommandId] = {} + + +def register_command(command_id: XncpCommandId) -> Callable[[type], type]: + def decorator(cls: type) -> type: + COMMANDS[command_id] = cls + REV_COMMANDS[cls] = command_id + return cls + + return decorator + + +class Bytes(bytes): + def serialize(self) -> Bytes: + return self + + @classmethod + def deserialize(cls, data: bytes) -> tuple[Bytes, bytes]: + return cls(data), b"" + + def __repr__(self) -> str: + # Reading byte sequences like \x200\x21 is extremely annoying + # compared to \x20\x30\x21 + escaped = "".join(f"\\x{b:02X}" for b in self) + + return f"b'{escaped}'" + + __str__ = __repr__ + + +class XncpCommandId(t.enum16): + GET_SUPPORTED_FEATURES_REQ = 0x0000 + SET_SOURCE_ROUTE_REQ = 0x0001 + GET_MFG_TOKEN_OVERRIDE_REQ = 0x0002 + GET_BUILD_STRING_REQ = 0x0003 + GET_FLOW_CONTROL_TYPE_REQ = 0x0004 + + GET_SUPPORTED_FEATURES_RSP = GET_SUPPORTED_FEATURES_REQ | 0x8000 + SET_SOURCE_ROUTE_RSP = SET_SOURCE_ROUTE_REQ | 0x8000 + GET_MFG_TOKEN_OVERRIDE_RSP = GET_MFG_TOKEN_OVERRIDE_REQ | 0x8000 + GET_BUILD_STRING_RSP = GET_BUILD_STRING_REQ | 0x8000 + GET_FLOW_CONTROL_TYPE_RSP = GET_FLOW_CONTROL_TYPE_REQ | 0x8000 + + UNKNOWN = 0xFFFF + + +@dataclasses.dataclass +class XncpCommand: + command_id: XncpCommandId + status: EmberStatus + payload: XncpCommandPayload + + @classmethod + def from_payload(cls, payload: XncpCommandPayload) -> XncpCommand: + return cls( + command_id=REV_COMMANDS[type(payload)], + status=EmberStatus.SUCCESS, + payload=payload, + ) + + @classmethod + def from_bytes(cls, data: bytes) -> XncpCommand: + command_id, data = XncpCommandId.deserialize(data) + status, data = EmberStatus.deserialize(data) + payload, rest = COMMANDS[command_id].deserialize(data) + + if rest: + _LOGGER.debug("Unparsed data remains after %s frame: %s", payload, rest) + + return cls(command_id=command_id, status=status, payload=payload) + + def serialize(self) -> Bytes: + return ( + self.command_id.serialize() + + self.status.serialize() + + self.payload.serialize() + ) + + +class FirmwareFeatures(t.bitmap32): + NONE = 0 + + # The firmware passes through all group traffic, regardless of group membership + MEMBER_OF_ALL_GROUPS = 1 << 0 + + # Source routes can be overridden by the application + MANUAL_SOURCE_ROUTE = 1 << 1 + + # The firmware supports overriding some manufacturing tokens + MFG_TOKEN_OVERRIDES = 1 << 2 + + # The firmware contains a free-form build string + BUILD_STRING = 1 << 3 + + # The flow control type (software or hardware) can be queried + FLOW_CONTROL_TYPE = 1 << 4 + + +class XncpCommandPayload(t.Struct): + pass + + +class FlowControlType(t.enum8): + Software = 0x00 + Hardware = 0x01 + + +@register_command(XncpCommandId.GET_SUPPORTED_FEATURES_REQ) +class GetSupportedFeaturesReq(XncpCommandPayload): + pass + + +@register_command(XncpCommandId.GET_SUPPORTED_FEATURES_RSP) +class GetSupportedFeaturesRsp(XncpCommandPayload): + features: FirmwareFeatures + + +@register_command(XncpCommandId.SET_SOURCE_ROUTE_REQ) +class SetSourceRouteReq(XncpCommandPayload): + destination: t.NWK + source_route: t.List[t.NWK] + + +@register_command(XncpCommandId.SET_SOURCE_ROUTE_RSP) +class SetSourceRouteRsp(XncpCommandPayload): + pass + + +@register_command(XncpCommandId.GET_MFG_TOKEN_OVERRIDE_REQ) +class GetMfgTokenOverrideReq(XncpCommandPayload): + token: EzspMfgTokenId + + +@register_command(XncpCommandId.GET_MFG_TOKEN_OVERRIDE_RSP) +class GetMfgTokenOverrideRsp(XncpCommandPayload): + value: Bytes + + +@register_command(XncpCommandId.GET_BUILD_STRING_REQ) +class GetBuildStringReq(XncpCommandPayload): + pass + + +@register_command(XncpCommandId.GET_BUILD_STRING_RSP) +class GetBuildStringRsp(XncpCommandPayload): + build_string: Bytes + + +@register_command(XncpCommandId.GET_FLOW_CONTROL_TYPE_REQ) +class GetFlowControlTypeReq(XncpCommandPayload): + pass + + +@register_command(XncpCommandId.GET_FLOW_CONTROL_TYPE_RSP) +class GetFlowControlTypeRsp(XncpCommandPayload): + flow_control_type: FlowControlType diff --git a/bellows/zigbee/application.py b/bellows/zigbee/application.py index 0680184e..e533ebfc 100644 --- a/bellows/zigbee/application.py +++ b/bellows/zigbee/application.py @@ -25,17 +25,25 @@ import bellows from bellows.config import ( + CONF_BELLOWS_CONFIG, CONF_EZSP_CONFIG, CONF_EZSP_POLICIES, + CONF_MANUAL_SOURCE_ROUTING, CONF_USE_THREAD, CONFIG_SCHEMA, ) -from bellows.exception import ControllerError, EzspError, StackAlreadyRunning +from bellows.exception import ( + ControllerError, + EzspError, + InvalidCommandError, + StackAlreadyRunning, +) import bellows.ezsp +from bellows.ezsp.xncp import FirmwareFeatures import bellows.multicast import bellows.types as t from bellows.zigbee import repairs -from bellows.zigbee.device import EZSPEndpoint +from bellows.zigbee.device import EZSPEndpoint, EZSPGroupEndpoint import bellows.zigbee.util as util APS_ACK_TIMEOUT = 120 @@ -203,13 +211,19 @@ async def start_network(self): group_membership = {} - try: - db_device = self.get_device(ieee=self.state.node_info.ieee) - except KeyError: - pass + if FirmwareFeatures.MEMBER_OF_ALL_GROUPS in self._ezsp._xncp_features: + # If the firmware passes through all incoming group messages, do nothing + endpoint_cls = EZSPEndpoint else: - if 1 in db_device.endpoints: - group_membership = db_device.endpoints[1].member_of + endpoint_cls = EZSPGroupEndpoint + + try: + db_device = self.get_device(ieee=self.state.node_info.ieee) + except KeyError: + pass + else: + if 1 in db_device.endpoints: + group_membership = db_device.endpoints[1].member_of ezsp_device = zigpy.device.Device( application=self, @@ -221,18 +235,17 @@ async def start_network(self): # The coordinator device does not respond to attribute reads so we have to # divine the internal NCP state. for zdo_desc in self._created_device_endpoints: - ep = EZSPEndpoint(ezsp_device, zdo_desc.endpoint, zdo_desc) + ep = endpoint_cls.from_descriptor(ezsp_device, zdo_desc.endpoint, zdo_desc) ezsp_device.endpoints[zdo_desc.endpoint] = ep ezsp_device.model = ep.model ezsp_device.manufacturer = ep.manufacturer await ezsp_device.schedule_initialize() - # Group membership is stored in the database for EZSP coordinators - ezsp_device.endpoints[1].member_of.update(group_membership) - - self._multicast = bellows.multicast.Multicast(ezsp) - await self._multicast.startup(ezsp_device) + if FirmwareFeatures.MEMBER_OF_ALL_GROUPS not in self._ezsp._xncp_features: + ezsp_device.endpoints[1].member_of.update(group_membership) + self._multicast = bellows.multicast.Multicast(ezsp) + await self._multicast.startup(ezsp_device) async def load_network_info(self, *, load_devices=False) -> None: ezsp = self._ezsp @@ -287,6 +300,11 @@ async def load_network_info(self, *, load_devices=False) -> None: can_burn_userdata_custom_eui64 = await ezsp.can_burn_userdata_custom_eui64() can_rewrite_custom_eui64 = await ezsp.can_rewrite_custom_eui64() + try: + flow_control = await self._ezsp.xncp_get_flow_control_type() + except InvalidCommandError: + flow_control = None + self.state.network_info = zigpy.state.NetworkInfo( source=f"bellows@{LIB_VERSION}", extended_pan_id=zigpy.types.ExtendedPanId(nwk_params.extendedPanId), @@ -307,6 +325,9 @@ async def load_network_info(self, *, load_devices=False) -> None: "stack_version": ezsp.ezsp_version, "can_burn_userdata_custom_eui64": can_burn_userdata_custom_eui64, "can_rewrite_custom_eui64": can_rewrite_custom_eui64, + "flow_control": ( + flow_control.name.lower() if flow_control is not None else None + ), } }, ) @@ -756,10 +777,22 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: ) if packet.source_route is not None: - await self._ezsp.set_source_route( - nwk=packet.dst.address, - relays=packet.source_route, - ) + if ( + FirmwareFeatures.MANUAL_SOURCE_ROUTE + in self._ezsp._xncp_features + and self.config[CONF_BELLOWS_CONFIG][ + CONF_MANUAL_SOURCE_ROUTING + ] + ): + await self._ezsp.xncp_set_manual_source_route( + nwk=packet.dst.address, + relays=packet.source_route, + ) + else: + await self._ezsp.set_source_route( + nwk=packet.dst.address, + relays=packet.source_route, + ) status, _ = await self._ezsp.send_unicast( nwk=packet.dst.address, diff --git a/bellows/zigbee/device.py b/bellows/zigbee/device.py index f34f3b80..9e6b65f7 100644 --- a/bellows/zigbee/device.py +++ b/bellows/zigbee/device.py @@ -21,30 +21,32 @@ class EZSPEndpoint(zigpy.endpoint.Endpoint): - def __init__( - self, + @classmethod + def from_descriptor( + cls, device: zigpy.device.Device, endpoint_id: int, descriptor: zdo_t.SimpleDescriptor, ) -> None: - super().__init__(device, endpoint_id) + ep = cls(device, endpoint_id) + ep.profile_id = descriptor.profile - self.profile_id = descriptor.profile - - if self.profile_id in PROFILE_TO_DEVICE_TYPE: - self.device_type = PROFILE_TO_DEVICE_TYPE[self.profile_id]( + if ep.profile_id in PROFILE_TO_DEVICE_TYPE: + ep.device_type = PROFILE_TO_DEVICE_TYPE[ep.profile_id]( descriptor.device_type ) else: - self.device_type = descriptor.device_type + ep.device_type = descriptor.device_type for cluster in descriptor.input_clusters: - self.add_input_cluster(cluster) + ep.add_input_cluster(cluster) for cluster in descriptor.output_clusters: - self.add_output_cluster(cluster) + ep.add_output_cluster(cluster) + + ep.status = zigpy.endpoint.Status.ZDO_INIT - self.status = zigpy.endpoint.Status.ZDO_INIT + return ep @property def manufacturer(self) -> str: @@ -56,7 +58,9 @@ def model(self) -> str: """Model.""" return "EZSP" - async def add_to_group(self, grp_id: int, name: str = None) -> None: + +class EZSPGroupEndpoint(EZSPEndpoint): + async def add_to_group(self, grp_id: int, name: str = None) -> t.EmberStatus: if grp_id in self.member_of: return diff --git a/tests/test_application.py b/tests/test_application.py index e35adfc5..70b3ca66 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -16,6 +16,7 @@ from bellows.exception import ControllerError, EzspError import bellows.ezsp as ezsp from bellows.ezsp.v9.commands import GetTokenDataRsp +from bellows.ezsp.xncp import FirmwareFeatures import bellows.types import bellows.types as t import bellows.types.struct @@ -121,6 +122,10 @@ def _create_app_for_startup( ezsp_mock.wait_for_stack_status.return_value.__enter__ = AsyncMock( return_value=t.EmberStatus.NETWORK_UP ) + ezsp_mock.xncp_get_supported_firmware_features = AsyncMock( + return_value=FirmwareFeatures.NONE + ) + ezsp_mock._xncp_features = FirmwareFeatures.NONE if board_info: ezsp_mock.get_board_info = AsyncMock( @@ -1295,7 +1300,9 @@ async def test_shutdown(app): @pytest.fixture def coordinator(app, ieee): dev = zigpy.device.Device(app, ieee, 0x0000) - dev.endpoints[1] = bellows.zigbee.device.EZSPEndpoint(dev, 1, MagicMock()) + dev.endpoints[1] = bellows.zigbee.device.EZSPGroupEndpoint.from_descriptor( + dev, 1, MagicMock() + ) dev.model = dev.endpoints[1].model dev.manufacturer = dev.endpoints[1].manufacturer @@ -1628,8 +1635,8 @@ async def test_startup_coordinator_existing_groups_joined(app, ieee): db_device = app.add_device(ieee, 0x0000) db_ep = db_device.add_endpoint(1) - app.groups.add_group(0x1234, "Group Name", suppress_event=True) - app.groups[0x1234].add_member(db_ep, suppress_event=True) + group = app.groups.add_group(0x1234, "Group Name", suppress_event=True) + group.add_member(db_ep, suppress_event=True) await app.start_network() diff --git a/tests/test_ezsp.py b/tests/test_ezsp.py index ee20af84..4bcb4a3c 100644 --- a/tests/test_ezsp.py +++ b/tests/test_ezsp.py @@ -286,8 +286,9 @@ async def test_no_close_without_callback(ezsp_f): @patch.object(ezsp.EZSP, "version", new_callable=AsyncMock) @patch.object(ezsp.EZSP, "reset", new_callable=AsyncMock) +@patch.object(ezsp.EZSP, "get_xncp_features", new_callable=AsyncMock) @patch("bellows.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) -async def test_ezsp_init(conn_mock, reset_mock, version_mock): +async def test_ezsp_init(conn_mock, xncp_mock, reset_mock, version_mock): """Test initialize method.""" zigpy_config = config.CONFIG_SCHEMA({"device": DEVICE_CONFIG}) await ezsp.EZSP.initialize(zigpy_config) @@ -618,8 +619,9 @@ async def test_write_custom_eui64_rcp(ezsp_f): @patch.object(ezsp.EZSP, "version", new_callable=AsyncMock) @patch.object(ezsp.EZSP, "reset", new_callable=AsyncMock) +@patch.object(ezsp.EZSP, "get_xncp_features", new_callable=AsyncMock) @patch("bellows.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) -async def test_ezsp_init_zigbeed(conn_mock, reset_mock, version_mock): +async def test_ezsp_init_zigbeed(conn_mock, xncp_mock, reset_mock, version_mock): """Test initialize method with a received startup reset frame.""" zigpy_config = config.CONFIG_SCHEMA( { @@ -642,9 +644,12 @@ async def test_ezsp_init_zigbeed(conn_mock, reset_mock, version_mock): @patch.object(ezsp.EZSP, "version", new_callable=AsyncMock) @patch.object(ezsp.EZSP, "reset", new_callable=AsyncMock) +@patch.object(ezsp.EZSP, "get_xncp_features", new_callable=AsyncMock) @patch("bellows.uart.connect", return_value=MagicMock(spec_set=uart.Gateway)) @patch("bellows.ezsp.NETWORK_COORDINATOR_STARTUP_RESET_WAIT", 0.01) -async def test_ezsp_init_zigbeed_timeout(conn_mock, reset_mock, version_mock): +async def test_ezsp_init_zigbeed_timeout( + conn_mock, xncp_mock, reset_mock, version_mock +): """Test initialize method with a received startup reset frame.""" zigpy_config = config.CONFIG_SCHEMA( {