"""ControllerApplication for deCONZ protocol based adapters.""" from __future__ import annotations import asyncio import importlib.metadata import logging import sys from typing import Any if sys.version_info[:2] < (3, 11): from async_timeout import timeout as asyncio_timeout # pragma: no cover else: from asyncio import timeout as asyncio_timeout # pragma: no cover import zigpy.application import zigpy.config import zigpy.device import zigpy.endpoint import zigpy.exceptions from zigpy.exceptions import FormationFailure, NetworkNotFormed import zigpy.state import zigpy.types import zigpy.util import zigpy.zdo.types as zdo_t import zigpy_deconz from zigpy_deconz import types as t from zigpy_deconz.api import ( Deconz, FirmwarePlatform, IndexedEndpoint, IndexedKey, LinkKey, NetworkParameter, NetworkState, SecurityMode, Status, TXStatus, ) from zigpy_deconz.config import CONFIG_SCHEMA import zigpy_deconz.exception from zigpy_deconz.utils import is_usb_serial_port LIB_VERSION = importlib.metadata.version("zigpy-deconz") LOGGER = logging.getLogger(__name__) CHANGE_NETWORK_POLL_TIME = 1 CHANGE_NETWORK_STATE_DELAY = 2 DELAY_NEIGHBOUR_SCAN_S = 1500 SEND_CONFIRM_TIMEOUT = 60 PROTO_VER_MANUAL_SOURCE_ROUTE = 0x010C PROTO_VER_WATCHDOG = 0x0108 PROTO_VER_NEIGBOURS = 0x0107 CONBEE_III_ENERGY_SCAN_ATTEMPTS = 5 class ControllerApplication(zigpy.application.ControllerApplication): SCHEMA = CONFIG_SCHEMA _probe_config_variants = [ {zigpy.config.CONF_DEVICE_BAUDRATE: 38400}, {zigpy.config.CONF_DEVICE_BAUDRATE: 115200}, ] _watchdog_period = 30 def __init__(self, config: dict[str, Any]): """Initialize instance.""" super().__init__(config=config) self._api = None self._pending_requests = {} self._delayed_neighbor_scan_task = None self._reconnect_task = None self._written_endpoints = set() async def _watchdog_feed(self): if self._api.protocol_version >= PROTO_VER_WATCHDOG and not ( self._api.firmware_version.platform == FirmwarePlatform.Conbee_III and self._api.firmware_version <= 0x26450900 ): await self._api.write_parameter( NetworkParameter.watchdog_ttl, int(2 * self._watchdog_period) ) else: await self._api.get_device_state() async def connect(self): api = Deconz(self, self._config[zigpy.config.CONF_DEVICE]) try: await api.connect() except Exception: await api.disconnect() raise self._api = api self._written_endpoints.clear() async def disconnect(self): if self._delayed_neighbor_scan_task is not None: self._delayed_neighbor_scan_task.cancel() self._delayed_neighbor_scan_task = None if self._api is not None: await self._api.disconnect() self._api = None async def permit_with_link_key(self, node: t.EUI64, link_key: t.KeyData, time_s=60): await self._api.write_parameter( NetworkParameter.link_key, LinkKey(ieee=node, key=link_key), ) await self.permit(time_s) async def start_network(self): await self.register_endpoints() await self.load_network_info(load_devices=False) await self._change_network_state(NetworkState.CONNECTED) coordinator = await DeconzDevice.new( self, self.state.node_info.ieee, self.state.node_info.nwk, self.state.node_info.model, ) self.devices[self.state.node_info.ieee] = coordinator if self._api.protocol_version >= PROTO_VER_NEIGBOURS and not ( self._api.firmware_version.platform == FirmwarePlatform.Conbee_III and self._api.firmware_version < 0x264D0900 ): await self.restore_neighbours() self._delayed_neighbor_scan_task = asyncio.create_task( self._delayed_neighbour_scan() ) async def _change_network_state( self, target_state: NetworkState, *, timeout: int = 10 * CHANGE_NETWORK_POLL_TIME, ): async def change_loop(): while True: try: device_state = await self._api.get_device_state() except asyncio.TimeoutError: # 0x264B0900 and earlier can reset during device state changes # requiring a firmware reset, causing state polling to fail LOGGER.debug("Failed to poll device state") else: if NetworkState(device_state.network_state) == target_state: break await asyncio.sleep(CHANGE_NETWORK_POLL_TIME) await self._api.change_network_state(target_state) try: async with asyncio_timeout(timeout): await change_loop() except asyncio.TimeoutError: if target_state != NetworkState.CONNECTED: raise raise FormationFailure( "Network formation refused: there is likely too much RF interference." " Make sure your coordinator is on a USB 2.0 extension cable and" " away from any sources of interference, like USB 3.0 ports, SSDs," " 2.4GHz routers, motherboards, etc." ) async def reset_network_info(self): # TODO: There does not appear to be a way to factory reset a Conbee await self.write_network_info( network_info=zigpy.state.NetworkInfo( pan_id=0xFFFF, extended_pan_id=zigpy.types.EUI64.convert("FF:FF:FF:FF:FF:FF:FF:FF"), channel=None, channel_mask=zigpy.types.Channels(0), nwk_update_id=0, network_key=zigpy.state.Key( key=zigpy.types.KeyData.convert( "FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF:FF" ) ), tc_link_key=zigpy.state.Key( key=zigpy.types.KeyData.convert(b"ZigBeeAlliance09".hex()) ), security_level=0x05, ), node_info=zigpy.state.NodeInfo( logical_type=zdo_t.LogicalType.Coordinator, ieee=zigpy.types.EUI64.UNKNOWN, nwk=0xFFFF, ), ) async def write_network_info(self, *, network_info, node_info): try: await self._api.write_parameter( NetworkParameter.nwk_frame_counter, network_info.network_key.tx_counter ) except zigpy_deconz.exception.CommandError as ex: assert ex.status == Status.UNSUPPORTED # If we are resetting the adapter or forming a brand new network, we can # skip this check if not ( network_info.stack_specific.get("form_quickly", False) or network_info.network_key.tx_counter == 0 ): fw_version = f"{int(self._api.firmware_version):#010x}" raise zigpy.exceptions.CannotWriteNetworkSettings( f"Please upgrade your adapter firmware. Firmware version" f" {fw_version} does not support writing the network key frame" f" counter, which is required for migration to succeed." ) if node_info.logical_type == zdo_t.LogicalType.Coordinator: await self._api.write_parameter( NetworkParameter.aps_designed_coordinator, 1 ) else: await self._api.write_parameter( NetworkParameter.aps_designed_coordinator, 0 ) await self._api.write_parameter(NetworkParameter.nwk_address, node_info.nwk) if node_info.ieee != zigpy.types.EUI64.UNKNOWN: # TODO: is there a way to revert it back to the hardware default? Or is this # information lost when the parameter is overwritten? await self._api.write_parameter( NetworkParameter.mac_address, node_info.ieee ) node_ieee = node_info.ieee else: ieee = await self._api.read_parameter(NetworkParameter.mac_address) node_ieee = zigpy.types.EUI64(ieee) # There is no way to specify both a mask and the logical channel if network_info.channel is not None: channel_mask = zigpy.types.Channels.from_channel_list( [network_info.channel] ) if network_info.channel_mask and channel_mask != network_info.channel_mask: LOGGER.warning( "Channel mask %s will be replaced with current logical channel %s", network_info.channel_mask, channel_mask, ) else: channel_mask = network_info.channel_mask await self._api.write_parameter(NetworkParameter.channel_mask, channel_mask) await self._api.write_parameter(NetworkParameter.use_predefined_nwk_panid, True) await self._api.write_parameter(NetworkParameter.nwk_panid, network_info.pan_id) await self._api.write_parameter( NetworkParameter.aps_extended_panid, network_info.extended_pan_id ) await self._api.write_parameter( NetworkParameter.nwk_update_id, network_info.nwk_update_id ) await self._api.write_parameter( NetworkParameter.network_key, IndexedKey(index=0, key=network_info.network_key.key), ) if network_info.network_key.seq != 0: LOGGER.warning( "Non-zero network key sequence number is not supported: %s", network_info.network_key.seq, ) tc_link_key_partner_ieee = network_info.tc_link_key.partner_ieee if tc_link_key_partner_ieee == zigpy.types.EUI64.UNKNOWN: tc_link_key_partner_ieee = node_ieee await self._api.write_parameter( NetworkParameter.trust_center_address, tc_link_key_partner_ieee, ) await self._api.write_parameter( NetworkParameter.link_key, LinkKey( ieee=tc_link_key_partner_ieee, key=network_info.tc_link_key.key, ), ) if self._api.firmware_version.platform != FirmwarePlatform.Conbee_III: if network_info.security_level == 0x00: await self._api.write_parameter( NetworkParameter.security_mode, SecurityMode.NO_SECURITY ) else: await self._api.write_parameter( NetworkParameter.security_mode, SecurityMode.ONLY_TCLK ) # Note: Changed network configuration parameters become only affective after # sending a Leave Network Request followed by a Create or Join Network Request await self._change_network_state(NetworkState.OFFLINE) if ( network_info.pan_id == 0xFFFF or network_info.channel_mask == zigpy.types.Channels(0) ): # Network is being reset, it will never enter the CONNECTED state return await asyncio.sleep(CHANGE_NETWORK_STATE_DELAY) await self._change_network_state(NetworkState.CONNECTED) async def load_network_info(self, *, load_devices=False): network_info = self.state.network_info node_info = self.state.node_info ieee = await self._api.read_parameter(NetworkParameter.mac_address) node_info.ieee = zigpy.types.EUI64(ieee) designed_coord = await self._api.read_parameter( NetworkParameter.aps_designed_coordinator ) if designed_coord == 0x01: node_info.logical_type = zdo_t.LogicalType.Coordinator else: node_info.logical_type = zdo_t.LogicalType.Router node_info.nwk = await self._api.read_parameter(NetworkParameter.nwk_address) node_info.manufacturer = "dresden elektronik" is_usb = await asyncio.get_running_loop().run_in_executor( None, is_usb_serial_port, self._config[zigpy.config.CONF_DEVICE][zigpy.config.CONF_DEVICE_PATH], ) if is_usb: node_info.model = "Conbee" else: node_info.model = "Raspbee" node_info.model += { FirmwarePlatform.Conbee: "", FirmwarePlatform.Conbee_II: " II", FirmwarePlatform.Conbee_III: " III", }[self._api.firmware_version.platform] node_info.version = f"{int(self._api.firmware_version):#010x}" network_info.source = f"zigpy-deconz@{LIB_VERSION}" network_info.metadata = { "deconz": { "version": node_info.version, } } network_info.pan_id = await self._api.read_parameter(NetworkParameter.nwk_panid) network_info.extended_pan_id = await self._api.read_parameter( NetworkParameter.aps_extended_panid ) if network_info.extended_pan_id == zigpy.types.ExtendedPanId.convert( "00:00:00:00:00:00:00:00" ): network_info.extended_pan_id = await self._api.read_parameter( NetworkParameter.nwk_extended_panid ) network_info.channel = await self._api.read_parameter( NetworkParameter.current_channel ) network_info.channel_mask = await self._api.read_parameter( NetworkParameter.channel_mask ) network_info.nwk_update_id = await self._api.read_parameter( NetworkParameter.nwk_update_id ) if ( node_info.nwk == 0xFFFF or network_info.pan_id == 0xFFFF or ( network_info.extended_pan_id == zigpy.types.ExtendedPanId.convert("FF:FF:FF:FF:FF:FF:FF:FF") ) or network_info.channel == 0 ): raise NetworkNotFormed("Network is not formed") indexed_key = await self._api.read_parameter(NetworkParameter.network_key, 0) network_info.network_key = zigpy.state.Key() network_info.network_key.key = indexed_key.key try: network_info.network_key.tx_counter = await self._api.read_parameter( NetworkParameter.nwk_frame_counter ) except zigpy_deconz.exception.CommandError as ex: assert ex.status == Status.UNSUPPORTED LOGGER.debug( "Conbee firmware is too old and does not support reading the frame" " counter, picking a large value instead" ) network_info.network_key.tx_counter = 2**30 network_info.tc_link_key = zigpy.state.Key() network_info.tc_link_key.partner_ieee = await self._api.read_parameter( NetworkParameter.trust_center_address ) link_key = await self._api.read_parameter( NetworkParameter.link_key, network_info.tc_link_key.partner_ieee, ) network_info.tc_link_key.key = link_key.key security_mode = await self._api.read_parameter(NetworkParameter.security_mode) if security_mode == SecurityMode.NO_SECURITY: network_info.security_level = 0x00 elif security_mode == SecurityMode.ONLY_TCLK: network_info.security_level = 0x05 else: LOGGER.warning("Unsupported security mode %r", security_mode) network_info.security_level = 0x05 async def force_remove(self, dev): """Forcibly remove device from NCP.""" async def energy_scan( self, channels: t.Channels.ALL_CHANNELS, duration_exp: int, count: int ) -> dict[int, float]: if self._api.firmware_version.platform in ( FirmwarePlatform.Conbee, FirmwarePlatform.Conbee_II, ): results = await super().energy_scan( channels=channels, duration_exp=duration_exp, count=count ) # The Conbee I/II seems to max out at an LQI of 85, which is exactly 255/3 return {c: v * 3 for c, v in results.items()} for i in range(CONBEE_III_ENERGY_SCAN_ATTEMPTS): # The Conbee III energy scan inherits the EmberZNet ZDO bug try: rsp = await self._device.zdo.Mgmt_NWK_Update_req( zigpy.zdo.types.NwkUpdate( ScanChannels=channels, ScanDuration=duration_exp, ScanCount=count, ) ) break except (asyncio.TimeoutError, zigpy.exceptions.DeliveryError): if i == CONBEE_III_ENERGY_SCAN_ATTEMPTS - 1: raise continue _, scanned_channels, _, _, energy_values = rsp return dict(zip(scanned_channels, energy_values)) async def _move_network_to_channel( self, new_channel: int, new_nwk_update_id: int ) -> None: """Move device to a new channel.""" channel_mask = zigpy.types.Channels.from_channel_list([new_channel]) await self._api.write_parameter(NetworkParameter.channel_mask, channel_mask) await self._api.write_parameter( NetworkParameter.nwk_update_id, new_nwk_update_id ) await self._change_network_state(NetworkState.OFFLINE) await asyncio.sleep(CHANGE_NETWORK_STATE_DELAY) await self._change_network_state(NetworkState.CONNECTED) async def add_endpoint(self, descriptor: zdo_t.SimpleDescriptor) -> None: """Register an endpoint on the device, replacing any with conflicting IDs.""" endpoints = {} # Read and count the current endpoints. Some firmwares have three, others four. for index in range(255 + 1): try: current_descriptor = await self._api.read_parameter( NetworkParameter.configure_endpoint, index ) except zigpy_deconz.exception.CommandError as ex: assert ex.status == Status.UNSUPPORTED break else: endpoints[index] = current_descriptor.descriptor LOGGER.debug("Got endpoint slots: %r", endpoints) # Don't write endpoints unnecessarily if descriptor in endpoints.values(): LOGGER.debug("Endpoint already registered, skipping") # Pretend we wrote it self._written_endpoints.add(list(endpoints.values()).index(descriptor)) return # Keep track of the best endpoint descriptor to replace target_index = None for index, current_descriptor in endpoints.items(): # Ignore ones we've already written if index in self._written_endpoints: continue target_index = index if current_descriptor.endpoint == descriptor.endpoint: # Prefer to replace the endpoint with the same ID break if target_index is None: raise ValueError(f"No available endpoint slots exist: {endpoints!r}") LOGGER.debug("Writing %s to slot %r", descriptor, target_index) await self._api.write_parameter( NetworkParameter.configure_endpoint, IndexedEndpoint(index=target_index, descriptor=descriptor), ) async def send_packet(self, packet): LOGGER.debug("Sending packet: %r", packet) tx_options = t.DeconzTransmitOptions.USE_NWK_KEY_SECURITY if ( zigpy.types.TransmitOptions.ACK in packet.tx_options and packet.dst.addr_mode in (zigpy.types.AddrMode.NWK, zigpy.types.AddrMode.IEEE) ): tx_options |= t.DeconzTransmitOptions.USE_APS_ACKS async with self._limit_concurrency(priority=packet.priority): req_id = self.get_sequence() if req_id in self._pending_requests: raise zigpy.exceptions.DeliveryError( f"Request with id {req_id} is already pending, cannot send" ) future = self._pending_requests[req_id] = asyncio.Future() try: try: await self._api.aps_data_request( req_id=req_id, dst_addr_ep=t.DeconzAddressEndpoint.from_zigpy_type( packet.dst, packet.dst_ep or 0 ), profile=packet.profile_id, cluster=packet.cluster_id, src_ep=min(1, packet.src_ep), aps_payload=packet.data.serialize(), tx_options=tx_options, relays=packet.source_route, radius=packet.radius or 0, ) except zigpy_deconz.exception.CommandError as ex: raise zigpy.exceptions.DeliveryError( f"Failed to enqueue packet: {ex!r}", ex.status ) async with asyncio_timeout(SEND_CONFIRM_TIMEOUT): status = await future if status != TXStatus.SUCCESS: raise zigpy.exceptions.DeliveryError( f"Failed to deliver packet: {status!r}", status ) finally: del self._pending_requests[req_id] async def permit_ncp(self, time_s=60): assert 0 <= time_s <= 254 await self._api.write_parameter(NetworkParameter.permit_join, time_s) def handle_tx_confirm(self, req_id, status): try: future = self._pending_requests[req_id] except KeyError: LOGGER.warning( "Unexpected transmit confirm for request id %s, Status: %s", req_id, status, ) else: try: future.set_result(status) except asyncio.InvalidStateError as exc: LOGGER.debug( "Invalid state on future - probably duplicate response: %s", exc ) async def restore_neighbours(self) -> None: """Restore children.""" coord = self.get_device(ieee=self.state.node_info.ieee) for neighbor in self.topology.neighbors[coord.ieee]: try: device = self.get_device(ieee=neighbor.ieee) except KeyError: continue descr = device.node_desc LOGGER.debug( "device: 0x%04x - %s %s, FFD=%s, Rx_on_when_idle=%s", device.nwk, device.manufacturer, device.model, descr.is_full_function_device if descr is not None else None, descr.is_receiver_on_when_idle if descr is not None else None, ) if ( descr is None or descr.is_full_function_device or descr.is_receiver_on_when_idle ): continue LOGGER.debug("Restoring %s as direct child", device) try: await self._api.add_neighbour( nwk=device.nwk, ieee=device.ieee, mac_capability_flags=descr.mac_capability_flags, ) except zigpy_deconz.exception.CommandError as ex: assert ex.status == Status.FAILURE LOGGER.debug("Failed to add device to neighbor table: %s", ex) async def _delayed_neighbour_scan(self) -> None: """Scan coordinator's neighbours.""" await asyncio.sleep(DELAY_NEIGHBOUR_SCAN_S) coord = self.get_device(ieee=self.state.node_info.ieee) await self.topology.scan(devices=[coord]) class DeconzDevice(zigpy.device.Device): """Zigpy Device representing Coordinator.""" def __init__(self, model: str, *args, **kwargs): """Initialize instance.""" super().__init__(*args, **kwargs) self._model = model async def add_to_group(self, grp_id: int, name: str = None) -> None: group = self.application.groups.add_group(grp_id, name) for epid in self.endpoints: if not epid: continue # skip ZDO group.add_member(self.endpoints[epid]) return [0] async def remove_from_group(self, grp_id: int) -> None: for epid in self.endpoints: if not epid: continue # skip ZDO self.application.groups[grp_id].remove_member(self.endpoints[epid]) return [0] @property def manufacturer(self): return "dresden elektronik" @property def model(self): return self._model @classmethod async def new(cls, application, ieee, nwk, model: str): """Create or replace zigpy device.""" dev = cls(model, application, ieee, nwk) if ieee in application.devices: from_dev = application.get_device(ieee=ieee) dev.status = from_dev.status dev.node_desc = from_dev.node_desc for ep_id, from_ep in from_dev.endpoints.items(): if not ep_id: continue # Skip ZDO ep = dev.add_endpoint(ep_id) ep.profile_id = from_ep.profile_id ep.device_type = from_ep.device_type ep.status = from_ep.status ep.in_clusters = from_ep.in_clusters ep.out_clusters = from_ep.out_clusters else: application.devices[ieee] = dev await dev.initialize() return dev