Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand All @@ -23,7 +23,6 @@ exclude = ["tests", "tests.*"]
[project.optional-dependencies]
testing = [
"pytest>=7.1.2",
"asynctest>=0.13.0",
"pytest-asyncio>=0.19.0",
]

Expand Down
3 changes: 1 addition & 2 deletions requirements_test.txt
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
# Test dependencies.

asynctest
isort
black
flake8
Expand All @@ -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
78 changes: 77 additions & 1 deletion tests/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,12 @@

import pytest
import zigpy.config as config
import zigpy.device
import zigpy.endpoint
import zigpy.exceptions
import zigpy.state
import zigpy.types as t
from zigpy.zcl.clusters.general import Basic, OnOff
import zigpy.zdo
import zigpy.zdo.types as zdo_t

Expand Down Expand Up @@ -347,7 +350,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

Expand Down Expand Up @@ -414,6 +417,79 @@ async def test_start_network(app):
await _test_start_network(app, ai_status=0x00, zs=1, legacy_module=True)


async def test_start_network_coordinator_device(app):
"""Test the coordinator device `start_network` builds."""
await _test_start_network(app, ai_status=0x00)

xbee_dev = app._device
assert isinstance(xbee_dev, application.XBeeCoordinator)
assert xbee_dev.status == zigpy.device.Status.ENDPOINTS_INIT
assert xbee_dev.manufacturer == "Digi"
assert xbee_dev.model == "XBee"
assert xbee_dev.node_desc.logical_type == zdo_t.LogicalType.Coordinator

# The XBee-specific endpoint, built by `XBeeCoordinator.__init__`
xbee_ep = xbee_dev.endpoints[application.XBEE_ENDPOINT_ID]
assert xbee_ep.status == zigpy.endpoint.Status.ZDO_INIT
assert xbee_ep.profile_id == 0xC105
assert xbee_ep.device_type == 0x0050
assert isinstance(xbee_ep.in_clusters[0x0006], application.XBeeGroup)
assert isinstance(xbee_ep.in_clusters[0x8006], application.XBeeGroupResponse)
# `add_to_group()`/`remove_from_group()` dispatch through `ep.groups`
assert xbee_ep.groups is xbee_ep.in_clusters[0x0006]
assert xbee_ep.xbee_groups_response is xbee_ep.in_clusters[0x8006]


async def test_start_network_registers_endpoints(app):
"""Test that zigpy's endpoints are registered with their clusters."""
await _test_start_network(app, ai_status=0x00)

# The quirks-based `add_endpoint()` this replaces silently registered bare
# endpoints: no profile, no device type and no clusters at all. The exact
# descriptors come from zigpy, so only their presence is asserted here.
ep1 = app._device.endpoints[1]
assert ep1.status == zigpy.endpoint.Status.ZDO_INIT
assert ep1.profile_id == 0x0104
assert ep1.device_type is not None
assert isinstance(ep1.in_clusters[0x0000], Basic)
assert ep1.out_clusters
# `XBeeGroup` overrides `cluster_id` to `0x0006`, which is `OnOff`'s real
# cluster ID: `_skip_registry` keeps it out of the global cluster registry,
# so unrelated endpoints still get `OnOff` here.
assert isinstance(ep1.in_clusters[0x0006], OnOff)

ep2 = app._device.endpoints[2]
assert ep2.status == zigpy.endpoint.Status.ZDO_INIT
assert ep2.device_type is not None
assert isinstance(ep2.in_clusters[0x0000], Basic)


async def test_start_network_raw_device_initialized_after_endpoints(app):
"""Test `raw_device_initialized` fires once the endpoints are registered."""
endpoints_at_event = None

class Listener:
def raw_device_initialized(self, device):
nonlocal endpoints_at_event
# Listeners snapshot the device synchronously (`zigpy.appdb` persists
# its endpoints and clusters right here), so what they see at this
# point is all they ever get.
endpoints_at_event = {
ep_id: sorted(ep.in_clusters)
for ep_id, ep in device.endpoints.items()
if ep_id != 0 # the ZDO endpoint has no clusters
}

app.add_listener(Listener())
await _test_start_network(app, ai_status=0x00)

assert endpoints_at_event is not None
assert set(endpoints_at_event) == set(app._device.endpoints) - {0}
# zigpy's endpoints, with their clusters -- not just the XBee endpoint
assert endpoints_at_event[1]
assert endpoints_at_event[2]


async def test_start_network_no_api_mode(app):
"""Test start network when not in API mode."""
with pytest.raises(asyncio.TimeoutError):
Expand Down
5 changes: 2 additions & 3 deletions tests/test_uart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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


Expand All @@ -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)

Expand Down
36 changes: 0 additions & 36 deletions tox.ini

This file was deleted.

16 changes: 8 additions & 8 deletions zigpy_xbee/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -310,7 +310,7 @@
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:
Expand All @@ -320,7 +320,7 @@
def connection_lost(self, exc: Exception) -> None:
"""Lost serial connection."""
if self._app is not None:
self._app.connection_lost(exc)

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.14

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.12

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.13

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.13

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.14

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 323 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.12

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

async def disconnect(self):
"""Close the connection."""
Expand Down Expand Up @@ -354,7 +354,7 @@
),
timeout=REMOTE_AT_COMMAND_TIMEOUT,
)
except asyncio.TimeoutError:
except TimeoutError:
LOGGER.warning("No response to %s command", name)
raise

Expand All @@ -366,7 +366,7 @@
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

Expand Down Expand Up @@ -507,12 +507,12 @@
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"))

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.14

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.12

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.13

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.13

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.14

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 510 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.12

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

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

Expand All @@ -534,7 +534,7 @@
LOGGER.debug("No response to %s cmd", cmd)
return None
LOGGER.debug("Successfully sent %s cmd", cmd)
self._uart.reset_command_mode()

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.14

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.12

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.13

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.13

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.11

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.14

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited

Check warning on line 537 in zigpy_xbee/api.py

View workflow job for this annotation

GitHub Actions / shared-ci / Run tests Python 3.12

coroutine 'AsyncMockMixin._execute_mock_call' was never awaited
return True

async def init_api_mode(self):
Expand Down
46 changes: 40 additions & 6 deletions zigpy_xbee/uart.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import asyncio
import logging
from typing import Any, Dict
from typing import Any

import zigpy.config
import zigpy.serial
Expand All @@ -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__()
Expand All @@ -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."""
Expand Down Expand Up @@ -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(),
Expand Down
Loading