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

pull/114311/head
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
import functools
import logging
from zigpy.quirks.v2 import BinarySensorMetadata, EntityMetadata
from zigpy.quirks.v2 import BinarySensorMetadata
import zigpy.types as t
from zigpy.zcl.clusters.general import OnOff
from zigpy.zcl.clusters.security import IasZone
@ -27,11 +28,11 @@ from .core.const import (
CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF,
CLUSTER_HANDLER_ZONE,
QUIRK_METADATA,
ENTITY_METADATA,
SIGNAL_ADD_ENTITIES,
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 .entity import ZhaEntity
@ -51,6 +52,8 @@ CONFIG_DIAGNOSTIC_MATCH = functools.partial(
ZHA_ENTITIES.config_diagnostic_match, Platform.BINARY_SENSOR
)
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
@ -79,15 +82,21 @@ class BinarySensor(ZhaEntity, BinarySensorEntity):
def __init__(self, unique_id, zha_device, cluster_handlers, **kwargs) -> None:
"""Initialize the ZHA binary sensor."""
self._cluster_handler = cluster_handlers[0]
if QUIRK_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA])
if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
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."""
super()._init_from_quirks_metadata(entity_metadata)
binary_sensor_metadata: BinarySensorMetadata = entity_metadata.entity_metadata
self._attribute_name = binary_sensor_metadata.attribute_name
self._attribute_name = entity_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:
"""Run when about to be added to hass."""

View File

@ -6,11 +6,7 @@ import functools
import logging
from typing import TYPE_CHECKING, Any, Self
from zigpy.quirks.v2 import (
EntityMetadata,
WriteAttributeButtonMetadata,
ZCLCommandButtonMetadata,
)
from zigpy.quirks.v2 import WriteAttributeButtonMetadata, ZCLCommandButtonMetadata
from homeassistant.components.button import ButtonDeviceClass, ButtonEntity
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 .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.registries import ZHA_ENTITIES
from .entity import ZhaEntity
@ -76,17 +72,18 @@ class ZHAButton(ZhaEntity, ButtonEntity):
) -> None:
"""Init this button."""
self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA])
if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
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."""
super()._init_from_quirks_metadata(entity_metadata)
button_metadata: ZCLCommandButtonMetadata = entity_metadata.entity_metadata
self._command_name = button_metadata.command_name
self._args = button_metadata.args
self._kwargs = button_metadata.kwargs
self._command_name = entity_metadata.command_name
self._args = entity_metadata.args
self._kwargs = entity_metadata.kwargs
def get_args(self) -> list[Any]:
"""Return the arguments to use in the command."""
@ -148,16 +145,17 @@ class ZHAAttributeButton(ZhaEntity, ButtonEntity):
) -> None:
"""Init this button."""
self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA])
if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
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."""
super()._init_from_quirks_metadata(entity_metadata)
button_metadata: WriteAttributeButtonMetadata = entity_metadata.entity_metadata
self._attribute_name = button_metadata.attribute_name
self._attribute_value = button_metadata.attribute_value
self._attribute_name = entity_metadata.attribute_name
self._attribute_value = entity_metadata.attribute_value
async def async_press(self) -> None:
"""Write attribute with defined value."""

View File

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

View File

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

View File

@ -14,7 +14,7 @@ from dataclasses import dataclass
import enum
import logging
import re
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar
from typing import TYPE_CHECKING, Any, ParamSpec, TypeVar, overload
import voluptuous as vol
import zigpy.exceptions
@ -24,8 +24,33 @@ import zigpy.zcl
from zigpy.zcl.foundation import CommandSchema
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.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.helpers import config_validation as cv, device_registry as dr
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."""
zha_gateway = get_zha_gateway(hass)
zha_devices = zha_gateway.devices.values()
@ -424,3 +449,80 @@ def get_zha_gateway(hass: HomeAssistant) -> ZHAGateway:
raise ValueError("No gateway object exists")
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:
self._attr_entity_registry_enabled_default = False
if entity_metadata.translation_key:
self._attr_translation_key = entity_metadata.translation_key
if hasattr(entity_metadata.entity_metadata, "attribute_name"):
if not entity_metadata.translation_key:
self._attr_translation_key = (
entity_metadata.entity_metadata.attribute_name
)
self._unique_id_suffix = entity_metadata.entity_metadata.attribute_name
elif hasattr(entity_metadata.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
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:
self._attr_translation_key = entity_metadata.translation_key
elif has_attribute_name:
self._attr_translation_key = entity_metadata.attribute_name
elif has_command_name:
self._attr_translation_key = entity_metadata.command_name
if has_attribute_name:
self._unique_id_suffix = entity_metadata.attribute_name
elif has_command_name:
self._unique_id_suffix = entity_metadata.command_name
if entity_metadata.entity_type is EntityType.CONFIG:
self._attr_entity_category = EntityCategory.CONFIG
elif entity_metadata.entity_type is EntityType.DIAGNOSTIC:
self._attr_entity_category = EntityCategory.DIAGNOSTIC
else:
self._attr_entity_category = None
@property
def available(self) -> bool:

View File

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

View File

@ -6,10 +6,10 @@ import functools
import logging
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 homeassistant.components.number import NumberEntity, NumberMode
from homeassistant.components.number import NumberDeviceClass, NumberEntity, NumberMode
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform, UnitOfMass, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
@ -26,11 +26,11 @@ from .core.const import (
CLUSTER_HANDLER_LEVEL,
CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_THERMOSTAT,
QUIRK_METADATA,
ENTITY_METADATA,
SIGNAL_ADD_ENTITIES,
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 .entity import ZhaEntity
@ -403,7 +403,7 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity):
Return entity if it is a supported configuration, otherwise return None
"""
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
or cls._attribute_name not in cluster_handler.cluster.attributes_by_name
or cluster_handler.cluster.get(cls._attribute_name) is None
@ -426,26 +426,34 @@ class ZHANumberConfigurationEntity(ZhaEntity, NumberEntity):
) -> None:
"""Init this number configuration entity."""
self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA])
if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
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."""
super()._init_from_quirks_metadata(entity_metadata)
number_metadata: NumberMetadata = entity_metadata.entity_metadata
self._attribute_name = number_metadata.attribute_name
self._attribute_name = entity_metadata.attribute_name
if number_metadata.min is not None:
self._attr_native_min_value = number_metadata.min
if number_metadata.max is not None:
self._attr_native_max_value = number_metadata.max
if number_metadata.step is not None:
self._attr_native_step = number_metadata.step
if number_metadata.unit is not None:
self._attr_native_unit_of_measurement = number_metadata.unit
if number_metadata.multiplier is not None:
self._attr_multiplier = number_metadata.multiplier
if entity_metadata.min is not None:
self._attr_native_min_value = entity_metadata.min
if entity_metadata.max is not None:
self._attr_native_max_value = entity_metadata.max
if entity_metadata.step is not None:
self._attr_native_step = entity_metadata.step
if entity_metadata.multiplier is not None:
self._attr_multiplier = entity_metadata.multiplier
if entity_metadata.device_class is not None:
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
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.switch_acn047 import OppleCluster as T2RelayOppleCluster
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.security import IasWd
@ -29,7 +29,7 @@ from .core.const import (
CLUSTER_HANDLER_INOVELLI,
CLUSTER_HANDLER_OCCUPANCY,
CLUSTER_HANDLER_ON_OFF,
QUIRK_METADATA,
ENTITY_METADATA,
SIGNAL_ADD_ENTITIES,
SIGNAL_ATTR_UPDATED,
Strobe,
@ -179,7 +179,7 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity):
Return entity if it is a supported configuration, otherwise return None
"""
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
or cls._attribute_name not in cluster_handler.cluster.attributes_by_name
or cluster_handler.cluster.get(cls._attribute_name) is None
@ -202,17 +202,16 @@ class ZCLEnumSelectEntity(ZhaEntity, SelectEntity):
) -> None:
"""Init this select entity."""
self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA])
if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
self._attr_options = [entry.name.replace("_", " ") for entry in self._enum]
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."""
super()._init_from_quirks_metadata(entity_metadata)
zcl_enum_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata
self._attribute_name = zcl_enum_metadata.attribute_name
self._enum = zcl_enum_metadata.enum
self._attribute_name = entity_metadata.attribute_name
self._enum = entity_metadata.enum
@property
def current_option(self) -> str | None:

View File

@ -13,7 +13,7 @@ import random
from typing import TYPE_CHECKING, Any, Self
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.zcl.clusters.closures import WindowCovering
from zigpy.zcl.clusters.general import Basic
@ -71,11 +71,11 @@ from .core.const import (
CLUSTER_HANDLER_TEMPERATURE,
CLUSTER_HANDLER_THERMOSTAT,
DATA_ZHA,
QUIRK_METADATA,
ENTITY_METADATA,
SIGNAL_ADD_ENTITIES,
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 .entity import BaseZhaEntity, ZhaEntity
@ -154,7 +154,7 @@ class Sensor(ZhaEntity, SensorEntity):
Return entity if it is a supported configuration, otherwise return None
"""
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
or cls._attribute_name not in cluster_handler.cluster.attributes_by_name
):
@ -176,21 +176,29 @@ class Sensor(ZhaEntity, SensorEntity):
) -> None:
"""Init this sensor."""
self._cluster_handler: ClusterHandler = cluster_handlers[0]
if QUIRK_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[QUIRK_METADATA])
if ENTITY_METADATA in kwargs:
self._init_from_quirks_metadata(kwargs[ENTITY_METADATA])
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."""
super()._init_from_quirks_metadata(entity_metadata)
sensor_metadata: ZCLSensorMetadata = entity_metadata.entity_metadata
self._attribute_name = sensor_metadata.attribute_name
if sensor_metadata.divisor is not None:
self._divisor = sensor_metadata.divisor
if sensor_metadata.multiplier is not None:
self._multiplier = sensor_metadata.multiplier
if sensor_metadata.unit is not None:
self._attr_native_unit_of_measurement = sensor_metadata.unit
self._attribute_name = entity_metadata.attribute_name
if entity_metadata.divisor is not None:
self._divisor = entity_metadata.divisor
if entity_metadata.multiplier is not None:
self._multiplier = entity_metadata.multiplier
if entity_metadata.device_class is not None:
self._attr_device_class = validate_device_class(
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:
"""Run when about to be added to hass."""
@ -355,12 +363,22 @@ class EnumSensor(Sensor):
_attr_device_class: SensorDeviceClass = SensorDeviceClass.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."""
ZhaEntity._init_from_quirks_metadata(self, entity_metadata) # pylint: disable=protected-access
sensor_metadata: ZCLEnumMetadata = entity_metadata.entity_metadata
self._attribute_name = sensor_metadata.attribute_name
self._enum = sensor_metadata.enum
self._attribute_name = entity_metadata.attribute_name
self._enum = entity_metadata.enum
def formatter(self, value: int) -> str | None:
"""Use name of enum."""

View File

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

View File

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

View File

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

View File

@ -1,6 +1,8 @@
"""Test ZHA device discovery."""
from collections.abc import Callable
import enum
import itertools
import re
from typing import Any
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
import zigpy.profiles.zha
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
import zigpy.types
from zigpy.zcl import ClusterType
@ -40,6 +51,7 @@ from homeassistant.const import STATE_OFF, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import EntityPlatform
from homeassistant.util.json import load_json
from .common import find_entity_id, update_attribute_cache
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,
unit=UnitOfTime.SECONDS,
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_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)
@ -683,7 +700,13 @@ async def test_quirks_v2_entity_discovery_e1_curtain(
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(
{
1: {
@ -703,7 +726,7 @@ def _get_test_device(zigpy_device_mock, manufacturer: str, model: str):
model=model,
)
(
v2_quirk = (
add_to_registry_v2(manufacturer, model, zigpy.quirks._DEVICE_REGISTRY)
.replaces(PowerConfig1CRCluster)
.replaces(ScenesCluster, cluster_type=ClusterType.Client)
@ -716,6 +739,7 @@ def _get_test_device(zigpy_device_mock, manufacturer: str, model: str):
step=1,
unit=UnitOfTime.SECONDS,
multiplier=1,
translation_key="on_off_transition_time",
)
.number(
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,
unit=UnitOfTime.SECONDS,
multiplier=1,
translation_key="on_off_transition_time",
)
.sensor(
zigpy.zcl.clusters.general.OnOff.AttributeDefs.off_wait_time.name,
zigpy.zcl.clusters.general.OnOff.cluster_id,
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.endpoints[1].power.PLUGGED_ATTR_READS = {
"battery_voltage": 3,
@ -792,14 +821,13 @@ async def test_quirks_v2_entity_discovery_errors(
# fmt: off
entity_details = (
"{'cluster_details': (1, 6, <ClusterType.Server: 0>), "
"'quirk_metadata': EntityMetadata(entity_metadata=ZCLSensorMetadata("
"attribute_name='off_wait_time', divisor=1, multiplier=1, unit=None, "
"device_class=None, state_class=None), entity_platform=<EntityPlatform."
"SENSOR: 'sensor'>, entity_type=<EntityType.CONFIG: 'config'>, "
"cluster_id=6, endpoint_id=1, cluster_type=<ClusterType.Server: 0>, "
"initially_disabled=False, attribute_initialized_from_cache=True, "
"translation_key=None)}"
"{'cluster_details': (1, 6, <ClusterType.Server: 0>), 'entity_metadata': "
"ZCLSensorMetadata(entity_platform=<EntityPlatform.SENSOR: 'sensor'>, "
"entity_type=<EntityType.CONFIG: 'config'>, cluster_id=6, endpoint_id=1, "
"cluster_type=<ClusterType.Server: 0>, initially_disabled=False, "
"attribute_initialized_from_cache=True, translation_key='analog_input', "
"attribute_name='off_wait_time', divisor=1, multiplier=1, "
"unit=None, device_class=None, state_class=None)}"
)
# 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 - "
m3 = "unable to create entity"
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."""
import enum
import logging
from unittest.mock import patch
import pytest
import voluptuous_serialize
import zigpy.profiles.zha as zha
from zigpy.quirks.v2.homeassistant import UnitOfPower as QuirksUnitOfPower
from zigpy.types.basic import uint16_t
import zigpy.zcl.clusters.general as general
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 (
cluster_command_schema_to_vol_schema,
convert_to_zcl_values,
validate_unit,
)
from homeassistant.const import Platform
from homeassistant.const import Platform, UnitOfPower
from homeassistant.core import HomeAssistant
import homeassistant.helpers.config_validation as cv
@ -40,7 +43,7 @@ def light_platform_only():
@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."""
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
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",
AqaraMotionSensitivities,
MotionSensitivityQuirk.OppleCluster.cluster_id,
translation_key="motion_sensitivity_translation_key",
translation_key="motion_sensitivity",
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
entity_registry = er.async_get(hass)
# none in id because the translation key does not exist
entity_entry = entity_registry.async_get("select.fake_manufacturer_fake_model_none")
entity_entry = entity_registry.async_get(entity_id)
assert entity_entry
assert entity_entry.entity_category == EntityCategory.CONFIG
assert entity_entry.disabled is True
assert entity_entry.translation_key == "motion_sensitivity_translation_key"
assert entity_entry.disabled is False
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
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})
assert_state(hass, entity_id, "5.0", UnitOfMass.GRAMS)
assert_state(hass, entity_id, "5.0", UnitOfMass.GRAMS.value)
@pytest.fixture