Defensively validate ZHA quirks v2 supplied entity metadata (#112643)

This commit is contained in:
David F. Mulcahey 2024-03-27 12:48:43 -04:00 committed by GitHub
parent 65230908c6
commit c518acfef3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 602 additions and 144 deletions

View File

@ -3,8 +3,9 @@
from __future__ import annotations from __future__ import annotations
import functools import functools
import logging
from zigpy.quirks.v2 import BinarySensorMetadata, EntityMetadata from zigpy.quirks.v2 import BinarySensorMetadata
import zigpy.types as t import zigpy.types as t
from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.clusters.security import IasZone from zigpy.zcl.clusters.security import IasZone
@ -27,11 +28,11 @@ from .core.const import (
CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ON_OFF,
CLUSTER_HANDLER_ZONE, CLUSTER_HANDLER_ZONE,
QUIRK_METADATA, ENTITY_METADATA,
SIGNAL_ADD_ENTITIES, SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED, SIGNAL_ATTR_UPDATED,
) )
from .core.helpers import get_zha_data from .core.helpers import get_zha_data, validate_device_class
from .core.registries import ZHA_ENTITIES from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity from .entity import ZhaEntity
@ -51,6 +52,8 @@ CONFIG_DIAGNOSTIC_MATCH = functools.partial(
ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR
) )
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
@ -79,15 +82,21 @@ class BinarySensor(ZhaEntity, BinarySensorEntity):
def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None: def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None:
"""Initialize the ZHA binary sensor.""" """Initialize the ZHA binary sensor."""
self._cluster_handler = cluster_handlers[0] self._cluster_handler = cluster_handlers[0]
if QUIRK_METADATA in kwargs: if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def _init_from_quirks_metadata(self, entity_metadata: BinarySensorMetadata) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata) super()._init_from_quirks_metadata(entity_metadata)
binary_sensor_metadata: BinarySensorMetadata = entity_metadata.entity_metadata self._attribute_name = entity_metadata.attribute_name
self._attribute_name = binary_sensor_metadata.attribute_name if entity_metadata.device_class is not None:
self._attr_device_class = validate_device_class(
BinarySensorDeviceClass,
entity_metadata.device_class,
Platform.BINARY_SENSOR.value,
_LOGGER,
)
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass.""" """Run when about to be added to hass."""

View File

@ -6,11 +6,7 @@ import functools
import logging import logging
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
from zigpy.quirks.v2 import ( from zigpy.quirks.v2 import WriteAttributeButtonMetadata, ZCLCommandButtonMetadata
EntityMetadata,
WriteAttributeButtonMetadata,
ZCLCommandButtonMetadata,
)
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
@ -20,7 +16,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .core import discovery from .core import discovery
from .core.const import CLUSTER_HANDLER_IDENTIFY, QUIRK_METADATA, SIGNAL_ADD_ENTITIES from .core.const import CLUSTER_HANDLER_IDENTIFY, ENTITY_METADATA, SIGNAL_ADD_ENTITIES
from .core.helpers import get_zha_data from .core.helpers import get_zha_data
from .core.registries import ZHA_ENTITIES from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity from .entity import ZhaEntity
@ -76,17 +72,18 @@ class ZHAButton(ZhaEntity, ButtonEntity):
) -> None: ) -> None:
"""Init this button.""" """Init this button."""
self._cluster_handler: ClusterHandler = cluster_handlers[0] self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs: if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def _init_from_quirks_metadata(
self, entity_metadata: ZCLCommandButtonMetadata
) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata) super()._init_from_quirks_metadata(entity_metadata)
button_metadata: ZCLCommandButtonMetadata = entity_metadata.entity_metadata self._command_name = entity_metadata.command_name
self._command_name = button_metadata.command_name self._args = entity_metadata.args
self._args = button_metadata.args self._kwargs = entity_metadata.kwargs
self._kwargs = button_metadata.kwargs
def get_args(self) -> list[Any]: def get_args(self) -> list[Any]:
"""Return the arguments to use in the command.""" """Return the arguments to use in the command."""
@ -148,16 +145,17 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity):
) -> None: ) -> None:
"""Init this button.""" """Init this button."""
self._cluster_handler: ClusterHandler = cluster_handlers[0] self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs: if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def _init_from_quirks_metadata(
self, entity_metadata: WriteAttributeButtonMetadata
) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata) super()._init_from_quirks_metadata(entity_metadata)
button_metadata: WriteAttributeButtonMetadata = entity_metadata.entity_metadata self._attribute_name = entity_metadata.attribute_name
self._attribute_name = button_metadata.attribute_name self._attribute_value = entity_metadata.attribute_value
self._attribute_value = button_metadata.attribute_value
async def async_press(self) -> None: async def async_press(self) -> None:
"""Write attribute with defined value.""" """Write attribute with defined value."""

View File

@ -220,6 +220,8 @@ DISCOVERY_KEY = "zha_discovery_info"
DOMAIN = "zha" DOMAIN = "zha"
ENTITY_METADATA = "entity_metadata"
GROUP_ID = "group_id" GROUP_ID = "group_id"
GROUP_IDS = "group_ids" GROUP_IDS = "group_ids"
GROUP_NAME = "group_name" GROUP_NAME = "group_name"
@ -233,8 +235,6 @@ PRESET_SCHEDULE = "Schedule"
PRESET_COMPLEX = "Complex" PRESET_COMPLEX = "Complex"
PRESET_TEMP_MANUAL = "Temporary manual" PRESET_TEMP_MANUAL = "Temporary manual"
QUIRK_METADATA = "quirk_metadata"
ZCL_INIT_ATTRS = "ZCL_INIT_ATTRS" ZCL_INIT_ATTRS = "ZCL_INIT_ATTRS"
ZHA_ALARM_OPTIONS = "zha_alarm_options" ZHA_ALARM_OPTIONS = "zha_alarm_options"

View File

@ -85,12 +85,18 @@ QUIRKS_ENTITY_META_TO_ENTITY_CLASS = {
WriteAttributeButtonMetadata, WriteAttributeButtonMetadata,
EntityType.CONFIG, EntityType.CONFIG,
): button.ZHAAttributeButton, ): button.ZHAAttributeButton,
(
Platform.BUTTON,
WriteAttributeButtonMetadata,
EntityType.STANDARD,
): button.ZHAAttributeButton,
(Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.ZHAButton, (Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.CONFIG): button.ZHAButton,
( (
Platform.BUTTON, Platform.BUTTON,
ZCLCommandButtonMetadata, ZCLCommandButtonMetadata,
EntityType.DIAGNOSTIC, EntityType.DIAGNOSTIC,
): button.ZHAButton, ): button.ZHAButton,
(Platform.BUTTON, ZCLCommandButtonMetadata, EntityType.STANDARD): button.ZHAButton,
( (
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
BinarySensorMetadata, BinarySensorMetadata,
@ -111,6 +117,7 @@ QUIRKS_ENTITY_META_TO_ENTITY_CLASS = {
(Platform.SENSOR, ZCLSensorMetadata, EntityType.DIAGNOSTIC): sensor.Sensor, (Platform.SENSOR, ZCLSensorMetadata, EntityType.DIAGNOSTIC): sensor.Sensor,
(Platform.SENSOR, ZCLSensorMetadata, EntityType.STANDARD): sensor.Sensor, (Platform.SENSOR, ZCLSensorMetadata, EntityType.STANDARD): sensor.Sensor,
(Platform.SELECT, ZCLEnumMetadata, EntityType.CONFIG): select.ZCLEnumSelectEntity, (Platform.SELECT, ZCLEnumMetadata, EntityType.CONFIG): select.ZCLEnumSelectEntity,
(Platform.SELECT, ZCLEnumMetadata, EntityType.STANDARD): select.ZCLEnumSelectEntity,
( (
Platform.SELECT, Platform.SELECT,
ZCLEnumMetadata, ZCLEnumMetadata,
@ -224,7 +231,7 @@ class ProbeEndpoint:
for ( for (
cluster_details, cluster_details,
quirk_metadata_list, entity_metadata_list,
) in zigpy_device.exposes_metadata.items(): ) in zigpy_device.exposes_metadata.items():
endpoint_id, cluster_id, cluster_type = cluster_details endpoint_id, cluster_id, cluster_type = cluster_details
@ -265,11 +272,11 @@ class ProbeEndpoint:
) )
assert cluster_handler assert cluster_handler
for quirk_metadata in quirk_metadata_list: for entity_metadata in entity_metadata_list:
platform = Platform(quirk_metadata.entity_platform.value) platform = Platform(entity_metadata.entity_platform.value)
metadata_type = type(quirk_metadata.entity_metadata) metadata_type = type(entity_metadata)
entity_class = QUIRKS_ENTITY_META_TO_ENTITY_CLASS.get( entity_class = QUIRKS_ENTITY_META_TO_ENTITY_CLASS.get(
(platform, metadata_type, quirk_metadata.entity_type) (platform, metadata_type, entity_metadata.entity_type)
) )
if entity_class is None: if entity_class is None:
@ -280,7 +287,7 @@ class ProbeEndpoint:
device.name, device.name,
{ {
zha_const.CLUSTER_DETAILS: cluster_details, zha_const.CLUSTER_DETAILS: cluster_details,
zha_const.QUIRK_METADATA: quirk_metadata, zha_const.ENTITY_METADATA: entity_metadata,
}, },
) )
continue continue
@ -288,13 +295,13 @@ class ProbeEndpoint:
# automatically add the attribute to ZCL_INIT_ATTRS for the cluster # automatically add the attribute to ZCL_INIT_ATTRS for the cluster
# handler if it is not already in the list # handler if it is not already in the list
if ( if (
hasattr(quirk_metadata.entity_metadata, "attribute_name") hasattr(entity_metadata, "attribute_name")
and quirk_metadata.entity_metadata.attribute_name and entity_metadata.attribute_name
not in cluster_handler.ZCL_INIT_ATTRS not in cluster_handler.ZCL_INIT_ATTRS
): ):
init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy() init_attrs = cluster_handler.ZCL_INIT_ATTRS.copy()
init_attrs[quirk_metadata.entity_metadata.attribute_name] = ( init_attrs[entity_metadata.attribute_name] = (
quirk_metadata.attribute_initialized_from_cache entity_metadata.attribute_initialized_from_cache
) )
cluster_handler.__dict__[zha_const.ZCL_INIT_ATTRS] = init_attrs cluster_handler.__dict__[zha_const.ZCL_INIT_ATTRS] = init_attrs
@ -303,7 +310,7 @@ class ProbeEndpoint:
entity_class, entity_class,
endpoint.unique_id, endpoint.unique_id,
[cluster_handler], [cluster_handler],
quirk_metadata=quirk_metadata, entity_metadata=entity_metadata,
) )
_LOGGER.debug( _LOGGER.debug(

View File

@ -14,7 +14,7 @@ from dataclasses import dataclass
import enum import enum
import logging import logging
import re import re
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload
import voluptuous as vol import voluptuous as vol
import zigpy.exceptions import zigpy.exceptions
@ -24,8 +24,33 @@ import zigpy.zcl
from zigpy.zcl.foundation import CommandSchema from zigpy.zcl.foundation import CommandSchema
import zigpy.zdo.types as zdo_types import zigpy.zdo.types as zdo_types
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.number import NumberDeviceClass
from homeassistant.components.sensor import SensorDeviceClass
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform from homeassistant.const import (
Platform,
UnitOfApparentPower,
UnitOfDataRate,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfInformation,
UnitOfIrradiance,
UnitOfLength,
UnitOfMass,
UnitOfPower,
UnitOfPrecipitationDepth,
UnitOfPressure,
UnitOfSoundPressure,
UnitOfSpeed,
UnitOfTemperature,
UnitOfTime,
UnitOfVolume,
UnitOfVolumeFlowRate,
UnitOfVolumetricFlux,
)
from homeassistant.core import HomeAssistant, State, callback from homeassistant.core import HomeAssistant, State, callback
from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType from homeassistant.helpers.typing import ConfigType
@ -218,7 +243,7 @@ def async_get_zha_config_value(
) )
def async_cluster_exists(hass, cluster_id, skip_coordinator=True): def async_cluster_exists(hass: HomeAssistant, cluster_id, skip_coordinator=True):
"""Determine if a device containing the specified in cluster is paired.""" """Determine if a device containing the specified in cluster is paired."""
zha_gateway = get_zha_gateway(hass) zha_gateway = get_zha_gateway(hass)
zha_devices = zha_gateway.devices.values() zha_devices = zha_gateway.devices.values()
@ -424,3 +449,80 @@ def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway:
raise ValueError("No gateway object exists") raise ValueError("No gateway object exists")
return zha_gateway return zha_gateway
UNITS_OF_MEASURE = {
UnitOfApparentPower.__name__: UnitOfApparentPower,
UnitOfPower.__name__: UnitOfPower,
UnitOfEnergy.__name__: UnitOfEnergy,
UnitOfElectricCurrent.__name__: UnitOfElectricCurrent,
UnitOfElectricPotential.__name__: UnitOfElectricPotential,
UnitOfTemperature.__name__: UnitOfTemperature,
UnitOfTime.__name__: UnitOfTime,
UnitOfLength.__name__: UnitOfLength,
UnitOfFrequency.__name__: UnitOfFrequency,
UnitOfPressure.__name__: UnitOfPressure,
UnitOfSoundPressure.__name__: UnitOfSoundPressure,
UnitOfVolume.__name__: UnitOfVolume,
UnitOfVolumeFlowRate.__name__: UnitOfVolumeFlowRate,
UnitOfMass.__name__: UnitOfMass,
UnitOfIrradiance.__name__: UnitOfIrradiance,
UnitOfVolumetricFlux.__name__: UnitOfVolumetricFlux,
UnitOfPrecipitationDepth.__name__: UnitOfPrecipitationDepth,
UnitOfSpeed.__name__: UnitOfSpeed,
UnitOfInformation.__name__: UnitOfInformation,
UnitOfDataRate.__name__: UnitOfDataRate,
}
def validate_unit(quirks_unit: enum.Enum) -> enum.Enum:
"""Validate and return a unit of measure."""
return UNITS_OF_MEASURE[type(quirks_unit).__name__](quirks_unit.value)
@overload
def validate_device_class(
device_class_enum: type[BinarySensorDeviceClass],
metadata_value,
platform: str,
logger: logging.Logger,
) -> BinarySensorDeviceClass | None: ...
@overload
def validate_device_class(
device_class_enum: type[SensorDeviceClass],
metadata_value,
platform: str,
logger: logging.Logger,
) -> SensorDeviceClass | None: ...
@overload
def validate_device_class(
device_class_enum: type[NumberDeviceClass],
metadata_value,
platform: str,
logger: logging.Logger,
) -> NumberDeviceClass | None: ...
def validate_device_class(
device_class_enum: type[BinarySensorDeviceClass]
| type[SensorDeviceClass]
| type[NumberDeviceClass],
metadata_value: enum.Enum,
platform: str,
logger: logging.Logger,
) -> BinarySensorDeviceClass | SensorDeviceClass | NumberDeviceClass | None:
"""Validate and return a device class."""
try:
return device_class_enum(metadata_value.value)
except ValueError as ex:
logger.warning(
"Quirks provided an invalid device class: %s for platform %s: %s",
metadata_value,
platform,
ex,
)
return None

View File

@ -182,25 +182,28 @@ class ZhaEntity(BaseZhaEntity, RestoreEntity):
if entity_metadata.initially_disabled: if entity_metadata.initially_disabled:
self._attr_entity_registry_enabled_default = False self._attr_entity_registry_enabled_default = False
has_device_class = hasattr(entity_metadata, "device_class")
has_attribute_name = hasattr(entity_metadata, "attribute_name")
has_command_name = hasattr(entity_metadata, "command_name")
if not has_device_class or (
has_device_class and entity_metadata.device_class is None
):
if entity_metadata.translation_key: if entity_metadata.translation_key:
self._attr_translation_key = entity_metadata.translation_key self._attr_translation_key = entity_metadata.translation_key
elif has_attribute_name:
if hasattr(entity_metadata.entity_metadata, "attribute_name"): self._attr_translation_key = entity_metadata.attribute_name
if not entity_metadata.translation_key: elif has_command_name:
self._attr_translation_key = ( self._attr_translation_key = entity_metadata.command_name
entity_metadata.entity_metadata.attribute_name if has_attribute_name:
) self._unique_id_suffix = entity_metadata.attribute_name
self._unique_id_suffix = entity_metadata.entity_metadata.attribute_name elif has_command_name:
elif hasattr(entity_metadata.entity_metadata, "command_name"): self._unique_id_suffix = entity_metadata.command_name
if not entity_metadata.translation_key:
self._attr_translation_key = (
entity_metadata.entity_metadata.command_name
)
self._unique_id_suffix = entity_metadata.entity_metadata.command_name
if entity_metadata.entity_type is EntityType.CONFIG: if entity_metadata.entity_type is EntityType.CONFIG:
self._attr_entity_category = EntityCategory.CONFIG self._attr_entity_category = EntityCategory.CONFIG
elif entity_metadata.entity_type is EntityType.DIAGNOSTIC: elif entity_metadata.entity_type is EntityType.DIAGNOSTIC:
self._attr_entity_category = EntityCategory.DIAGNOSTIC self._attr_entity_category = EntityCategory.DIAGNOSTIC
else:
self._attr_entity_category = None
@property @property
def available(self) -> bool: def available(self) -> bool:

View File

@ -26,7 +26,7 @@
"pyserial-asyncio==0.6", "pyserial-asyncio==0.6",
"zha-quirks==0.0.112", "zha-quirks==0.0.112",
"zigpy-deconz==0.23.1", "zigpy-deconz==0.23.1",
"zigpy==0.63.4", "zigpy==0.63.5",
"zigpy-xbee==0.20.1", "zigpy-xbee==0.20.1",
"zigpy-zigate==0.12.0", "zigpy-zigate==0.12.0",
"zigpy-znp==0.12.1", "zigpy-znp==0.12.1",

View File

@ -6,10 +6,10 @@ import functools
import logging import logging
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
from zigpy.quirks.v2 import EntityMetadata, NumberMetadata from zigpy.quirks.v2 import NumberMetadata
from zigpy.zcl.clusters.hvac import Thermostat from zigpy.zcl.clusters.hvac import Thermostat
from homeassistant.components.number import NumberEntity, NumberMode from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
@ -26,11 +26,11 @@ from .core.const import (
CLUSTER_HANDLER_LEVEL, CLUSTER_HANDLER_LEVEL,
CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_THERMOSTAT, CLUSTER_HANDLER_THERMOSTAT,
QUIRK_METADATA, ENTITY_METADATA,
SIGNAL_ADD_ENTITIES, SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED, SIGNAL_ATTR_UPDATED,
) )
from .core.helpers import get_zha_data from .core.helpers import get_zha_data, validate_device_class, validate_unit
from .core.registries import ZHA_ENTITIES from .core.registries import ZHA_ENTITIES
from .entity import ZhaEntity from .entity import ZhaEntity
@ -403,7 +403,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity):
Return entity if it is a supported configuration, otherwise return None Return entity if it is a supported configuration, otherwise return None
""" """
cluster_handler = cluster_handlers[0] cluster_handler = cluster_handlers[0]
if QUIRK_METADATA not in kwargs and ( if ENTITY_METADATA not in kwargs and (
cls._attribute_name in cluster_handler.cluster.unsupported_attributes cls._attribute_name in cluster_handler.cluster.unsupported_attributes
or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cls._attribute_name not in cluster_handler.cluster.attributes_by_name
or cluster_handler.cluster.get(cls._attribute_name) is None or cluster_handler.cluster.get(cls._attribute_name) is None
@ -426,26 +426,34 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity):
) -> None: ) -> None:
"""Init this number configuration entity.""" """Init this number configuration entity."""
self._cluster_handler: ClusterHandler = cluster_handlers[0] self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs: if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def _init_from_quirks_metadata(self, entity_metadata: NumberMetadata) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata) super()._init_from_quirks_metadata(entity_metadata)
number_metadata: NumberMetadata = entity_metadata.entity_metadata self._attribute_name = entity_metadata.attribute_name
self._attribute_name = number_metadata.attribute_name
if number_metadata.min is not None: if entity_metadata.min is not None:
self._attr_native_min_value = number_metadata.min self._attr_native_min_value = entity_metadata.min
if number_metadata.max is not None: if entity_metadata.max is not None:
self._attr_native_max_value = number_metadata.max self._attr_native_max_value = entity_metadata.max
if number_metadata.step is not None: if entity_metadata.step is not None:
self._attr_native_step = number_metadata.step self._attr_native_step = entity_metadata.step
if number_metadata.unit is not None: if entity_metadata.multiplier is not None:
self._attr_native_unit_of_measurement = number_metadata.unit self._attr_multiplier = entity_metadata.multiplier
if number_metadata.multiplier is not None: if entity_metadata.device_class is not None:
self._attr_multiplier = number_metadata.multiplier self._attr_device_class = validate_device_class(
NumberDeviceClass,
entity_metadata.device_class,
Platform.NUMBER.value,
_LOGGER,
)
if entity_metadata.device_class is None and entity_metadata.unit is not None:
self._attr_native_unit_of_measurement = validate_unit(
entity_metadata.unit
).value
@property @property
def native_value(self) -> float: def native_value(self) -> float:

View File

@ -11,7 +11,7 @@ from zhaquirks.quirk_ids import TUYA_PLUG_MANUFACTURER, TUYA_PLUG_ONOFF
from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster from zhaquirks.xiaomi.aqara.magnet_ac01 import OppleCluster as MagnetAC01OppleCluster
from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster from zhaquirks.xiaomi.aqara.switch_acn047 import OppleCluster as T2RelayOppleCluster
from zigpy import types from zigpy import types
from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata from zigpy.quirks.v2 import ZCLEnumMetadata
from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.clusters.security import IasWd from zigpy.zcl.clusters.security import IasWd
@ -29,7 +29,7 @@ from .core.const import (
CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_INOVELLI,
CLUSTER_HANDLER_OCCUPANCY, CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ON_OFF,
QUIRK_METADATA, ENTITY_METADATA,
SIGNAL_ADD_ENTITIES, SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED, SIGNAL_ATTR_UPDATED,
Strobe, Strobe,
@ -179,7 +179,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity):
Return entity if it is a supported configuration, otherwise return None Return entity if it is a supported configuration, otherwise return None
""" """
cluster_handler = cluster_handlers[0] cluster_handler = cluster_handlers[0]
if QUIRK_METADATA not in kwargs and ( if ENTITY_METADATA not in kwargs and (
cls._attribute_name in cluster_handler.cluster.unsupported_attributes cls._attribute_name in cluster_handler.cluster.unsupported_attributes
or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cls._attribute_name not in cluster_handler.cluster.attributes_by_name
or cluster_handler.cluster.get(cls._attribute_name) is None or cluster_handler.cluster.get(cls._attribute_name) is None
@ -202,17 +202,16 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity):
) -> None: ) -> None:
"""Init this select entity.""" """Init this select entity."""
self._cluster_handler: ClusterHandler = cluster_handlers[0] self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs: if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
self._attr_options = [entry.name.replace("_", " ") for entry in self._enum] self._attr_options = [entry.name.replace("_", " ") for entry in self._enum]
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def _init_from_quirks_metadata(self, entity_metadata: ZCLEnumMetadata) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata) super()._init_from_quirks_metadata(entity_metadata)
zcl_enum_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata self._attribute_name = entity_metadata.attribute_name
self._attribute_name = zcl_enum_metadata.attribute_name self._enum = entity_metadata.enum
self._enum = zcl_enum_metadata.enum
@property @property
def current_option(self) -> str | None: def current_option(self) -> str | None:

View File

@ -13,7 +13,7 @@ import random
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
from zigpy import types from zigpy import types
from zigpy.quirks.v2 import EntityMetadata, ZCLEnumMetadata, ZCLSensorMetadata from zigpy.quirks.v2 import ZCLEnumMetadata, ZCLSensorMetadata
from zigpy.state import Counter, State from zigpy.state import Counter, State
from zigpy.zcl.clusters.closures import WindowCovering from zigpy.zcl.clusters.closures import WindowCovering
from zigpy.zcl.clusters.general import Basic from zigpy.zcl.clusters.general import Basic
@ -71,11 +71,11 @@ from .core.const import (
CLUSTER_HANDLER_TEMPERATURE, CLUSTER_HANDLER_TEMPERATURE,
CLUSTER_HANDLER_THERMOSTAT, CLUSTER_HANDLER_THERMOSTAT,
DATA_ZHA, DATA_ZHA,
QUIRK_METADATA, ENTITY_METADATA,
SIGNAL_ADD_ENTITIES, SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED, SIGNAL_ATTR_UPDATED,
) )
from .core.helpers import get_zha_data from .core.helpers import get_zha_data, validate_device_class, validate_unit
from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES from .core.registries import SMARTTHINGS_HUMIDITY_CLUSTER, ZHA_ENTITIES
from .entity import BaseZhaEntity, ZhaEntity from .entity import BaseZhaEntity, ZhaEntity
@ -154,7 +154,7 @@ class Sensor(ZhaEntity, SensorEntity):
Return entity if it is a supported configuration, otherwise return None Return entity if it is a supported configuration, otherwise return None
""" """
cluster_handler = cluster_handlers[0] cluster_handler = cluster_handlers[0]
if QUIRK_METADATA not in kwargs and ( if ENTITY_METADATA not in kwargs and (
cls._attribute_name in cluster_handler.cluster.unsupported_attributes cls._attribute_name in cluster_handler.cluster.unsupported_attributes
or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cls._attribute_name not in cluster_handler.cluster.attributes_by_name
): ):
@ -176,21 +176,29 @@ class Sensor(ZhaEntity, SensorEntity):
) -> None: ) -> None:
"""Init this sensor.""" """Init this sensor."""
self._cluster_handler: ClusterHandler = cluster_handlers[0] self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs: if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def _init_from_quirks_metadata(self, entity_metadata: ZCLSensorMetadata) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata) super()._init_from_quirks_metadata(entity_metadata)
sensor_metadata: ZCLSensorMetadata = entity_metadata.entity_metadata self._attribute_name = entity_metadata.attribute_name
self._attribute_name = sensor_metadata.attribute_name if entity_metadata.divisor is not None:
if sensor_metadata.divisor is not None: self._divisor = entity_metadata.divisor
self._divisor = sensor_metadata.divisor if entity_metadata.multiplier is not None:
if sensor_metadata.multiplier is not None: self._multiplier = entity_metadata.multiplier
self._multiplier = sensor_metadata.multiplier if entity_metadata.device_class is not None:
if sensor_metadata.unit is not None: self._attr_device_class = validate_device_class(
self._attr_native_unit_of_measurement = sensor_metadata.unit SensorDeviceClass,
entity_metadata.device_class,
Platform.SENSOR.value,
_LOGGER,
)
if entity_metadata.device_class is None and entity_metadata.unit is not None:
self._attr_native_unit_of_measurement = validate_unit(
entity_metadata.unit
).value
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass.""" """Run when about to be added to hass."""
@ -355,12 +363,22 @@ class EnumSensor(Sensor):
_attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM _attr_device_class: SensorDeviceClass = SensorDeviceClass.ENUM
_enum: type[enum.Enum] _enum: type[enum.Enum]
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def __init__(
self,
unique_id: str,
zha_device: ZHADevice,
cluster_handlers: list[ClusterHandler],
**kwargs: Any,
) -> None:
"""Init this sensor."""
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
self._attr_options = [e.name for e in self._enum]
def _init_from_quirks_metadata(self, entity_metadata: ZCLEnumMetadata) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access
sensor_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata self._attribute_name = entity_metadata.attribute_name
self._attribute_name = sensor_metadata.attribute_name self._enum = entity_metadata.enum
self._enum = sensor_metadata.enum
def formatter(self, value: int) -> str | None: def formatter(self, value: int) -> str | None:
"""Use name of enum.""" """Use name of enum."""

View File

@ -7,7 +7,7 @@ import logging
from typing import TYPE_CHECKING, Any, Self from typing import TYPE_CHECKING, Any, Self
from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF
from zigpy.quirks.v2 import EntityMetadata, SwitchMetadata from zigpy.quirks.v2 import SwitchMetadata
from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode
from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.foundation import Status from zigpy.zcl.foundation import Status
@ -25,7 +25,7 @@ from .core.const import (
CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_COVER,
CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_INOVELLI,
CLUSTER_HANDLER_ON_OFF, CLUSTER_HANDLER_ON_OFF,
QUIRK_METADATA, ENTITY_METADATA,
SIGNAL_ADD_ENTITIES, SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED, SIGNAL_ATTR_UPDATED,
) )
@ -192,7 +192,7 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity):
Return entity if it is a supported configuration, otherwise return None Return entity if it is a supported configuration, otherwise return None
""" """
cluster_handler = cluster_handlers[0] cluster_handler = cluster_handlers[0]
if QUIRK_METADATA not in kwargs and ( if ENTITY_METADATA not in kwargs and (
cls._attribute_name in cluster_handler.cluster.unsupported_attributes cls._attribute_name in cluster_handler.cluster.unsupported_attributes
or cls._attribute_name not in cluster_handler.cluster.attributes_by_name or cls._attribute_name not in cluster_handler.cluster.attributes_by_name
or cluster_handler.cluster.get(cls._attribute_name) is None or cluster_handler.cluster.get(cls._attribute_name) is None
@ -215,21 +215,20 @@ class ZHASwitchConfigurationEntity(ZhaEntity, SwitchEntity):
) -> None: ) -> None:
"""Init this number configuration entity.""" """Init this number configuration entity."""
self._cluster_handler: ClusterHandler = cluster_handlers[0] self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs: if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA]) self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
super().__init__(unique_id, zha_device, cluster_handlers, **kwargs) super().__init__(unique_id, zha_device, cluster_handlers, **kwargs)
def _init_from_quirks_metadata(self, entity_metadata: EntityMetadata) -> None: def _init_from_quirks_metadata(self, entity_metadata: SwitchMetadata) -> None:
"""Init this entity from the quirks metadata.""" """Init this entity from the quirks metadata."""
super()._init_from_quirks_metadata(entity_metadata) super()._init_from_quirks_metadata(entity_metadata)
switch_metadata: SwitchMetadata = entity_metadata.entity_metadata self._attribute_name = entity_metadata.attribute_name
self._attribute_name = switch_metadata.attribute_name if entity_metadata.invert_attribute_name:
if switch_metadata.invert_attribute_name: self._inverter_attribute_name = entity_metadata.invert_attribute_name
self._inverter_attribute_name = switch_metadata.invert_attribute_name if entity_metadata.force_inverted:
if switch_metadata.force_inverted: self._force_inverted = entity_metadata.force_inverted
self._force_inverted = switch_metadata.force_inverted self._off_value = entity_metadata.off_value
self._off_value = switch_metadata.off_value self._on_value = entity_metadata.on_value
self._on_value = switch_metadata.on_value
async def async_added_to_hass(self) -> None: async def async_added_to_hass(self) -> None:
"""Run when about to be added to hass.""" """Run when about to be added to hass."""

View File

@ -2952,7 +2952,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.1 zigpy-znp==0.12.1
# homeassistant.components.zha # homeassistant.components.zha
zigpy==0.63.4 zigpy==0.63.5
# homeassistant.components.zoneminder # homeassistant.components.zoneminder
zm-py==0.5.4 zm-py==0.5.4

View File

@ -2281,7 +2281,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.1 zigpy-znp==0.12.1
# homeassistant.components.zha # homeassistant.components.zha
zigpy==0.63.4 zigpy==0.63.5
# homeassistant.components.zwave_js # homeassistant.components.zwave_js
zwave-js-server-python==0.55.3 zwave-js-server-python==0.55.3

View File

@ -1,6 +1,8 @@
"""Test ZHA device discovery.""" """Test ZHA device discovery."""
from collections.abc import Callable from collections.abc import Callable
import enum
import itertools
import re import re
from typing import Any from typing import Any
from unittest import mock from unittest import mock
@ -20,7 +22,16 @@ from zhaquirks.xiaomi.aqara.driver_curtain_e1 import (
from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC from zigpy.const import SIG_ENDPOINTS, SIG_MANUFACTURER, SIG_MODEL, SIG_NODE_DESC
import zigpy.profiles.zha import zigpy.profiles.zha
import zigpy.quirks import zigpy.quirks
from zigpy.quirks.v2 import EntityType, add_to_registry_v2 from zigpy.quirks.v2 import (
BinarySensorMetadata,
EntityMetadata,
EntityType,
NumberMetadata,
QuirksV2RegistryEntry,
ZCLCommandButtonMetadata,
ZCLSensorMetadata,
add_to_registry_v2,
)
from zigpy.quirks.v2.homeassistant import UnitOfTime from zigpy.quirks.v2.homeassistant import UnitOfTime
import zigpy.types import zigpy.types
from zigpy.zcl import ClusterType from zigpy.zcl import ClusterType
@ -40,6 +51,7 @@ from homeassistant.const import STATE_OFF, Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.util.json import load_json
from .common import find_entity_id, update_attribute_cache from .common import find_entity_id, update_attribute_cache
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
@ -520,6 +532,7 @@ async def test_quirks_v2_entity_discovery(
step=1, step=1,
unit=UnitOfTime.SECONDS, unit=UnitOfTime.SECONDS,
multiplier=1, multiplier=1,
translation_key="on_off_transition_time",
) )
) )
@ -618,7 +631,11 @@ async def test_quirks_v2_entity_discovery_e1_curtain(
entity_platform=Platform.SENSOR, entity_platform=Platform.SENSOR,
entity_type=EntityType.DIAGNOSTIC, entity_type=EntityType.DIAGNOSTIC,
) )
.binary_sensor("error_detected", FakeXiaomiAqaraDriverE1.cluster_id) .binary_sensor(
"error_detected",
FakeXiaomiAqaraDriverE1.cluster_id,
translation_key="valve_alarm",
)
) )
aqara_E1_device = zigpy.quirks._DEVICE_REGISTRY.get_device(aqara_E1_device) aqara_E1_device = zigpy.quirks._DEVICE_REGISTRY.get_device(aqara_E1_device)
@ -683,7 +700,13 @@ async def test_quirks_v2_entity_discovery_e1_curtain(
assert state.state == STATE_OFF assert state.state == STATE_OFF
def _get_test_device(zigpy_device_mock, manufacturer: str, model: str): def _get_test_device(
zigpy_device_mock,
manufacturer: str,
model: str,
augment_method: Callable[[QuirksV2RegistryEntry], QuirksV2RegistryEntry]
| None = None,
):
zigpy_device = zigpy_device_mock( zigpy_device = zigpy_device_mock(
{ {
1: { 1: {
@ -703,7 +726,7 @@ def _get_test_device(zigpy_device_mock, manufacturer: str, model: str):
model=model, model=model,
) )
( v2_quirk = (
add_to_registry_v2(manufacturer, model, zigpy.quirks._DEVICE_REGISTRY) add_to_registry_v2(manufacturer, model, zigpy.quirks._DEVICE_REGISTRY)
.replaces(PowerConfig1CRCluster) .replaces(PowerConfig1CRCluster)
.replaces(ScenesCluster, cluster_type=ClusterType.Client) .replaces(ScenesCluster, cluster_type=ClusterType.Client)
@ -716,6 +739,7 @@ def _get_test_device(zigpy_device_mock, manufacturer: str, model: str):
step=1, step=1,
unit=UnitOfTime.SECONDS, unit=UnitOfTime.SECONDS,
multiplier=1, multiplier=1,
translation_key="on_off_transition_time",
) )
.number( .number(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name,
@ -725,14 +749,19 @@ def _get_test_device(zigpy_device_mock, manufacturer: str, model: str):
step=1, step=1,
unit=UnitOfTime.SECONDS, unit=UnitOfTime.SECONDS,
multiplier=1, multiplier=1,
translation_key="on_off_transition_time",
) )
.sensor( .sensor(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name, zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name,
zigpy.zcl.clusters.general.OnOff.cluster_id, zigpy.zcl.clusters.general.OnOff.cluster_id,
entity_type=EntityType.CONFIG, entity_type=EntityType.CONFIG,
translation_key="analog_input",
) )
) )
if augment_method:
v2_quirk = augment_method(v2_quirk)
zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device)
zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = { zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = {
"battery_voltage": 3, "battery_voltage": 3,
@ -792,14 +821,13 @@ async def test_quirks_v2_entity_discovery_errors(
# fmt: off # fmt: off
entity_details = ( entity_details = (
"{'cluster_details': (1, 6, <ClusterType.Server: 0>), " "{'cluster_details': (1, 6, <ClusterType.Server: 0>), 'entity_metadata': "
"'quirk_metadata': EntityMetadata(entity_metadata=ZCLSensorMetadata(" "ZCLSensorMetadata(entity_platform=<EntityPlatform.SENSOR: 'sensor'>, "
"attribute_name='off_wait_time', divisor=1, multiplier=1, unit=None, " "entity_type=<EntityType.CONFIG: 'config'>, cluster_id=6, endpoint_id=1, "
"device_class=None, state_class=None), entity_platform=<EntityPlatform." "cluster_type=<ClusterType.Server: 0>, initially_disabled=False, "
"SENSOR: 'sensor'>, entity_type=<EntityType.CONFIG: 'config'>, " "attribute_initialized_from_cache=True, translation_key='analog_input', "
"cluster_id=6, endpoint_id=1, cluster_type=<ClusterType.Server: 0>, " "attribute_name='off_wait_time', divisor=1, multiplier=1, "
"initially_disabled=False, attribute_initialized_from_cache=True, " "unit=None, device_class=None, state_class=None)}"
"translation_key=None)}"
) )
# fmt: on # fmt: on
@ -807,3 +835,266 @@ async def test_quirks_v2_entity_discovery_errors(
m2 = f"details: {entity_details} that does not have an entity class mapping - " m2 = f"details: {entity_details} that does not have an entity class mapping - "
m3 = "unable to create entity" m3 = "unable to create entity"
assert f"{m1}{m2}{m3}" in caplog.text assert f"{m1}{m2}{m3}" in caplog.text
DEVICE_CLASS_TYPES = [NumberMetadata, BinarySensorMetadata, ZCLSensorMetadata]
def validate_device_class_unit(
quirk: QuirksV2RegistryEntry,
entity_metadata: EntityMetadata,
platform: Platform,
translations: dict,
) -> None:
"""Ensure device class and unit are used correctly."""
if (
hasattr(entity_metadata, "unit")
and entity_metadata.unit is not None
and hasattr(entity_metadata, "device_class")
and entity_metadata.device_class is not None
):
m1 = "device_class and unit are both set - unit: "
m2 = f"{entity_metadata.unit} device_class: "
m3 = f"{entity_metadata.device_class} for {platform.name} "
raise ValueError(f"{m1}{m2}{m3}{quirk}")
def validate_translation_keys(
quirk: QuirksV2RegistryEntry,
entity_metadata: EntityMetadata,
platform: Platform,
translations: dict,
) -> None:
"""Ensure translation keys exist for all v2 quirks."""
if isinstance(entity_metadata, ZCLCommandButtonMetadata):
default_translation_key = entity_metadata.command_name
else:
default_translation_key = entity_metadata.attribute_name
translation_key = entity_metadata.translation_key or default_translation_key
if (
translation_key is not None
and translation_key not in translations["entity"][platform]
):
raise ValueError(
f"Missing translation key: {translation_key} for {platform.name} {quirk}"
)
def validate_translation_keys_device_class(
quirk: QuirksV2RegistryEntry,
entity_metadata: EntityMetadata,
platform: Platform,
translations: dict,
) -> None:
"""Validate translation keys and device class usage."""
if isinstance(entity_metadata, ZCLCommandButtonMetadata):
default_translation_key = entity_metadata.command_name
else:
default_translation_key = entity_metadata.attribute_name
translation_key = entity_metadata.translation_key or default_translation_key
metadata_type = type(entity_metadata)
if metadata_type in DEVICE_CLASS_TYPES:
device_class = entity_metadata.device_class
if device_class is not None and translation_key is not None:
m1 = "translation_key and device_class are both set - translation_key: "
m2 = f"{translation_key} device_class: {device_class} for {platform.name} "
raise ValueError(f"{m1}{m2}{quirk}")
def validate_metadata(validator: Callable) -> None:
"""Ensure v2 quirks metadata does not violate HA rules."""
all_v2_quirks = itertools.chain.from_iterable(
zigpy.quirks._DEVICE_REGISTRY._registry_v2.values()
)
translations = load_json("homeassistant/components/zha/strings.json")
for quirk in all_v2_quirks:
for entity_metadata in quirk.entity_metadata:
platform = Platform(entity_metadata.entity_platform.value)
validator(quirk, entity_metadata, platform, translations)
def bad_translation_key(v2_quirk: QuirksV2RegistryEntry) -> QuirksV2RegistryEntry:
"""Introduce a bad translation key."""
return v2_quirk.sensor(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name,
zigpy.zcl.clusters.general.OnOff.cluster_id,
entity_type=EntityType.CONFIG,
translation_key="missing_translation_key",
)
def bad_device_class_unit_combination(
v2_quirk: QuirksV2RegistryEntry,
) -> QuirksV2RegistryEntry:
"""Introduce a bad device class and unit combination."""
return v2_quirk.sensor(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name,
zigpy.zcl.clusters.general.OnOff.cluster_id,
entity_type=EntityType.CONFIG,
unit="invalid",
device_class="invalid",
translation_key="analog_input",
)
def bad_device_class_translation_key_usage(
v2_quirk: QuirksV2RegistryEntry,
) -> QuirksV2RegistryEntry:
"""Introduce a bad device class and translation key combination."""
return v2_quirk.sensor(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name,
zigpy.zcl.clusters.general.OnOff.cluster_id,
entity_type=EntityType.CONFIG,
translation_key="invalid",
device_class="invalid",
)
@pytest.mark.parametrize(
("augment_method", "validate_method", "expected_exception_string"),
[
(
bad_translation_key,
validate_translation_keys,
"Missing translation key: missing_translation_key",
),
(
bad_device_class_unit_combination,
validate_device_class_unit,
"cannot have both unit and device_class",
),
(
bad_device_class_translation_key_usage,
validate_translation_keys_device_class,
"cannot have both a translation_key and a device_class",
),
],
)
async def test_quirks_v2_metadata_errors(
hass: HomeAssistant,
zigpy_device_mock,
zha_device_joined,
augment_method: Callable[[QuirksV2RegistryEntry], QuirksV2RegistryEntry],
validate_method: Callable,
expected_exception_string: str,
) -> None:
"""Ensure all v2 quirks translation keys exist."""
# no error yet
validate_metadata(validate_method)
# ensure the error is caught and raised
with pytest.raises(ValueError, match=expected_exception_string):
try:
# introduce an error
zigpy_device = _get_test_device(
zigpy_device_mock,
"Ikea of Sweden4",
"TRADFRI remote control4",
augment_method=augment_method,
)
await zha_device_joined(zigpy_device)
validate_metadata(validate_method)
# if the device was created we remove it
# so we don't pollute the rest of the tests
zigpy.quirks._DEVICE_REGISTRY.remove(zigpy_device)
except ValueError as e:
# if the device was not created we remove it
# so we don't pollute the rest of the tests
zigpy.quirks._DEVICE_REGISTRY._registry_v2.pop(
(
"Ikea of Sweden4",
"TRADFRI remote control4",
)
)
raise e
class BadDeviceClass(enum.Enum):
"""Bad device class."""
BAD = "bad"
def bad_binary_sensor_device_class(
v2_quirk: QuirksV2RegistryEntry,
) -> QuirksV2RegistryEntry:
"""Introduce a bad device class on a binary sensor."""
return v2_quirk.binary_sensor(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.on_off.name,
zigpy.zcl.clusters.general.OnOff.cluster_id,
device_class=BadDeviceClass.BAD,
)
def bad_sensor_device_class(
v2_quirk: QuirksV2RegistryEntry,
) -> QuirksV2RegistryEntry:
"""Introduce a bad device class on a sensor."""
return v2_quirk.sensor(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name,
zigpy.zcl.clusters.general.OnOff.cluster_id,
device_class=BadDeviceClass.BAD,
)
def bad_number_device_class(
v2_quirk: QuirksV2RegistryEntry,
) -> QuirksV2RegistryEntry:
"""Introduce a bad device class on a number."""
return v2_quirk.number(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.on_time.name,
zigpy.zcl.clusters.general.OnOff.cluster_id,
device_class=BadDeviceClass.BAD,
)
ERROR_ROOT = "Quirks provided an invalid device class"
@pytest.mark.parametrize(
("augment_method", "expected_exception_string"),
[
(
bad_binary_sensor_device_class,
f"{ERROR_ROOT}: BadDeviceClass.BAD for platform binary_sensor",
),
(
bad_sensor_device_class,
f"{ERROR_ROOT}: BadDeviceClass.BAD for platform sensor",
),
(
bad_number_device_class,
f"{ERROR_ROOT}: BadDeviceClass.BAD for platform number",
),
],
)
async def test_quirks_v2_metadata_bad_device_classes(
hass: HomeAssistant,
zigpy_device_mock,
zha_device_joined,
caplog: pytest.LogCaptureFixture,
augment_method: Callable[[QuirksV2RegistryEntry], QuirksV2RegistryEntry],
expected_exception_string: str,
) -> None:
"""Test bad quirks v2 device classes."""
# introduce an error
zigpy_device = _get_test_device(
zigpy_device_mock,
"Ikea of Sweden4",
"TRADFRI remote control4",
augment_method=augment_method,
)
await zha_device_joined(zigpy_device)
assert expected_exception_string in caplog.text
# remove the device so we don't pollute the rest of the tests
zigpy.quirks._DEVICE_REGISTRY.remove(zigpy_device)

View File

@ -1,11 +1,13 @@
"""Tests for ZHA helpers.""" """Tests for ZHA helpers."""
import enum
import logging import logging
from unittest.mock import patch from unittest.mock import patch
import pytest import pytest
import voluptuous_serialize import voluptuous_serialize
import zigpy.profiles.zha as zha import zigpy.profiles.zha as zha
from zigpy.quirks.v2.homeassistant import UnitOfPower as QuirksUnitOfPower
from zigpy.types.basic import uint16_t from zigpy.types.basic import uint16_t
import zigpy.zcl.clusters.general as general import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.lighting as lighting import zigpy.zcl.clusters.lighting as lighting
@ -13,8 +15,9 @@ import zigpy.zcl.clusters.lighting as lighting
from homeassistant.components.zha.core.helpers import ( from homeassistant.components.zha.core.helpers import (
cluster_command_schema_to_vol_schema, cluster_command_schema_to_vol_schema,
convert_to_zcl_values, convert_to_zcl_values,
validate_unit,
) )
from homeassistant.const import Platform from homeassistant.const import Platform, UnitOfPower
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -40,7 +43,7 @@ def light_platform_only():
@pytest.fixture @pytest.fixture
async def device_light(hass, zigpy_device_mock, zha_device_joined): async def device_light(hass: HomeAssistant, zigpy_device_mock, zha_device_joined):
"""Test light.""" """Test light."""
zigpy_device = zigpy_device_mock( zigpy_device = zigpy_device_mock(
@ -211,3 +214,25 @@ async def test_zcl_schema_conversions(hass: HomeAssistant, device_light) -> None
# No flags are passed through # No flags are passed through
assert converted_data["update_flags"] == 0 assert converted_data["update_flags"] == 0
def test_unit_validation() -> None:
"""Test unit validation."""
assert validate_unit(QuirksUnitOfPower.WATT) == UnitOfPower.WATT
class FooUnit(enum.Enum):
"""Foo unit."""
BAR = "bar"
class UnitOfMass(enum.Enum):
"""UnitOfMass."""
BAR = "bar"
with pytest.raises(KeyError):
validate_unit(FooUnit.BAR)
with pytest.raises(ValueError):
validate_unit(UnitOfMass.BAR)

View File

@ -435,7 +435,7 @@ async def test_on_off_select_attribute_report(
"motion_sensitivity_disabled", "motion_sensitivity_disabled",
AqaraMotionSensitivities, AqaraMotionSensitivities,
MotionSensitivityQuirk.OppleCluster.cluster_id, MotionSensitivityQuirk.OppleCluster.cluster_id,
translation_key="motion_sensitivity_translation_key", translation_key="motion_sensitivity",
initially_disabled=True, initially_disabled=True,
) )
) )
@ -491,9 +491,8 @@ async def test_on_off_select_attribute_report_v2(
assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name assert hass.states.get(entity_id).state == AqaraMotionSensitivities.Low.name
entity_registry = er.async_get(hass) entity_registry = er.async_get(hass)
# none in id because the translation key does not exist entity_entry = entity_registry.async_get(entity_id)
entity_entry = entity_registry.async_get("select.fake_manufacturer_fake_model_none")
assert entity_entry assert entity_entry
assert entity_entry.entity_category == EntityCategory.CONFIG assert entity_entry.entity_category == EntityCategory.CONFIG
assert entity_entry.disabled is True assert entity_entry.disabled is False
assert entity_entry.translation_key == "motion_sensitivity_translation_key" assert entity_entry.translation_key == "motion_sensitivity"

View File

@ -1260,10 +1260,10 @@ async def test_last_feeding_size_sensor_v2(
assert entity_id is not None assert entity_id is not None
await send_attributes_report(hass, cluster, {0x010C: 1}) await send_attributes_report(hass, cluster, {0x010C: 1})
assert_state(hass, entity_id, "1.0", UnitOfMass.GRAMS) assert_state(hass, entity_id, "1.0", UnitOfMass.GRAMS.value)
await send_attributes_report(hass, cluster, {0x010C: 5}) await send_attributes_report(hass, cluster, {0x010C: 5})
assert_state(hass, entity_id, "5.0", UnitOfMass.GRAMS) assert_state(hass, entity_id, "5.0", UnitOfMass.GRAMS.value)
@pytest.fixture @pytest.fixture