diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7524317..03ab0c2 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -3,7 +3,7 @@ repos: rev: v3.15.0 hooks: - id: pyupgrade - args: [--py38-plus] + args: [--py311-plus] - repo: https://github.com/PyCQA/autoflake rev: v2.2.1 diff --git a/pyproject.toml b/pyproject.toml index e76e655..752fbd5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,9 @@ authors = [ ] readme = "README.md" license = {text = "GPL-3.0"} -requires-python = ">=3.8" +requires-python = ">=3.11" dependencies = [ - "zigpy>=0.70.0", + "zigpy>=2.0.0", ] [tool.setuptools.packages.find] @@ -23,7 +23,6 @@ exclude = ["tests", "tests.*"] [project.optional-dependencies] testing = [ "pytest>=7.1.2", - "asynctest>=0.13.0", "pytest-asyncio>=0.19.0", ] diff --git a/requirements_test.txt b/requirements_test.txt index 96ab54b..a664180 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,6 +1,5 @@ # Test dependencies. -asynctest isort black flake8 @@ -15,6 +14,6 @@ pytest-sugar pytest-timeout pytest-asyncio>=0.17 pytest>=7.1.3 -zigpy>=0.56.0 +zigpy>=2.0.0 ruff>=0.0.291 Flake8-pyproject diff --git a/tests/test_application.py b/tests/test_application.py index e4c363f..26be1a8 100644 --- a/tests/test_application.py +++ b/tests/test_application.py @@ -347,7 +347,7 @@ async def _test_start_network( def _at_command_mock(cmd, *args): nonlocal ai_tries if not api_mode: - raise asyncio.TimeoutError + raise TimeoutError if cmd == "CE" and legacy_module: raise InvalidCommand diff --git a/tests/test_uart.py b/tests/test_uart.py index fa4739d..f844cac 100644 --- a/tests/test_uart.py +++ b/tests/test_uart.py @@ -4,8 +4,8 @@ from unittest import mock import pytest -import serial_asyncio_fast import zigpy.config +import zigpy.serial from zigpy_xbee import uart @@ -22,7 +22,6 @@ def gw(): """Gateway fixture.""" gw = uart.Gateway(mock.MagicMock()) gw._transport = mock.MagicMock() - gw._transport.serial.BAUDRATES = serial_asyncio_fast.serial.Serial.BAUDRATES return gw @@ -48,7 +47,7 @@ async def mock_conn(loop, protocol_factory, **kwargs): loop.call_soon(protocol.connection_made, None) return None, protocol - monkeypatch.setattr(serial_asyncio_fast, "create_serial_connection", mock_conn) + monkeypatch.setattr(zigpy.serial, "create_serial_connection", mock_conn) await uart.connect(DEVICE_CONFIG, api) diff --git a/tox.ini b/tox.ini deleted file mode 100644 index 5ee39c4..0000000 --- a/tox.ini +++ /dev/null @@ -1,36 +0,0 @@ -# Tox (http://tox.testrun.org/) is a tool for running tests -# in multiple virtualenvs. This configuration file will run the -# test suite on all supported python versions. To use it, "pip install tox" -# and then run "tox" from this directory. - -[tox] -envlist = py38, py39, py310, py311, py312, lint, black -skip_missing_interpreters = True - -[testenv] -setenv = PYTHONPATH = {toxinidir} -install_command = pip install {opts} {packages} -commands = py.test --cov --cov-report= -deps = - asynctest - pytest - pytest-cov - pytest-asyncio - -[testenv:lint] -basepython = python3 -deps = - flake8==6.1.0 - isort==5.12.0 - Flake8-pyproject==1.2.3 -commands = - flake8 - isort --check {toxinidir}/zigpy_xbee {toxinidir}/tests {toxinidir}/setup.py - -[testenv:black] -deps=black -setenv = - LC_ALL=C.UTF-8 - LANG=C.UTF-8 -commands= - black --check --fast {toxinidir}/zigpy_xbee {toxinidir}/tests {toxinidir}/setup.py diff --git a/zigpy_xbee/api.py b/zigpy_xbee/api.py index 55faeae..e2f881d 100644 --- a/zigpy_xbee/api.py +++ b/zigpy_xbee/api.py @@ -4,7 +4,7 @@ import binascii import functools import logging -from typing import Any, Dict, Optional +from typing import Any from zigpy.exceptions import APIException, DeliveryError import zigpy.types as t @@ -274,15 +274,15 @@ class XBee: """Class implementing XBee communication protocol.""" - def __init__(self, device_config: Dict[str, Any]) -> None: + def __init__(self, device_config: dict[str, Any]) -> None: """Initialize instance.""" self._config = device_config - self._uart: Optional[uart.Gateway] = None + self._uart: uart.Gateway | None = 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._cmd_mode_future: asyncio.Future | None = None self._reset: asyncio.Event = asyncio.Event() self._running: asyncio.Event = asyncio.Event() @@ -310,7 +310,7 @@ async def connect(self) -> None: try: # Ensure we have escaped commands await self._at_command("AP", 2) - except asyncio.TimeoutError: + except TimeoutError: if not await self.init_api_mode(): raise APIException("Failed to configure XBee for API mode") except Exception: @@ -354,7 +354,7 @@ async def _remote_at_command(self, ieee, nwk, options, name, *args): ), timeout=REMOTE_AT_COMMAND_TIMEOUT, ) - except asyncio.TimeoutError: + except TimeoutError: LOGGER.warning("No response to %s command", name) raise @@ -366,7 +366,7 @@ async def _at_partial(self, cmd_type, name, *args): self._command(cmd_type, name.encode("ascii"), data), timeout=AT_COMMAND_TIMEOUT, ) - except asyncio.TimeoutError: + except TimeoutError: LOGGER.warning("%s: No response to %s command", cmd_type, name) raise @@ -512,7 +512,7 @@ async def command_mode_at_cmd(self, command): try: res = await asyncio.wait_for(self._cmd_mode_future, timeout=2) return res - except asyncio.TimeoutError: + except TimeoutError: LOGGER.debug("Command mode no response to AT '%s' command", command) return None diff --git a/zigpy_xbee/uart.py b/zigpy_xbee/uart.py index 61a3dcf..98457eb 100644 --- a/zigpy_xbee/uart.py +++ b/zigpy_xbee/uart.py @@ -2,7 +2,7 @@ import asyncio import logging -from typing import Any, Dict +from typing import Any import zigpy.config import zigpy.serial @@ -21,6 +21,42 @@ class Gateway(zigpy.serial.SerialProtocol): RESERVED = START + ESCAPE + XON + XOFF THIS_ONE = True + # Standard baudrates, previously sourced from the underlying serial library. + # zigpy now uses `serialx`, which does not expose a `BAUDRATES` attribute, so + # the list is kept here to avoid depending on the serial backend. + BAUDRATES = ( + 50, + 75, + 110, + 134, + 150, + 200, + 300, + 600, + 1200, + 1800, + 2400, + 4800, + 9600, + 19200, + 38400, + 57600, + 115200, + 230400, + 460800, + 500000, + 576000, + 921600, + 1000000, + 1152000, + 1500000, + 2000000, + 2500000, + 3000000, + 3500000, + 4000000, + ) + def __init__(self, api): """Initialize instance.""" super().__init__() @@ -44,12 +80,10 @@ def baudrate(self): @baudrate.setter def baudrate(self, baudrate): """Set baudrate.""" - if baudrate in self._transport.serial.BAUDRATES: + if baudrate in self.BAUDRATES: self._transport.serial.baudrate = baudrate else: - raise ValueError( - f"baudrate must be one of {self._transport.serial.BAUDRATES}" - ) + raise ValueError(f"baudrate must be one of {self.BAUDRATES}") def connection_lost(self, exc) -> None: """Port was closed expectedly or unexpectedly.""" @@ -148,7 +182,7 @@ def _checksum(self, data): return 0xFF - (sum(data) % 0x100) -async def connect(device_config: Dict[str, Any], api) -> Gateway: +async def connect(device_config: dict[str, Any], api) -> Gateway: """Connect to the device.""" transport, protocol = await zigpy.serial.create_serial_connection( loop=asyncio.get_running_loop(), diff --git a/zigpy_xbee/zigbee/application.py b/zigpy_xbee/zigbee/application.py index aedb799..b9d0120 100644 --- a/zigpy_xbee/zigbee/application.py +++ b/zigpy_xbee/zigbee/application.py @@ -3,6 +3,7 @@ from __future__ import annotations import asyncio +from datetime import datetime, timezone import logging import math import statistics @@ -12,8 +13,8 @@ import zigpy.config from zigpy.config import CONF_DEVICE import zigpy.device +import zigpy.endpoint import zigpy.exceptions -import zigpy.quirks import zigpy.state import zigpy.types import zigpy.util @@ -237,13 +238,14 @@ async def force_remove(self, dev): async def add_endpoint(self, descriptor: zdo_t.SimpleDescriptor) -> None: """Register a new endpoint on the device.""" - self._device.replacement["endpoints"][descriptor.endpoint] = { - "device_type": descriptor.device_type, - "profile_id": descriptor.profile, - "input_clusters": descriptor.input_clusters, - "output_clusters": descriptor.output_clusters, - } - self._device.add_endpoint(descriptor.endpoint) + ep = self._device.add_endpoint(descriptor.endpoint) + ep.status = zigpy.endpoint.Status.ZDO_INIT + ep.profile_id = descriptor.profile + ep.device_type = descriptor.device_type + for cluster_id in descriptor.input_clusters: + ep.add_input_cluster(cluster_id) + for cluster_id in descriptor.output_clusters: + ep.add_output_cluster(cluster_id) async def _get_association_state(self): """Wait for Zigbee to start.""" @@ -308,7 +310,7 @@ async def send_packet(self, packet: zigpy.types.ZigbeePacket) -> None: try: v = await asyncio.wait_for(send_req, timeout=TIMEOUT_TX_STATUS) - except asyncio.TimeoutError: + except TimeoutError: raise zigpy.exceptions.DeliveryError( "Timeout waiting for ACK", status=TXStatus.NETWORK_ACK_FAILURE ) @@ -370,7 +372,7 @@ def handle_rx( LOGGER.info("handle_rx self addressed") try: - self._device.update_last_seen() + self._device.last_seen = datetime.now(timezone.utc) except KeyError: pass @@ -427,33 +429,54 @@ async def _routes_updated( break -class XBeeCoordinator(zigpy.quirks.CustomDevice): - """Zigpy Device representing Coordinator.""" +class XBeeGroup(Groups): + """XBeeGroup cluster. - class XBeeGroup(zigpy.quirks.CustomCluster, Groups): - """XBeeGroup custom cluster.""" + Subclasses the standard ``Groups`` cluster but is kept out of the global + cluster registry, as it is only used on the XBee coordinator device. + """ - cluster_id = 0x0006 + _skip_registry = True + cluster_id = 0x0006 - class XBeeGroupResponse(zigpy.quirks.CustomCluster, Groups): - """XBeeGroupResponse custom cluster.""" - cluster_id = 0x8006 - ep_attribute = "xbee_groups_response" +class XBeeGroupResponse(Groups): + """XBeeGroupResponse cluster. - client_commands = { - **Groups.client_commands, - 0x04: foundation.ZCLCommandDef( - "remove_all_response", - {"status": foundation.Status}, - direction=foundation.Direction.Client_to_Server, - ), - } + Subclasses the standard ``Groups`` cluster but is kept out of the global + cluster registry, as it is only used on the XBee coordinator device. + """ + + _skip_registry = True + cluster_id = 0x8006 + ep_attribute = "xbee_groups_response" + + client_commands = { + **Groups.client_commands, + 0x04: foundation.ZCLCommandDef( + "remove_all_response", + {"status": foundation.Status}, + direction=foundation.Direction.Client_to_Server, + ), + } - def __init__(self, *args, **kwargs): + +class XBeeCoordinator(zigpy.device.Device): + """Zigpy Device representing the coordinator.""" + + def __init__( + self, + application: zigpy.application.ControllerApplication, + ieee: zigpy.types.EUI64, + nwk: zigpy.types.NWK, + replaces: zigpy.device.Device, + ): """Initialize instance.""" + super().__init__(application, ieee, nwk) - super().__init__(*args, **kwargs) + self.status = replaces.status + self.manufacturer = "Digi" + self.model = "XBee" self.node_desc = zdo_t.NodeDescriptor( logical_type=zdo_t.LogicalType.Coordinator, complex_descriptor_available=0, @@ -475,15 +498,9 @@ def __init__(self, *args, **kwargs): descriptor_capability_field=zdo_t.NodeDescriptor.DescriptorCapability.NONE, ) - replacement = { - "manufacturer": "Digi", - "model": "XBee", - "endpoints": { - XBEE_ENDPOINT_ID: { - "device_type": 0x0050, - "profile_id": 0xC105, - "input_clusters": [XBeeGroup, XBeeGroupResponse], - "output_clusters": [], - } - }, - } + ep = self.add_endpoint(XBEE_ENDPOINT_ID) + ep.status = zigpy.endpoint.Status.ZDO_INIT + ep.profile_id = 0xC105 + ep.device_type = 0x0050 + for cluster in (XBeeGroup, XBeeGroupResponse): + ep.add_input_cluster(cluster.cluster_id, cluster(ep, is_server=True))