diff --git a/custom_components/matter_experimental/adapter.py b/custom_components/matter_experimental/adapter.py index 5736ac69..6f2a9311 100644 --- a/custom_components/matter_experimental/adapter.py +++ b/custom_components/matter_experimental/adapter.py @@ -185,23 +185,25 @@ async def setup_node(self, node: MatterNode) -> None: created = False for platform, devices in DEVICE_PLATFORM.items(): - device_mappings = devices.get(device.device_type) + entity_descriptions = devices.get(device.device_type) - if device_mappings is None: + if entity_descriptions is None: continue - if not isinstance(device_mappings, list): - device_mappings = [device_mappings] + if not isinstance(entity_descriptions, list): + entity_descriptions = [entity_descriptions] entities = [] - for device_mapping in device_mappings: + for entity_description in entity_descriptions: self.logger.debug( "Creating %s entity for %s (%s)", platform, device.device_type.__name__, hex(device.device_type.device_type), ) - entities.append(device_mapping.entity_cls(device, device_mapping)) + entities.append( + entity_description.entity_cls(device, entity_description) + ) self.platform_handlers[platform](entities) created = True diff --git a/custom_components/matter_experimental/binary_sensor.py b/custom_components/matter_experimental/binary_sensor.py index c067c274..22e645a7 100644 --- a/custom_components/matter_experimental/binary_sensor.py +++ b/custom_components/matter_experimental/binary_sensor.py @@ -1,6 +1,8 @@ """Matter switches.""" from __future__ import annotations +from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING from homeassistant.components.binary_sensor import ( @@ -17,8 +19,8 @@ from matter_server.vendor.chip.clusters import Objects as clusters from .const import DOMAIN -from .device_platform_helper import DeviceMapping from .entity import MatterEntity +from .entity_description import MatterEntityDescription if TYPE_CHECKING: from matter_server.client.matter import Matter @@ -37,13 +39,15 @@ async def async_setup_entry( class MatterBinarySensor(MatterEntity, BinarySensorEntity): """Representation of a Matter binary sensor.""" + entity_description: MatterBinarySensorEntityDescription + @callback def _update_from_device(self) -> None: """Update from device.""" self._attr_is_on = self._device.get_cluster(clusters.BooleanState).stateValue -class MatterOccupancySensor(MatterEntity, BinarySensorEntity): +class MatterOccupancySensor(MatterBinarySensor): """Representation of a Matter occupancy sensor.""" _attr_device_class = BinarySensorDeviceClass.OCCUPANCY @@ -56,18 +60,30 @@ def _update_from_device(self) -> None: self._attr_is_on = occupancy & 1 == 1 +@dataclass +class MatterBinarySensorEntityDescription( + BinarySensorEntityDescription, + MatterEntityDescription, +): + """Matter Binary Sensor entity description.""" + + +# You can't set default values on inherited data classes +MatterSensorEntityDescriptionFactory = partial( + MatterBinarySensorEntityDescription, entity_cls=MatterBinarySensor +) + DEVICE_ENTITY: dict[ - type[device_types.DeviceType], DeviceMapping | list[DeviceMapping] + type[device_types.DeviceType], + MatterEntityDescription | list[MatterEntityDescription], ] = { - device_types.ContactSensor: DeviceMapping( - entity_cls=MatterBinarySensor, + device_types.ContactSensor: MatterSensorEntityDescriptionFactory( + key=device_types.ContactSensor, subscribe_attributes=(clusters.BooleanState.Attributes.StateValue,), - entity_description=BinarySensorEntityDescription( - key=None, - device_class=BinarySensorDeviceClass.DOOR, - ), + device_class=BinarySensorDeviceClass.DOOR, ), - device_types.OccupancySensor: DeviceMapping( + device_types.OccupancySensor: MatterSensorEntityDescriptionFactory( + key=device_types.OccupancySensor, entity_cls=MatterOccupancySensor, subscribe_attributes=(clusters.OccupancySensing.Attributes.Occupancy,), ), diff --git a/custom_components/matter_experimental/device_platform.py b/custom_components/matter_experimental/device_platform.py index cee715ad..f1bfa3e8 100644 --- a/custom_components/matter_experimental/device_platform.py +++ b/custom_components/matter_experimental/device_platform.py @@ -13,11 +13,12 @@ if TYPE_CHECKING: from matter_server.vendor.device_types import DeviceType - from .device_platform_helper import DeviceMapping + from .entity_description import MatterEntityDescription DEVICE_PLATFORM: dict[ - Platform, dict[type[DeviceType], DeviceMapping | list[DeviceMapping]] + Platform, + dict[type[DeviceType], MatterEntityDescription | list[MatterEntityDescription]], ] = { Platform.BINARY_SENSOR: BINARY_SENSOR_DEVICE_ENTITY, Platform.LIGHT: LIGHT_DEVICE_ENTITY, diff --git a/custom_components/matter_experimental/device_platform_helper.py b/custom_components/matter_experimental/device_platform_helper.py deleted file mode 100644 index 07a5e8d0..00000000 --- a/custom_components/matter_experimental/device_platform_helper.py +++ /dev/null @@ -1,18 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from typing import TYPE_CHECKING - -from homeassistant.helpers.entity import EntityDescription - -if TYPE_CHECKING: - from .entity import MatterEntity - - -@dataclass -class DeviceMapping: - """Map a matter device to a HA entity.""" - - entity_cls: type[MatterEntity] - subscribe_attributes: tuple | None = None - entity_description: EntityDescription | None = None diff --git a/custom_components/matter_experimental/entity.py b/custom_components/matter_experimental/entity.py index 378ed64a..33050969 100644 --- a/custom_components/matter_experimental/entity.py +++ b/custom_components/matter_experimental/entity.py @@ -11,19 +11,20 @@ from matter_server.client.model.device import MatterDevice from .const import DOMAIN -from .device_platform_helper import DeviceMapping +from .entity_description import MatterEntityDescriptionBaseClass class MatterEntity(entity.Entity): + entity_description: MatterEntityDescriptionBaseClass _attr_should_poll = False _unsubscribe: Callable[..., Coroutine[Any, Any, None]] | None = None - def __init__(self, device: MatterDevice, mapping: DeviceMapping) -> None: + def __init__( + self, device: MatterDevice, entity_description: MatterEntityDescriptionBaseClass + ) -> None: self._device = device - self._device_mapping = mapping - if mapping.entity_description: - self.entity_description = mapping.entity_description + self.entity_description = entity_description self._attr_unique_id = f"{device.node.matter.client.server_info.compressedFabricId}-{device.node.unique_id}-{device.endpoint_id}-{device.device_type.device_type}" @property @@ -54,19 +55,19 @@ async def init_matter_device(self) -> None: self._attr_name = name - if not self._device_mapping.subscribe_attributes: + if not self.entity_description.subscribe_attributes: self._update_from_device() return try: # Subscribe to updates. self._unsubscribe = await self._device.subscribe_updates( - self._device_mapping.subscribe_attributes, self._subscription_update + self.entity_description.subscribe_attributes, self._subscription_update ) # Fetch latest info from the device. await self._device.update_attributes( - self._device_mapping.subscribe_attributes + self.entity_description.subscribe_attributes ) except FailedCommand as err: self._device.node.matter.adapter.logger.warning( diff --git a/custom_components/matter_experimental/entity_description.py b/custom_components/matter_experimental/entity_description.py new file mode 100644 index 00000000..834b320f --- /dev/null +++ b/custom_components/matter_experimental/entity_description.py @@ -0,0 +1,23 @@ +"""Matter entity description base class.""" +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homeassistant.helpers.entity import EntityDescription + +if TYPE_CHECKING: + from .entity import MatterEntity + + +@dataclass +class MatterEntityDescription: + """Mixin to map a matter device to a HA entity.""" + + entity_cls: type[MatterEntity] + subscribe_attributes: tuple + + +@dataclass +class MatterEntityDescriptionBaseClass(EntityDescription, MatterEntityDescription): + """For typing a base class that inherits from both entity descriptions.""" diff --git a/custom_components/matter_experimental/light.py b/custom_components/matter_experimental/light.py index c45f6ce9..bfb4c8f3 100644 --- a/custom_components/matter_experimental/light.py +++ b/custom_components/matter_experimental/light.py @@ -1,12 +1,15 @@ """Matter light.""" from __future__ import annotations +from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Any from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity_platform import AddEntitiesCallback from matter_server.client.model.device import MatterDevice @@ -14,8 +17,8 @@ from matter_server.vendor.chip.clusters import Objects as clusters from .const import DOMAIN -from .device_platform_helper import DeviceMapping from .entity import MatterEntity +from .entity_description import MatterEntityDescription from .util import renormalize if TYPE_CHECKING: @@ -35,7 +38,9 @@ async def async_setup_entry( class MatterLight(MatterEntity, LightEntity): """Representation of a Matter light.""" - def __init__(self, device: MatterDevice, mapping: DeviceMapping) -> None: + entity_description: MatterLightEntityDescription + + def __init__(self, device: MatterDevice, mapping: MatterEntityDescription) -> None: """Initialize the light.""" super().__init__(device, mapping) if self._supports_brightness(): @@ -45,7 +50,7 @@ def _supports_brightness(self): """Return if device supports brightness.""" return ( clusters.LevelControl.Attributes.CurrentLevel - in self._device_mapping.subscribe_attributes + in self.entity_description.subscribe_attributes ) async def async_turn_on(self, **kwargs: Any) -> None: @@ -82,7 +87,7 @@ def _update_from_device(self) -> None: if ( clusters.LevelControl.Attributes.CurrentLevel - in self._device_mapping.subscribe_attributes + in self.entity_description.subscribe_attributes ): level_control = self._device.get_cluster(clusters.LevelControl) @@ -96,22 +101,37 @@ def _update_from_device(self) -> None: ) +@dataclass +class MatterLightEntityDescription( + EntityDescription, + MatterEntityDescription, +): + """Matter light entity description.""" + + +# You can't set default values on inherited data classes +MatterLightEntityDescriptionFactory = partial( + MatterLightEntityDescription, entity_cls=MatterLight +) + + DEVICE_ENTITY: dict[ - type[device_types.DeviceType], DeviceMapping | list[DeviceMapping] + type[device_types.DeviceType], + MatterEntityDescription | list[MatterEntityDescription], ] = { - device_types.OnOffLight: DeviceMapping( - entity_cls=MatterLight, + device_types.OnOffLight: MatterLightEntityDescriptionFactory( + key=device_types.OnOffLight, subscribe_attributes=(clusters.OnOff.Attributes.OnOff,), ), - device_types.DimmableLight: DeviceMapping( - entity_cls=MatterLight, + device_types.DimmableLight: MatterLightEntityDescriptionFactory( + key=device_types.DimmableLight, subscribe_attributes=( clusters.OnOff.Attributes.OnOff, clusters.LevelControl.Attributes.CurrentLevel, ), ), - device_types.DimmablePlugInUnit: DeviceMapping( - entity_cls=MatterLight, + device_types.DimmablePlugInUnit: MatterLightEntityDescriptionFactory( + key=device_types.DimmablePlugInUnit, subscribe_attributes=( clusters.OnOff.Attributes.OnOff, clusters.LevelControl.Attributes.CurrentLevel, diff --git a/custom_components/matter_experimental/sensor.py b/custom_components/matter_experimental/sensor.py index b84b2c63..d6494e7b 100644 --- a/custom_components/matter_experimental/sensor.py +++ b/custom_components/matter_experimental/sensor.py @@ -29,8 +29,8 @@ from matter_server.vendor.chip.clusters.Types import Nullable, NullValue from .const import DOMAIN -from .device_platform_helper import DeviceMapping from .entity import MatterEntity +from .entity_description import MatterEntityDescription if TYPE_CHECKING: from matter_server.client.matter import Matter @@ -50,7 +50,7 @@ class MatterSensor(MatterEntity, SensorEntity): """Representation of a Matter sensor.""" _attr_state_class = SensorStateClass.MEASUREMENT - _device_mapping: SensorDeviceMapping + entity_description: MatterSensorEntityDescription @callback def _update_from_device(self) -> None: @@ -58,13 +58,13 @@ def _update_from_device(self) -> None: measurement: Nullable | float = _get_attribute_value( self._device, # We always subscribe to a single value - self._device_mapping.subscribe_attributes[0], + self.entity_description.subscribe_attributes[0], ) if measurement is NullValue: measurement = None else: - measurement = self._device_mapping.measurement_to_ha(measurement) + measurement = self.entity_description.measurement_to_ha(measurement) self._attr_native_value = measurement @@ -92,68 +92,69 @@ def _get_attribute_value( @dataclass -class SensorDeviceMappingMixin: +class MatterSensorEntityDescriptionMixin: """Required fields for sensor device mapping.""" measurement_to_ha: Callable[[float], float] @dataclass -class SensorDeviceMapping(DeviceMapping, SensorDeviceMappingMixin): - """Matter Sensor device mapping.""" +class MatterSensorEntityDescription( + SensorEntityDescription, + MatterSensorEntityDescriptionMixin, + MatterEntityDescription, +): + """Matter Sensor entity description.""" # You can't set default values on inherited data classes -SensorDeviceMappingCls = partial(SensorDeviceMapping, entity_cls=MatterSensor) -SensorEntityDescriptionKey = partial(SensorEntityDescription, key=None) +MatterSensorEntityDescriptionFactory = partial( + MatterSensorEntityDescription, entity_cls=MatterSensor +) DEVICE_ENTITY: dict[ - type[device_types.DeviceType], DeviceMapping | list[DeviceMapping] + type[device_types.DeviceType], + MatterEntityDescription | list[MatterEntityDescription], ] = { - device_types.TemperatureSensor: SensorDeviceMappingCls( + device_types.TemperatureSensor: MatterSensorEntityDescriptionFactory( + key=device_types.TemperatureSensor, measurement_to_ha=lambda x: x / 100, subscribe_attributes=( clusters.TemperatureMeasurement.Attributes.MeasuredValue, ), - entity_description=SensorEntityDescriptionKey( - native_unit_of_measurement=TEMP_CELSIUS, - device_class=SensorDeviceClass.TEMPERATURE, - ), + native_unit_of_measurement=TEMP_CELSIUS, + device_class=SensorDeviceClass.TEMPERATURE, ), - device_types.PressureSensor: SensorDeviceMappingCls( + device_types.PressureSensor: MatterSensorEntityDescriptionFactory( + key=device_types.PressureSensor, measurement_to_ha=lambda x: x / 10, subscribe_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,), - entity_description=SensorEntityDescriptionKey( - native_unit_of_measurement=PRESSURE_KPA, - device_class=SensorDeviceClass.PRESSURE, - ), + native_unit_of_measurement=PRESSURE_KPA, + device_class=SensorDeviceClass.PRESSURE, ), - device_types.FlowSensor: SensorDeviceMappingCls( + device_types.FlowSensor: MatterSensorEntityDescriptionFactory( + key=device_types.FlowSensor, measurement_to_ha=lambda x: x / 10, subscribe_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,), - entity_description=SensorEntityDescriptionKey( - native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, - ), + native_unit_of_measurement=VOLUME_FLOW_RATE_CUBIC_METERS_PER_HOUR, ), - device_types.HumiditySensor: SensorDeviceMappingCls( + device_types.HumiditySensor: MatterSensorEntityDescriptionFactory( + key=device_types.HumiditySensor, measurement_to_ha=lambda x: x / 100, subscribe_attributes=( clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue, ), - entity_description=SensorEntityDescriptionKey( - native_unit_of_measurement=PERCENTAGE, - device_class=SensorDeviceClass.HUMIDITY, - ), + native_unit_of_measurement=PERCENTAGE, + device_class=SensorDeviceClass.HUMIDITY, ), - device_types.LightSensor: SensorDeviceMappingCls( + device_types.LightSensor: MatterSensorEntityDescriptionFactory( + key=device_types.LightSensor, measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1), subscribe_attributes=( clusters.IlluminanceMeasurement.Attributes.MeasuredValue, ), - entity_description=SensorEntityDescriptionKey( - native_unit_of_measurement=LIGHT_LUX, - device_class=SensorDeviceClass.ILLUMINANCE, - ), + native_unit_of_measurement=LIGHT_LUX, + device_class=SensorDeviceClass.ILLUMINANCE, ), } diff --git a/custom_components/matter_experimental/switch.py b/custom_components/matter_experimental/switch.py index ef4ef0a0..5250d7cf 100644 --- a/custom_components/matter_experimental/switch.py +++ b/custom_components/matter_experimental/switch.py @@ -1,6 +1,8 @@ """Matter switches.""" from __future__ import annotations +from dataclasses import dataclass +from functools import partial from typing import TYPE_CHECKING, Any from homeassistant.components.switch import ( @@ -17,8 +19,8 @@ from matter_server.vendor.chip.clusters import Objects as clusters from .const import DOMAIN -from .device_platform_helper import DeviceMapping from .entity import MatterEntity +from .entity_description import MatterEntityDescription if TYPE_CHECKING: from matter_server.client.matter import Matter @@ -37,6 +39,8 @@ async def async_setup_entry( class MatterSwitch(MatterEntity, SwitchEntity): """Representation of a Matter switch.""" + entity_description: MatterSwitchEntityDescription + async def async_turn_on(self, **kwargs: Any) -> None: """Turn switch on.""" await self._device.send_command( @@ -55,15 +59,27 @@ def _update_from_device(self) -> None: self._attr_is_on = self._device.get_cluster(clusters.OnOff).onOff +@dataclass +class MatterSwitchEntityDescription( + SwitchEntityDescription, + MatterEntityDescription, +): + """Matter Switch entity description.""" + + +# You can't set default values on inherited data classes +MatterSwitchEntityDescriptionFactory = partial( + MatterSwitchEntityDescription, entity_cls=MatterSwitch +) + + DEVICE_ENTITY: dict[ - type[device_types.DeviceType], DeviceMapping | list[DeviceMapping] + type[device_types.DeviceType], + MatterEntityDescription | list[MatterEntityDescription], ] = { - device_types.OnOffPlugInUnit: DeviceMapping( - entity_cls=MatterSwitch, + device_types.OnOffPlugInUnit: MatterSwitchEntityDescriptionFactory( + key=device_types.OnOffPlugInUnit, subscribe_attributes=(clusters.OnOff.Attributes.OnOff,), - entity_description=SwitchEntityDescription( - key=None, - device_class=SwitchDeviceClass.OUTLET, - ), + device_class=SwitchDeviceClass.OUTLET, ), } diff --git a/scripts/show_stored_node.py b/scripts/show_stored_node.py index a05f714c..81d05608 100644 --- a/scripts/show_stored_node.py +++ b/scripts/show_stored_node.py @@ -52,33 +52,25 @@ def print_device(device: MatterDevice): print(f" {device}") for platform, devices in DEVICE_PLATFORM.items(): - device_mappings = devices.get(device.device_type) + entity_descriptions = devices.get(device.device_type) - if device_mappings is None: + if entity_descriptions is None: continue - if not isinstance(device_mappings, list): - device_mappings = [device_mappings] + if not isinstance(entity_descriptions, list): + entity_descriptions = [entity_descriptions] - for device_mapping in device_mappings: + for entity_description in entity_descriptions: created = True print(f" - Platform: {platform}") - for key, value in sorted(dataclasses.asdict(device_mapping).items()): + for key, value in sorted(dataclasses.asdict(entity_description).items()): if value is None: continue - if key == "entity_description": - print(f" {key}:") - for ed_key, ed_value in sorted(value.items()): - if ed_value is None or ( # filter out default values - device_mapping.entity_description.__dataclass_fields__[ - ed_key - ].default - is ed_value - ): - continue - print(f" {ed_key}: {ed_value}") + if value is None or ( # filter out default values + entity_description.__dataclass_fields__[key].default is value + ): continue if key == "entity_cls": @@ -93,8 +85,8 @@ def print_device(device: MatterDevice): for sub in value: print(f" - {sub.__qualname__}") - # Try instantiating to ensure the device mapping doesn't crash - device_mapping.entity_cls(device, device_mapping) + # Try instantiating to ensure the entity description doesn't crash + entity_description.entity_cls(device, entity_description) # Do not warng on root node if not created: