"""XBee API implementation.""" import asyncio import binascii import functools import logging from typing import Any, Dict, Optional from zigpy.exceptions import APIException, DeliveryError import zigpy.types as t from zigpy_xbee.exceptions import ( ATCommandError, ATCommandException, InvalidCommand, InvalidParameter, TransmissionFailure, ) from . import types as xbee_t, uart LOGGER = logging.getLogger(__name__) AT_COMMAND_TIMEOUT = 3 REMOTE_AT_COMMAND_TIMEOUT = 30 # https://www.digi.com/resources/documentation/digidocs/PDFs/90000976.pdf COMMAND_REQUESTS = { "at": (0x08, (xbee_t.FrameId, xbee_t.ATCommand, xbee_t.Bytes), 0x88), "queued_at": (0x09, (xbee_t.FrameId, xbee_t.ATCommand, xbee_t.Bytes), 0x88), "remote_at": ( 0x17, ( xbee_t.FrameId, xbee_t.EUI64, xbee_t.NWK, t.uint8_t, xbee_t.ATCommand, xbee_t.Bytes, ), 0x97, ), "tx": (0x10, (), None), "tx_explicit": ( 0x11, ( xbee_t.FrameId, xbee_t.EUI64, xbee_t.NWK, t.uint8_t, t.uint8_t, t.uint16_t_be, t.uint16_t_be, t.uint8_t, t.uint8_t, xbee_t.Bytes, ), 0x8B, ), "create_source_route": ( 0x21, (xbee_t.FrameId, xbee_t.EUI64, xbee_t.NWK, t.uint8_t, xbee_t.Relays), None, ), "register_joining_device": ( 0x24, (xbee_t.FrameId, xbee_t.EUI64, t.uint16_t_be, t.uint8_t, xbee_t.Bytes), 0xA4, ), } COMMAND_RESPONSES = { "at_response": ( 0x88, (xbee_t.FrameId, xbee_t.ATCommand, t.uint8_t, xbee_t.Bytes), None, ), "modem_status": (0x8A, (xbee_t.ModemStatus,), None), "tx_status": ( 0x8B, ( xbee_t.FrameId, xbee_t.NWK, t.uint8_t, xbee_t.TXStatus, xbee_t.DiscoveryStatus, ), None, ), "route_information": (0x8D, (), None), "rx": (0x90, (), None), "explicit_rx_indicator": ( 0x91, ( xbee_t.EUI64, xbee_t.NWK, t.uint8_t, t.uint8_t, t.uint16_t_be, t.uint16_t_be, t.uint8_t, xbee_t.Bytes, ), None, ), "rx_io_data_long_addr": (0x92, (), None), "remote_at_response": ( 0x97, ( xbee_t.FrameId, xbee_t.EUI64, xbee_t.NWK, xbee_t.ATCommand, t.uint8_t, xbee_t.Bytes, ), None, ), "extended_status": (0x98, (), None), "route_record_indicator": ( 0xA1, (xbee_t.EUI64, xbee_t.NWK, t.uint8_t, xbee_t.Relays), None, ), "many_to_one_rri": (0xA3, (xbee_t.EUI64, xbee_t.NWK, t.uint8_t), None), "registration_status": (0xA4, (xbee_t.FrameId, xbee_t.RegistrationStatus), None), "node_id_indicator": (0x95, (), None), } # https://www.digi.com/resources/documentation/digidocs/pdfs/90001539.pdf pg 175 AT_COMMANDS = { # Addressing commands "DH": t.uint32_t_be, "DL": t.uint32_t_be, "MY": t.uint16_t_be, "MP": t.uint16_t_be, "NC": t.uint32_t_be, # 0 - MAX_CHILDREN. "SH": t.uint32_t_be, "SL": t.uint32_t_be, "NI": t, # 20 byte printable ascii string "SE": t.uint8_t, "DE": t.uint8_t, "CI": t.uint16_t_be, "TO": t.uint8_t, "NP": t.uint16_t_be, "DD": t.uint32_t_be, "CR": t.uint8_t, # 0 - 0x3F # Networking commands "CH": t.uint8_t, # 0x0B - 0x1A "DA": t, # no param "ID": t.uint64_t_be, "OP": t.uint64_t_be, "NH": t.uint8_t, "BH": t.uint8_t, # 0 - 0x1E "OI": t.uint16_t_be, "NT": t.uint8_t, # 0x20 - 0xFF "NO": t.uint8_t, # bitfield, 0 - 3 "SC": t.uint16_t_be, # 1 - 0xFFFF "SD": t.uint8_t, # 0 - 7 "ZS": t.uint8_t, # 0 - 2 "NJ": t.uint8_t, "JV": t.Bool, "NW": t.uint16_t_be, # 0 - 0x64FF "JN": t.Bool, "AR": t.uint8_t, "DJ": t.Bool, # WTF, docs "II": t.uint16_t_be, # Security commands "EE": t.Bool, "EO": t.uint8_t, "NK": xbee_t.Bytes, # 128-bit value "KY": xbee_t.Bytes, # 128-bit value "KT": t.uint16_t_be, # 0x1E - 0xFFFF # RF interfacing commands "PL": t.uint8_t, # 0 - 4 (basically an Enum) "PM": t.Bool, "DB": t.uint8_t, "PP": t.uint8_t, # RO "AP": t.uint8_t, # 1-2 (an Enum) "AO": t.uint8_t, # 0 - 3 (an Enum) "BD": t.uint8_t, # 0 - 7 (an Enum) "NB": t.uint8_t, # 0 - 3 (an Enum) "SB": t.uint8_t, # 0 - 1 (an Enum) "RO": t.uint8_t, "D6": t.uint8_t, # 0 - 5 (an Enum) "D7": t.uint8_t, # 0 - 7 (an Enum) "P3": t.uint8_t, # 0 - 5 (an Enum) "P4": t.uint8_t, # 0 - 5 (an Enum) # MAC diagnostics commands "ED": xbee_t.Bytes, # 16-byte value # I/O commands "IR": t.uint16_t_be, "IC": t.uint16_t_be, "D0": t.uint8_t, # 0 - 5 (an Enum) "D1": t.uint8_t, # 0 - 5 (an Enum) "D2": t.uint8_t, # 0 - 5 (an Enum) "D3": t.uint8_t, # 0 - 5 (an Enum) "D4": t.uint8_t, # 0 - 5 (an Enum) "D5": t.uint8_t, # 0 - 5 (an Enum) "D8": t.uint8_t, # 0 - 5 (an Enum) "D9": t.uint8_t, # 0 - 5 (an Enum) "P0": t.uint8_t, # 0 - 5 (an Enum) "P1": t.uint8_t, # 0 - 5 (an Enum) "P2": t.uint8_t, # 0 - 5 (an Enum) "P5": t.uint8_t, # 0 - 5 (an Enum) "P6": t.uint8_t, # 0 - 5 (an Enum) "P7": t.uint8_t, # 0 - 5 (an Enum) "P8": t.uint8_t, # 0 - 5 (an Enum) "P9": t.uint8_t, # 0 - 5 (an Enum) "LT": t.uint8_t, "PR": t.uint16_t_be, "RP": t.uint8_t, "%V": t.uint16_t_be, # read only "V+": t.uint16_t_be, "TP": t.uint16_t_be, "M0": t.uint16_t_be, # 0 - 0x3FF "M1": t.uint16_t_be, # 0 - 0x3FF # Diagnostics commands "VR": t.uint16_t_be, "HV": t.uint16_t_be, "AI": t.uint8_t, # AT command options "CT": t.uint16_t_be, # 2 - 0x028F "CN": None, "GT": t.uint16_t_be, "CC": t.uint8_t, # Sleep commands "SM": t.uint8_t, "SN": t.uint16_t_be, "SP": t.uint16_t_be, "ST": t.uint16_t_be, "SO": t.uint8_t, "WH": t.uint16_t_be, "SI": None, "PO": t.uint16_t_be, # 0 - 0x3E8 # Execution commands "AC": None, "WR": None, "RE": None, "FR": None, "NR": t.Bool, "CB": t.uint8_t, "ND": t, # "optional 2-Byte NI value" "DN": xbee_t.Bytes, # "up to 20-Byte printable ASCII string" "IS": None, "1S": None, "AS": None, # Stuff I've guessed "CE": t.uint8_t, } BAUDRATE_TO_BD = { 1200: "ATBD0", 2400: "ATBD1", 4800: "ATBD2", 9600: "ATBD3", 19200: "ATBD4", 38400: "ATBD5", 57600: "ATBD6", 115200: "ATBD7", 230400: "ATBD8", } AT_COMMAND_RESULT = { 1: ATCommandError, 2: InvalidCommand, 3: InvalidParameter, 4: TransmissionFailure, } class XBee: """Class implementing XBee communication protocol.""" def __init__(self, device_config: Dict[str, Any]) -> None: """Initialize instance.""" self._config = device_config self._uart: Optional[uart.Gateway] = None self._seq: int = 1 self._commands_by_id = {v[0]: k for k, v in COMMAND_RESPONSES.items()} self._awaiting = {} self._app = None self._cmd_mode_future: Optional[asyncio.Future] = None self._reset: asyncio.Event = asyncio.Event() self._running: asyncio.Event = asyncio.Event() @property def reset_event(self): """Return reset event.""" return self._reset @property def coordinator_started_event(self): """Return coordinator started.""" return self._running @property def is_running(self): """Return true if coordinator is running.""" return self.coordinator_started_event.is_set() async def connect(self) -> None: """Connect to the device.""" assert self._uart is None self._uart = await uart.connect(self._config, self) try: try: # Ensure we have escaped commands await self._at_command("AP", 2) except asyncio.TimeoutError: if not await self.init_api_mode(): raise APIException("Failed to configure XBee for API mode") except Exception: await self.disconnect() raise def connection_lost(self, exc: Exception) -> None: """Lost serial connection.""" if self._app is not None: self._app.connection_lost(exc) async def disconnect(self): """Close the connection.""" if self._uart: await self._uart.disconnect() self._uart = None def _command(self, name, *args, mask_frame_id=False): """Send API frame to the device.""" LOGGER.debug("Command %s %s", name, args) if self._uart is None: raise APIException("API is not running") frame_id = 0 if mask_frame_id else self._seq data, needs_response = self._api_frame(name, frame_id, *args) self._uart.send(data) future = None if needs_response and frame_id: future = asyncio.Future() self._awaiting[frame_id] = (future,) self._seq = (self._seq % 255) + 1 return future async def _remote_at_command(self, ieee, nwk, options, name, *args): """Execute AT command on a different XBee module in the network.""" LOGGER.debug("Remote AT command: %s %s", name, args) data = t.serialize(args, (AT_COMMANDS[name],)) try: return await asyncio.wait_for( self._command( "remote_at", ieee, nwk, options, name.encode("ascii"), data ), timeout=REMOTE_AT_COMMAND_TIMEOUT, ) except asyncio.TimeoutError: LOGGER.warning("No response to %s command", name) raise async def _at_partial(self, cmd_type, name, *args): LOGGER.debug("%s command: %s %s", cmd_type, name, args) data = t.serialize(args, (AT_COMMANDS[name],)) try: return await asyncio.wait_for( self._command(cmd_type, name.encode("ascii"), data), timeout=AT_COMMAND_TIMEOUT, ) except asyncio.TimeoutError: LOGGER.warning("%s: No response to %s command", cmd_type, name) raise _at_command = functools.partialmethod(_at_partial, "at") _queued_at = functools.partialmethod(_at_partial, "queued_at") def _api_frame(self, name, *args): """Build API frame.""" c = COMMAND_REQUESTS[name] return (bytes([c[0]]) + t.serialize(args, c[1])), c[2] def frame_received(self, data): """Handle API frame from the device.""" command = self._commands_by_id[data[0]] LOGGER.debug("Frame received: %s", command) data, rest = t.deserialize(data[1:], COMMAND_RESPONSES[command][1]) try: getattr(self, f"_handle_{command}")(*data) except AttributeError: LOGGER.error("No '%s' handler. Data: %s", command, binascii.hexlify(data)) def _handle_at_response(self, frame_id, cmd, status, value): """Local AT command response.""" (fut,) = self._awaiting.pop(frame_id) if status: try: exception = AT_COMMAND_RESULT[status] except KeyError: exception = ATCommandException fut.set_exception(exception(f"AT Command response: {status}")) return response_type = AT_COMMANDS[cmd.decode("ascii")] if response_type is None or len(value) == 0: fut.set_result(None) return response, remains = response_type.deserialize(value) fut.set_result(response) def _handle_remote_at_response(self, frame_id, ieee, nwk, cmd, status, value): """Remote AT command response.""" LOGGER.debug( "Remote AT command response from: %s", (frame_id, ieee, nwk, cmd, status, value), ) return self._handle_at_response(frame_id, cmd, status, value) def _handle_many_to_one_rri(self, ieee, nwk, reserved): LOGGER.debug("_handle_many_to_one_rri: %s", (ieee, nwk, reserved)) def _handle_modem_status(self, status): LOGGER.debug("Handle modem status frame: %s", status) status = status if status == xbee_t.ModemStatus.COORDINATOR_STARTED: self.coordinator_started_event.set() elif status in ( xbee_t.ModemStatus.HARDWARE_RESET, xbee_t.ModemStatus.WATCHDOG_TIMER_RESET, ): self.reset_event.set() self.coordinator_started_event.clear() elif status == xbee_t.ModemStatus.DISASSOCIATED: self.coordinator_started_event.clear() if self._app: self._app.handle_modem_status(status) def _handle_explicit_rx_indicator( self, ieee, nwk, src_ep, dst_ep, cluster, profile, rx_opts, data ): LOGGER.debug( "_handle_explicit_rx: %s", (ieee, nwk, dst_ep, cluster, rx_opts, binascii.hexlify(data)), ) self._app.handle_rx(ieee, nwk, src_ep, dst_ep, cluster, profile, rx_opts, data) def _handle_route_record_indicator(self, ieee, src, rx_opts, hops): """Handle Route Record indicator from a device.""" LOGGER.debug("_handle_route_record_indicator: %s", (ieee, src, rx_opts, hops)) def _handle_tx_status(self, frame_id, nwk, tries, tx_status, dsc_status): LOGGER.debug( ( "tx_explicit to 0x%04x: %s after %i tries. Discovery Status: %s," " Frame #%i" ), nwk, tx_status, tries, dsc_status, frame_id, ) try: (fut,) = self._awaiting.pop(frame_id) except KeyError: LOGGER.debug("unexpected tx_status report received") return try: if tx_status in ( xbee_t.TXStatus.BROADCAST_APS_TX_ATTEMPT, xbee_t.TXStatus.SELF_ADDRESSED, xbee_t.TXStatus.SUCCESS, ): fut.set_result(tx_status) else: fut.set_exception(DeliveryError(f"{tx_status}")) except asyncio.InvalidStateError as ex: LOGGER.debug("duplicate tx_status for %s nwk? State: %s", nwk, ex) def _handle_registration_status(self, frame_id, status): (fut,) = self._awaiting.pop(frame_id) if status: fut.set_exception(RuntimeError(f"Registration Status: {status.name}")) return LOGGER.debug(f"Registration Status: {status.name}") fut.set_result(status) def set_application(self, app): """Set reference to ControllerApplication.""" self._app = app def handle_command_mode_rsp(self, data): """Handle AT command response in command mode.""" fut = self._cmd_mode_future if fut is None or fut.done(): return if "OK" in data: fut.set_result(True) elif "ERROR" in data: fut.set_result(False) else: fut.set_result(data) async def command_mode_at_cmd(self, command): """Send AT command in command mode.""" self._cmd_mode_future = asyncio.Future() self._uart.command_mode_send(command.encode("ascii")) try: res = await asyncio.wait_for(self._cmd_mode_future, timeout=2) return res except asyncio.TimeoutError: LOGGER.debug("Command mode no response to AT '%s' command", command) return None async def enter_at_command_mode(self): """Enter command mode.""" await asyncio.sleep(1.2) # keep UART quiet for 1s before escaping return await self.command_mode_at_cmd("+++") async def api_mode_at_commands(self, baudrate): """Configure API and exit AT command mode.""" cmds = ["ATAP2", "ATWR", "ATCN"] bd = BAUDRATE_TO_BD.get(baudrate) if bd: cmds.insert(0, bd) for cmd in cmds: if not await self.command_mode_at_cmd(cmd + "\r"): LOGGER.debug("No response to %s cmd", cmd) return None LOGGER.debug("Successfully sent %s cmd", cmd) self._uart.reset_command_mode() return True async def init_api_mode(self): """Configure API mode on XBee.""" current_baudrate = self._uart.baudrate if await self.enter_at_command_mode(): LOGGER.debug("Entered AT Command mode at %dbps.", self._uart.baudrate) return await self.api_mode_at_commands(current_baudrate) for baudrate in sorted(BAUDRATE_TO_BD.keys()): LOGGER.debug( "Failed to enter AT command mode at %dbps, trying %d next", self._uart.baudrate, baudrate, ) self._uart.baudrate = baudrate if await self.enter_at_command_mode(): LOGGER.debug("Entered AT Command mode at %dbps.", self._uart.baudrate) res = await self.api_mode_at_commands(current_baudrate) self._uart.baudrate = current_baudrate return res LOGGER.debug( "Couldn't enter AT command mode at any known baudrate." "Configure XBee manually for escaped API mode ATAP2" ) return False def __getattr__(self, item): """Handle supported command requests.""" if item in COMMAND_REQUESTS: return functools.partial(self._command, item) raise AttributeError(f"Unknown command {item}")