Skip to content
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,9 @@ dependencies = [
"zha-quirks==0.0.124",
"pyserial==3.5",
"pyserial-asyncio-fast",
"pydantic==2.9.2",
"websockets",
"aiohttp"
]

[tool.setuptools.packages.find]
Expand Down
67 changes: 67 additions & 0 deletions tests/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -504,6 +504,9 @@ def create_mock_zigpy_device(
descriptor_capability_field=zdo_t.NodeDescriptor.DescriptorCapability.NONE,
)

if isinstance(node_descriptor, bytes):
node_descriptor = zdo_t.NodeDescriptor.deserialize(node_descriptor)[0]

device.node_desc = node_descriptor
device.last_seen = time.time()

Expand Down Expand Up @@ -542,3 +545,67 @@ def create_mock_zigpy_device(
cluster._attr_cache[attr_id] = value

return device


def find_entity_id(
domain: str, zha_device: Device, qualifier: Optional[str] = None
) -> Optional[str]:
"""Find the entity id under the testing.

This is used to get the entity id in order to get the state from the state
machine so that we can test state changes.
"""
entities = find_entity_ids(domain, zha_device)
if not entities:
return None
if qualifier:
for entity_id in entities:
if qualifier in entity_id:
return entity_id
return None
else:
return entities[0]


def find_entity_ids(
domain: str, zha_device: Device, omit: Optional[list[str]] = None
) -> list[str]:
"""Find the entity ids under the testing.

This is used to get the entity id in order to get the state from the state
machine so that we can test state changes.
"""
head = f"{domain}.{str(zha_device.ieee)}"

entity_ids = [
f"{entity.PLATFORM}.{entity.unique_id}"
for entity in zha_device.platform_entities.values()
]

matches = []
res = []
for entity_id in entity_ids:
if entity_id.startswith(head):
matches.append(entity_id)

if omit:
for entity_id in matches:
skip = False
for o in omit:
if o in entity_id:
skip = True
break
if not skip:
res.append(entity_id)
else:
res = matches
return res


def async_find_group_entity_id(domain: str, group: Group) -> Optional[str]:
"""Find the group entity id under test."""
entity_id = f"{domain}_zha_group_0x{group.group_id:04x}"

if entity_id in group.group_entities:
return entity_id
return None
61 changes: 57 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"""Test configuration for the ZHA component."""

import asyncio
from collections.abc import Callable, Generator
from collections.abc import AsyncGenerator, Callable, Generator
from contextlib import contextmanager
import logging
import os
Expand All @@ -10,6 +10,7 @@
from types import TracebackType
from unittest.mock import AsyncMock, MagicMock, patch

import aiohttp.test_utils
import pytest
import zigpy
from zigpy.application import ControllerApplication
Expand All @@ -28,10 +29,13 @@
AlarmControlPanelOptions,
CoordinatorConfiguration,
LightOptions,
ServerConfiguration,
ZHAConfiguration,
ZHAData,
)
from zha.async_ import ZHAJob
from zha.websocket.client.controller import Controller
from zha.websocket.server.gateway import WebSocketGateway

FIXTURE_GRP_ID = 0x1001
FIXTURE_GRP_NAME = "fixture group"
Expand Down Expand Up @@ -230,7 +234,21 @@ async def zigpy_app_controller_fixture():

# Create a fake coordinator device
dev = app.add_device(nwk=app.state.node_info.nwk, ieee=app.state.node_info.ieee)
dev.node_desc = zdo_t.NodeDescriptor()
dev.node_desc = zdo_t.NodeDescriptor(
logical_type=zdo_t.LogicalType.Coordinator,
complex_descriptor_available=0,
user_descriptor_available=0,
reserved=0,
aps_flags=0,
frequency_band=zdo_t.NodeDescriptor.FrequencyBand.Freq2400MHz,
mac_capability_flags=zdo_t.NodeDescriptor.MACCapabilityFlags.AllocateAddress,
manufacturer_code=0x1234,
maximum_buffer_size=127,
maximum_incoming_transfer_size=100,
server_mask=10752,
maximum_outgoing_transfer_size=100,
descriptor_capability_field=zdo_t.NodeDescriptor.DescriptorCapability.NONE,
)
dev.node_desc.logical_type = zdo_t.LogicalType.Coordinator
dev.manufacturer = "Coordinator Manufacturer"
dev.model = "Coordinator Model"
Expand All @@ -253,7 +271,7 @@ def caplog_fixture(caplog: pytest.LogCaptureFixture) -> pytest.LogCaptureFixture
@pytest.fixture(name="zha_data")
def zha_data_fixture() -> ZHAData:
"""Fixture representing zha configuration data."""

port = aiohttp.test_utils.unused_port()
return ZHAData(
config=ZHAConfiguration(
coordinator_configuration=CoordinatorConfiguration(
Expand All @@ -269,7 +287,12 @@ def zha_data_fixture() -> ZHAData:
master_code="4321",
failed_tries=2,
),
)
),
server_config=ServerConfiguration(
host="localhost",
port=port,
network_auto_start=False,
),
)


Expand Down Expand Up @@ -299,6 +322,36 @@ async def __aexit__(
await asyncio.sleep(0)


@pytest.fixture
async def connected_client_and_server(
zha_data: ZHAData,
zigpy_app_controller: ControllerApplication,
caplog: pytest.LogCaptureFixture, # pylint: disable=unused-argument
) -> AsyncGenerator[tuple[Controller, WebSocketGateway], None]:
"""Return the connected client and server fixture."""

with (
patch(
"bellows.zigbee.application.ControllerApplication.new",
return_value=zigpy_app_controller,
),
patch(
"bellows.zigbee.application.ControllerApplication",
return_value=zigpy_app_controller,
),
):
ws_gateway = await WebSocketGateway.async_from_config(zha_data)
await ws_gateway.async_initialize()
await ws_gateway.async_block_till_done()
await ws_gateway.async_initialize_devices_and_entities()
async with (
ws_gateway as gateway,
Controller(f"ws://localhost:{zha_data.server_config.port}") as controller,
):
await controller.clients.listen()
yield controller, gateway


@pytest.fixture
async def zha_gateway(
zha_data: ZHAData,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"ieee":"00:0d:6f:00:0f:3a:e3:69","nwk":"0x970A","manufacturer":"CentraLite","model":"3320-L","name":"CentraLite 3320-L","quirk_applied":true,"quirk_class":"zhaquirks.centralite.ias.CentraLiteIASSensor","quirk_id":null,"manufacturer_code":49887,"power_source":"Battery or Unknown","lqi":null,"rssi":null,"available":true,"device_type":"EndDevice","signature":{"node_descriptor":{"logical_type":2,"complex_descriptor_available":0,"user_descriptor_available":0,"reserved":0,"aps_flags":0,"frequency_band":8,"mac_capability_flags":128,"manufacturer_code":49887,"maximum_buffer_size":82,"maximum_incoming_transfer_size":82,"server_mask":0,"maximum_outgoing_transfer_size":82,"descriptor_capability_field":0},"endpoints":{"1":{"profile_id":"0x0104","device_type":"0x0402","input_clusters":["0x0000","0x0001","0x0003","0x0020","0x0402","0x0500","0x0b05"],"output_clusters":["0x0019"]},"2":{"profile_id":"0xc2df","device_type":"0x000c","input_clusters":["0x0000","0x0003","0x0b05","0xfc0f"],"output_clusters":["0x0003"]}},"manufacturer":"CentraLite","model":"3320-L"},"active_coordinator":false,"entities":{"binary_sensor,00:0d:6f:00:0f:3a:e3:69-1-1280":{"platform":"binary_sensor","unique_id":"00:0d:6f:00:0f:3a:e3:69-1-1280","class_name":"IASZone","translation_key":null,"device_class":"opening","state_class":null,"entity_category":null,"entity_registry_enabled_default":true,"enabled":true,"fallback_name":null,"state":{"class_name":"IASZone","state":false,"available":true},"cluster_handlers":[{"class_name":"IASZoneClusterHandler","generic_id":"cluster_handler_0x0500","endpoint_id":1,"cluster":{"id":1280,"name":"IAS Zone","type":"server","endpoint_id":1,"endpoint_attribute":"ias_zone"},"id":"1:0x0500","unique_id":"00:0d:6f:00:0f:3a:e3:69:1:0x0500","status":"initialized","value_attribute":null}],"device_ieee":"00:0d:6f:00:0f:3a:e3:69","endpoint_id":1,"available":true,"group_id":null,"attribute_name":"zone_status"},"button,00:0d:6f:00:0f:3a:e3:69-1-3":{"platform":"button","unique_id":"00:0d:6f:00:0f:3a:e3:69-1-3","class_name":"IdentifyButton","translation_key":null,"device_class":"identify","state_class":null,"entity_category":"diagnostic","entity_registry_enabled_default":true,"enabled":true,"fallback_name":null,"state":{"class_name":"IdentifyButton","available":true,"state":null},"cluster_handlers":[{"class_name":"IdentifyClusterHandler","generic_id":"cluster_handler_0x0003","endpoint_id":1,"cluster":{"id":3,"name":"Identify","type":"server","endpoint_id":1,"endpoint_attribute":"identify"},"id":"1:0x0003","unique_id":"00:0d:6f:00:0f:3a:e3:69:1:0x0003","status":"initialized","value_attribute":null}],"device_ieee":"00:0d:6f:00:0f:3a:e3:69","endpoint_id":1,"available":true,"group_id":null,"command":"identify","attribute_name":null,"attribute_value":null,"args":[5],"kwargs":{}},"sensor,00:0d:6f:00:0f:3a:e3:69-1-1":{"platform":"sensor","unique_id":"00:0d:6f:00:0f:3a:e3:69-1-1","class_name":"Battery","translation_key":null,"device_class":"battery","state_class":"measurement","entity_category":"diagnostic","entity_registry_enabled_default":true,"enabled":true,"fallback_name":null,"state":{"class_name":"Battery","state":100,"battery_size":"Other","battery_quantity":1,"battery_voltage":2.8,"available":true},"cluster_handlers":[{"class_name":"PowerConfigurationClusterHandler","generic_id":"cluster_handler_0x0001","endpoint_id":1,"cluster":{"id":1,"name":"Power Configuration","type":"server","endpoint_id":1,"endpoint_attribute":"power"},"id":"1:0x0001","unique_id":"00:0d:6f:00:0f:3a:e3:69:1:0x0001","status":"initialized","value_attribute":"battery_voltage"}],"device_ieee":"00:0d:6f:00:0f:3a:e3:69","endpoint_id":1,"available":true,"group_id":null,"attribute":"battery_percentage_remaining","decimals":1,"divisor":1,"multiplier":1,"unit":"%"},"sensor,00:0d:6f:00:0f:3a:e3:69-1-1026":{"platform":"sensor","unique_id":"00:0d:6f:00:0f:3a:e3:69-1-1026","class_name":"Temperature","translation_key":null,"device_class":"temperature","state_class":"measurement","entity_category":null,"entity_registry_enabled_default":true,"enabled":true,"fallback_name":null,"state":{"class_name":"Temperature","available":true,"state":20.2},"cluster_handlers":[{"class_name":"TemperatureMeasurementClusterHandler","generic_id":"cluster_handler_0x0402","endpoint_id":1,"cluster":{"id":1026,"name":"Temperature Measurement","type":"server","endpoint_id":1,"endpoint_attribute":"temperature"},"id":"1:0x0402","unique_id":"00:0d:6f:00:0f:3a:e3:69:1:0x0402","status":"initialized","value_attribute":"measured_value"}],"device_ieee":"00:0d:6f:00:0f:3a:e3:69","endpoint_id":1,"available":true,"group_id":null,"attribute":"measured_value","decimals":1,"divisor":100,"multiplier":1,"unit":"°C"},"sensor,00:0d:6f:00:0f:3a:e3:69-1-0-rssi":{"platform":"sensor","unique_id":"00:0d:6f:00:0f:3a:e3:69-1-0-rssi","class_name":"RSSISensor","translation_key":"rssi","device_class":"signal_strength","state_class":"measurement","entity_category":"diagnostic","entity_registry_enabled_default":false,"enabled":true,"fallback_name":null,"state":{"class_name":"RSSISensor","available":true,"state":null},"cluster_handlers":[{"class_name":"BasicClusterHandler","generic_id":"cluster_handler_0x0000","endpoint_id":1,"cluster":{"id":0,"name":"Basic","type":"server","endpoint_id":1,"endpoint_attribute":"basic"},"id":"1:0x0000","unique_id":"00:0d:6f:00:0f:3a:e3:69:1:0x0000","status":"initialized","value_attribute":null}],"device_ieee":"00:0d:6f:00:0f:3a:e3:69","endpoint_id":1,"available":true,"group_id":null,"attribute":null,"decimals":1,"divisor":1,"multiplier":1,"unit":"dBm"},"sensor,00:0d:6f:00:0f:3a:e3:69-1-0-lqi":{"platform":"sensor","unique_id":"00:0d:6f:00:0f:3a:e3:69-1-0-lqi","class_name":"LQISensor","translation_key":"lqi","device_class":null,"state_class":"measurement","entity_category":"diagnostic","entity_registry_enabled_default":false,"enabled":true,"fallback_name":null,"state":{"class_name":"LQISensor","available":true,"state":null},"cluster_handlers":[{"class_name":"BasicClusterHandler","generic_id":"cluster_handler_0x0000","endpoint_id":1,"cluster":{"id":0,"name":"Basic","type":"server","endpoint_id":1,"endpoint_attribute":"basic"},"id":"1:0x0000","unique_id":"00:0d:6f:00:0f:3a:e3:69:1:0x0000","status":"initialized","value_attribute":null}],"device_ieee":"00:0d:6f:00:0f:3a:e3:69","endpoint_id":1,"available":true,"group_id":null,"attribute":null,"decimals":1,"divisor":1,"multiplier":1,"unit":null},"update,00:0d:6f:00:0f:3a:e3:69-1-25-firmware_update":{"platform":"update","unique_id":"00:0d:6f:00:0f:3a:e3:69-1-25-firmware_update","class_name":"FirmwareUpdateEntity","translation_key":null,"device_class":"firmware","state_class":null,"entity_category":"config","entity_registry_enabled_default":true,"enabled":true,"fallback_name":null,"state":{"class_name":"FirmwareUpdateEntity","available":true,"installed_version":null,"in_progress":false,"progress":0,"latest_version":null,"release_summary":null,"release_notes":null,"release_url":null},"cluster_handlers":[{"class_name":"OtaClientClusterHandler","generic_id":"cluster_handler_0x0019","endpoint_id":1,"cluster":{"id":25,"name":"Ota","type":"client","endpoint_id":1,"endpoint_attribute":"ota"},"id":"1:0x0019","unique_id":"00:0d:6f:00:0f:3a:e3:69:1:0x0019","status":"initialized","value_attribute":null}],"device_ieee":"00:0d:6f:00:0f:3a:e3:69","endpoint_id":1,"available":true,"group_id":null,"supported_features":7}},"neighbors":[],"routes":[],"endpoint_names":[{"name":"IAS_ZONE"},{"name":"unknown 12 device_type of 0xc2df profile id"}],"device_automation_triggers":{"device_offline,device_offline":{"device_event_type":"device_offline"}}}
121 changes: 120 additions & 1 deletion tests/test_device.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
SIG_EP_TYPE,
create_mock_zigpy_device,
join_zigpy_device,
zigpy_device_from_json,
)
from zha.application import Platform
from zha.application.const import (
Expand All @@ -36,7 +37,13 @@
from zha.application.platforms.sensor import LQISensor, RSSISensor
from zha.application.platforms.switch import Switch
from zha.exceptions import ZHAException
from zha.zigbee.device import ClusterBinding, Device, get_device_automation_triggers
from zha.zigbee.device import (
ClusterBinding,
Device,
NeighborInfo,
RouteInfo,
get_device_automation_triggers,
)
from zha.zigbee.group import Group


Expand Down Expand Up @@ -848,3 +855,115 @@ async def test_device_properties(
assert zha_device.is_router is None
assert zha_device.is_end_device is None
assert zha_device.is_coordinator is None


def test_neighbor_info_ser_deser() -> None:
"""Test the serialization and deserialization of the neighbor info."""

neighbor_info = NeighborInfo(
ieee="00:0d:6f:00:0a:90:69:e7",
nwk="0x1234",
extended_pan_id="00:0d:6f:00:0a:90:69:e7",
lqi=255,
relationship=zdo_t._NeighborEnums.Relationship.Child.name,
depth=0,
device_type=zdo_t._NeighborEnums.DeviceType.Router.name,
rx_on_when_idle=zdo_t._NeighborEnums.RxOnWhenIdle.On.name,
permit_joining=zdo_t._NeighborEnums.PermitJoins.Accepting.name,
)

assert isinstance(neighbor_info.ieee, zigpy.types.EUI64)
assert isinstance(neighbor_info.nwk, zigpy.types.NWK)
assert isinstance(neighbor_info.extended_pan_id, zigpy.types.EUI64)
assert isinstance(neighbor_info.relationship, zdo_t._NeighborEnums.Relationship)
assert isinstance(neighbor_info.device_type, zdo_t._NeighborEnums.DeviceType)
assert isinstance(neighbor_info.rx_on_when_idle, zdo_t._NeighborEnums.RxOnWhenIdle)
assert isinstance(neighbor_info.permit_joining, zdo_t._NeighborEnums.PermitJoins)

assert neighbor_info.model_dump() == {
"ieee": "00:0d:6f:00:0a:90:69:e7",
"nwk": 0x1234,
"extended_pan_id": "00:0d:6f:00:0a:90:69:e7",
"lqi": 255,
"relationship": zdo_t._NeighborEnums.Relationship.Child.name,
"depth": 0,
"device_type": zdo_t._NeighborEnums.DeviceType.Router.name,
"rx_on_when_idle": zdo_t._NeighborEnums.RxOnWhenIdle.On.name,
"permit_joining": zdo_t._NeighborEnums.PermitJoins.Accepting.name,
}

assert neighbor_info.model_dump_json() == (
'{"device_type":"Router","rx_on_when_idle":"On","relationship":"Child",'
'"extended_pan_id":"00:0d:6f:00:0a:90:69:e7","ieee":"00:0d:6f:00:0a:90:69:e7","nwk":"0x1234",'
'"permit_joining":"Accepting","depth":0,"lqi":255}'
)


def test_route_info_ser_deser() -> None:
"""Test the serialization and deserialization of the route info."""

route_info = RouteInfo(
dest_nwk=0x1234,
next_hop=0x5678,
route_status=zdo_t.RouteStatus.Active.name,
memory_constrained=0,
many_to_one=1,
route_record_required=1,
)

assert isinstance(route_info.dest_nwk, zigpy.types.NWK)
assert isinstance(route_info.next_hop, zigpy.types.NWK)
assert isinstance(route_info.route_status, zdo_t.RouteStatus)

assert route_info.model_dump() == {
"dest_nwk": 0x1234,
"next_hop": 0x5678,
"route_status": zdo_t.RouteStatus.Active.name,
"memory_constrained": 0,
"many_to_one": 1,
"route_record_required": 1,
}

assert route_info.model_dump_json() == (
'{"dest_nwk":"0x1234","route_status":"Active","memory_constrained":0,"many_to_one":1,'
'"route_record_required":1,"next_hop":"0x5678"}'
)


def test_convert_extended_pan_id() -> None:
"""Test conversion of extended panid."""

extended_pan_id = zigpy.types.ExtendedPanId.convert("00:0d:6f:00:0a:90:69:e7")

assert NeighborInfo.convert_extended_pan_id(extended_pan_id) == extended_pan_id

converted_extended_pan_id = NeighborInfo.convert_extended_pan_id(
"00:0d:6f:00:0a:90:69:e7"
)
assert isinstance(converted_extended_pan_id, zigpy.types.ExtendedPanId)
assert converted_extended_pan_id == extended_pan_id


async def test_extended_device_info_ser_deser(zha_gateway: Gateway) -> None:
"""Test the serialization and deserialization of the extended device info."""

zigpy_dev = await zigpy_device_from_json(
zha_gateway.application_controller, "tests/data/devices/centralite-3320-l.json"
)
zha_device = await join_zigpy_device(zha_gateway, zigpy_dev)
assert zha_device is not None

assert isinstance(zha_device.extended_device_info.ieee, zigpy.types.EUI64)
assert isinstance(zha_device.extended_device_info.nwk, zigpy.types.NWK)

# last_seen changes so we exclude it from the comparison
json = zha_device.extended_device_info.model_dump_json(exclude=["last_seen"])

# load the json from a file as string
with open(
"tests/data/serialization_data/centralite-3320-l-extended-device-info.json",
encoding="UTF-8",
) as file:
expected_json = file.read()

assert json == expected_json
Loading