mirror of
https://github.com/home-assistant/core.git
synced 2025-07-15 17:27:10 +00:00
Fix support for Bridge(d) and composed devices in Matter (#88662)
* Refactor discovery of entities to support composed and bridged devices * Bump library version to 3.1.0 * move discovery schemas to platforms * optimize a tiny bit * simplify even more * fixed bug in light platform * fix color control logic * fix some issues * Update homeassistant/components/matter/discovery.py Co-authored-by: Paulus Schoutsen <balloob@gmail.com> * fix some tests * fix light test --------- Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
This commit is contained in:
parent
0fb28dcf9e
commit
5adf1dcc90
@ -27,7 +27,7 @@ from .adapter import MatterAdapter
|
|||||||
from .addon import get_addon_manager
|
from .addon import get_addon_manager
|
||||||
from .api import async_register_api
|
from .api import async_register_api
|
||||||
from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER
|
from .const import CONF_INTEGRATION_CREATED_ADDON, CONF_USE_ADDON, DOMAIN, LOGGER
|
||||||
from .device_platform import DEVICE_PLATFORM
|
from .discovery import SUPPORTED_PLATFORMS
|
||||||
from .helpers import MatterEntryData, get_matter, get_node_from_device_entry
|
from .helpers import MatterEntryData, get_matter, get_node_from_device_entry
|
||||||
|
|
||||||
CONNECT_TIMEOUT = 10
|
CONNECT_TIMEOUT = 10
|
||||||
@ -101,12 +101,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
|||||||
matter = MatterAdapter(hass, matter_client, entry)
|
matter = MatterAdapter(hass, matter_client, entry)
|
||||||
hass.data[DOMAIN][entry.entry_id] = MatterEntryData(matter, listen_task)
|
hass.data[DOMAIN][entry.entry_id] = MatterEntryData(matter, listen_task)
|
||||||
|
|
||||||
await hass.config_entries.async_forward_entry_setups(entry, DEVICE_PLATFORM)
|
await hass.config_entries.async_forward_entry_setups(entry, SUPPORTED_PLATFORMS)
|
||||||
await matter.setup_nodes()
|
await matter.setup_nodes()
|
||||||
|
|
||||||
# If the listen task is already failed, we need to raise ConfigEntryNotReady
|
# If the listen task is already failed, we need to raise ConfigEntryNotReady
|
||||||
if listen_task.done() and (listen_error := listen_task.exception()) is not None:
|
if listen_task.done() and (listen_error := listen_task.exception()) is not None:
|
||||||
await hass.config_entries.async_unload_platforms(entry, DEVICE_PLATFORM)
|
await hass.config_entries.async_unload_platforms(entry, SUPPORTED_PLATFORMS)
|
||||||
hass.data[DOMAIN].pop(entry.entry_id)
|
hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
try:
|
try:
|
||||||
await matter_client.disconnect()
|
await matter_client.disconnect()
|
||||||
@ -142,7 +142,9 @@ async def _client_listen(
|
|||||||
|
|
||||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||||
"""Unload a config entry."""
|
"""Unload a config entry."""
|
||||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, DEVICE_PLATFORM)
|
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||||
|
entry, SUPPORTED_PLATFORMS
|
||||||
|
)
|
||||||
|
|
||||||
if unload_ok:
|
if unload_ok:
|
||||||
matter_entry_data: MatterEntryData = hass.data[DOMAIN].pop(entry.entry_id)
|
matter_entry_data: MatterEntryData = hass.data[DOMAIN].pop(entry.entry_id)
|
||||||
|
@ -3,11 +3,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING, cast
|
from typing import TYPE_CHECKING, cast
|
||||||
|
|
||||||
from chip.clusters import Objects as all_clusters
|
|
||||||
from matter_server.client.models.node_device import (
|
|
||||||
AbstractMatterNodeDevice,
|
|
||||||
MatterBridgedNodeDevice,
|
|
||||||
)
|
|
||||||
from matter_server.common.models import EventType, ServerInfoMessage
|
from matter_server.common.models import EventType, ServerInfoMessage
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry
|
from homeassistant.config_entries import ConfigEntry
|
||||||
@ -17,12 +12,12 @@ from homeassistant.helpers import device_registry as dr
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER
|
from .const import DOMAIN, ID_TYPE_DEVICE_ID, ID_TYPE_SERIAL, LOGGER
|
||||||
from .device_platform import DEVICE_PLATFORM
|
from .discovery import async_discover_entities
|
||||||
from .helpers import get_device_id
|
from .helpers import get_device_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from matter_server.client import MatterClient
|
from matter_server.client import MatterClient
|
||||||
from matter_server.client.models.node import MatterNode
|
from matter_server.client.models.node import MatterEndpoint, MatterNode
|
||||||
|
|
||||||
|
|
||||||
class MatterAdapter:
|
class MatterAdapter:
|
||||||
@ -51,12 +46,8 @@ class MatterAdapter:
|
|||||||
for node in await self.matter_client.get_nodes():
|
for node in await self.matter_client.get_nodes():
|
||||||
self._setup_node(node)
|
self._setup_node(node)
|
||||||
|
|
||||||
def node_added_callback(event: EventType, node: MatterNode | None) -> None:
|
def node_added_callback(event: EventType, node: MatterNode) -> None:
|
||||||
"""Handle node added event."""
|
"""Handle node added event."""
|
||||||
if node is None:
|
|
||||||
# We can clean this up when we've improved the typing in the library.
|
|
||||||
# https://github.com/home-assistant-libs/python-matter-server/pull/153
|
|
||||||
raise RuntimeError("Node added event without node")
|
|
||||||
self._setup_node(node)
|
self._setup_node(node)
|
||||||
|
|
||||||
self.config_entry.async_on_unload(
|
self.config_entry.async_on_unload(
|
||||||
@ -67,48 +58,32 @@ class MatterAdapter:
|
|||||||
"""Set up an node."""
|
"""Set up an node."""
|
||||||
LOGGER.debug("Setting up entities for node %s", node.node_id)
|
LOGGER.debug("Setting up entities for node %s", node.node_id)
|
||||||
|
|
||||||
bridge_unique_id: str | None = None
|
for endpoint in node.endpoints.values():
|
||||||
|
# Node endpoints are translated into HA devices
|
||||||
if (
|
self._setup_endpoint(endpoint)
|
||||||
node.aggregator_device_type_instance is not None
|
|
||||||
and node.root_device_type_instance is not None
|
|
||||||
and node.root_device_type_instance.get_cluster(
|
|
||||||
all_clusters.BasicInformation
|
|
||||||
)
|
|
||||||
):
|
|
||||||
# create virtual (parent) device for bridge node device
|
|
||||||
bridge_device = MatterBridgedNodeDevice(
|
|
||||||
node.aggregator_device_type_instance
|
|
||||||
)
|
|
||||||
self._create_device_registry(bridge_device)
|
|
||||||
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
|
|
||||||
bridge_unique_id = get_device_id(server_info, bridge_device)
|
|
||||||
|
|
||||||
for node_device in node.node_devices:
|
|
||||||
self._setup_node_device(node_device, bridge_unique_id)
|
|
||||||
|
|
||||||
def _create_device_registry(
|
def _create_device_registry(
|
||||||
self,
|
self,
|
||||||
node_device: AbstractMatterNodeDevice,
|
endpoint: MatterEndpoint,
|
||||||
bridge_unique_id: str | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Create a device registry entry."""
|
"""Create a device registry entry for a MatterNode."""
|
||||||
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
|
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
|
||||||
|
|
||||||
basic_info = node_device.device_info()
|
basic_info = endpoint.device_info
|
||||||
device_type_instances = node_device.device_type_instances()
|
name = basic_info.nodeLabel or basic_info.productLabel or basic_info.productName
|
||||||
|
|
||||||
name = basic_info.nodeLabel
|
# handle bridged devices
|
||||||
if not name and isinstance(node_device, MatterBridgedNodeDevice):
|
bridge_device_id = None
|
||||||
# fallback name for Bridge
|
if endpoint.is_bridged_device:
|
||||||
name = "Hub device"
|
bridge_device_id = get_device_id(
|
||||||
elif not name and device_type_instances:
|
server_info,
|
||||||
# use the productName if no node label is present
|
endpoint.node.endpoints[0],
|
||||||
name = basic_info.productName
|
)
|
||||||
|
bridge_device_id = f"{ID_TYPE_DEVICE_ID}_{bridge_device_id}"
|
||||||
|
|
||||||
node_device_id = get_device_id(
|
node_device_id = get_device_id(
|
||||||
server_info,
|
server_info,
|
||||||
node_device,
|
endpoint,
|
||||||
)
|
)
|
||||||
identifiers = {(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
|
identifiers = {(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
|
||||||
# if available, we also add the serialnumber as identifier
|
# if available, we also add the serialnumber as identifier
|
||||||
@ -124,50 +99,21 @@ class MatterAdapter:
|
|||||||
sw_version=basic_info.softwareVersionString,
|
sw_version=basic_info.softwareVersionString,
|
||||||
manufacturer=basic_info.vendorName,
|
manufacturer=basic_info.vendorName,
|
||||||
model=basic_info.productName,
|
model=basic_info.productName,
|
||||||
via_device=(DOMAIN, bridge_unique_id) if bridge_unique_id else None,
|
via_device=(DOMAIN, bridge_device_id) if bridge_device_id else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _setup_node_device(
|
def _setup_endpoint(self, endpoint: MatterEndpoint) -> None:
|
||||||
self, node_device: AbstractMatterNodeDevice, bridge_unique_id: str | None
|
"""Set up a MatterEndpoint as HA Device."""
|
||||||
) -> None:
|
# pre-create device registry entry
|
||||||
"""Set up a node device."""
|
self._create_device_registry(endpoint)
|
||||||
self._create_device_registry(node_device, bridge_unique_id)
|
|
||||||
# run platform discovery from device type instances
|
# run platform discovery from device type instances
|
||||||
for instance in node_device.device_type_instances():
|
for entity_info in async_discover_entities(endpoint):
|
||||||
created = False
|
LOGGER.debug(
|
||||||
|
"Creating %s entity for %s",
|
||||||
for platform, devices in DEVICE_PLATFORM.items():
|
entity_info.platform,
|
||||||
entity_descriptions = devices.get(instance.device_type)
|
entity_info.primary_attribute,
|
||||||
|
)
|
||||||
if entity_descriptions is None:
|
new_entity = entity_info.entity_class(
|
||||||
continue
|
self.matter_client, endpoint, entity_info
|
||||||
|
)
|
||||||
if not isinstance(entity_descriptions, list):
|
self.platform_handlers[entity_info.platform]([new_entity])
|
||||||
entity_descriptions = [entity_descriptions]
|
|
||||||
|
|
||||||
entities = []
|
|
||||||
for entity_description in entity_descriptions:
|
|
||||||
LOGGER.debug(
|
|
||||||
"Creating %s entity for %s (%s)",
|
|
||||||
platform,
|
|
||||||
instance.device_type.__name__,
|
|
||||||
hex(instance.device_type.device_type),
|
|
||||||
)
|
|
||||||
entities.append(
|
|
||||||
entity_description.entity_cls(
|
|
||||||
self.matter_client,
|
|
||||||
node_device,
|
|
||||||
instance,
|
|
||||||
entity_description,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
self.platform_handlers[platform](entities)
|
|
||||||
created = True
|
|
||||||
|
|
||||||
if not created:
|
|
||||||
LOGGER.warning(
|
|
||||||
"Found unsupported device %s (%s)",
|
|
||||||
type(instance).__name__,
|
|
||||||
hex(instance.device_type.device_type),
|
|
||||||
)
|
|
||||||
|
@ -1,11 +1,9 @@
|
|||||||
"""Matter binary sensors."""
|
"""Matter binary sensors."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from chip.clusters import Objects as clusters
|
from chip.clusters import Objects as clusters
|
||||||
from matter_server.client.models import device_types
|
from chip.clusters.Objects import uint
|
||||||
|
from chip.clusters.Types import Nullable, NullValue
|
||||||
|
|
||||||
from homeassistant.components.binary_sensor import (
|
from homeassistant.components.binary_sensor import (
|
||||||
BinarySensorDeviceClass,
|
BinarySensorDeviceClass,
|
||||||
@ -17,8 +15,9 @@ from homeassistant.const import Platform
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .entity import MatterEntity, MatterEntityDescriptionBaseClass
|
from .entity import MatterEntity
|
||||||
from .helpers import get_matter
|
from .helpers import get_matter
|
||||||
|
from .models import MatterDiscoverySchema
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@ -34,60 +33,70 @@ async def async_setup_entry(
|
|||||||
class MatterBinarySensor(MatterEntity, BinarySensorEntity):
|
class MatterBinarySensor(MatterEntity, BinarySensorEntity):
|
||||||
"""Representation of a Matter binary sensor."""
|
"""Representation of a Matter binary sensor."""
|
||||||
|
|
||||||
entity_description: MatterBinarySensorEntityDescription
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _update_from_device(self) -> None:
|
def _update_from_device(self) -> None:
|
||||||
"""Update from device."""
|
"""Update from device."""
|
||||||
self._attr_is_on = self.get_matter_attribute_value(
|
value: bool | uint | int | Nullable | None
|
||||||
# We always subscribe to a single value
|
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
|
||||||
self.entity_description.subscribe_attributes[0],
|
if value in (None, NullValue):
|
||||||
)
|
value = None
|
||||||
|
elif value_convert := self._entity_info.measurement_to_ha:
|
||||||
|
value = value_convert(value)
|
||||||
|
self._attr_is_on = value
|
||||||
|
|
||||||
|
|
||||||
class MatterOccupancySensor(MatterBinarySensor):
|
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||||
"""Representation of a Matter occupancy sensor."""
|
DISCOVERY_SCHEMAS = [
|
||||||
|
# device specific: translate Hue motion to sensor to HA Motion sensor
|
||||||
_attr_device_class = BinarySensorDeviceClass.OCCUPANCY
|
# instead of generic occupancy sensor
|
||||||
|
MatterDiscoverySchema(
|
||||||
@callback
|
platform=Platform.BINARY_SENSOR,
|
||||||
def _update_from_device(self) -> None:
|
entity_description=BinarySensorEntityDescription(
|
||||||
"""Update from device."""
|
key="HueMotionSensor",
|
||||||
value = self.get_matter_attribute_value(
|
device_class=BinarySensorDeviceClass.MOTION,
|
||||||
# We always subscribe to a single value
|
name="Motion",
|
||||||
self.entity_description.subscribe_attributes[0],
|
),
|
||||||
)
|
entity_class=MatterBinarySensor,
|
||||||
|
required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
|
||||||
|
vendor_id=(4107,),
|
||||||
|
product_name=("Hue motion sensor",),
|
||||||
|
measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
|
||||||
|
),
|
||||||
|
MatterDiscoverySchema(
|
||||||
|
platform=Platform.BINARY_SENSOR,
|
||||||
|
entity_description=BinarySensorEntityDescription(
|
||||||
|
key="ContactSensor",
|
||||||
|
device_class=BinarySensorDeviceClass.DOOR,
|
||||||
|
name="Contact",
|
||||||
|
),
|
||||||
|
entity_class=MatterBinarySensor,
|
||||||
|
required_attributes=(clusters.BooleanState.Attributes.StateValue,),
|
||||||
|
# value is inverted on matter to what we expect
|
||||||
|
measurement_to_ha=lambda x: not x,
|
||||||
|
),
|
||||||
|
MatterDiscoverySchema(
|
||||||
|
platform=Platform.BINARY_SENSOR,
|
||||||
|
entity_description=BinarySensorEntityDescription(
|
||||||
|
key="OccupancySensor",
|
||||||
|
device_class=BinarySensorDeviceClass.OCCUPANCY,
|
||||||
|
name="Occupancy",
|
||||||
|
),
|
||||||
|
entity_class=MatterBinarySensor,
|
||||||
|
required_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
|
||||||
# The first bit = if occupied
|
# The first bit = if occupied
|
||||||
self._attr_is_on = (value & 1 == 1) if value is not None else None
|
measurement_to_ha=lambda x: (x & 1 == 1) if x is not None else None,
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MatterBinarySensorEntityDescription(
|
|
||||||
BinarySensorEntityDescription,
|
|
||||||
MatterEntityDescriptionBaseClass,
|
|
||||||
):
|
|
||||||
"""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],
|
|
||||||
MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass],
|
|
||||||
] = {
|
|
||||||
device_types.ContactSensor: MatterSensorEntityDescriptionFactory(
|
|
||||||
key=device_types.ContactSensor,
|
|
||||||
name="Contact",
|
|
||||||
subscribe_attributes=(clusters.BooleanState.Attributes.StateValue,),
|
|
||||||
device_class=BinarySensorDeviceClass.DOOR,
|
|
||||||
),
|
),
|
||||||
device_types.OccupancySensor: MatterSensorEntityDescriptionFactory(
|
MatterDiscoverySchema(
|
||||||
key=device_types.OccupancySensor,
|
platform=Platform.BINARY_SENSOR,
|
||||||
name="Occupancy",
|
entity_description=BinarySensorEntityDescription(
|
||||||
entity_cls=MatterOccupancySensor,
|
key="BatteryChargeLevel",
|
||||||
subscribe_attributes=(clusters.OccupancySensing.Attributes.Occupancy,),
|
device_class=BinarySensorDeviceClass.BATTERY,
|
||||||
|
name="Battery Status",
|
||||||
|
),
|
||||||
|
entity_class=MatterBinarySensor,
|
||||||
|
required_attributes=(clusters.PowerSource.Attributes.BatChargeLevel,),
|
||||||
|
# only add binary battery sensor if a regular percentage based is not available
|
||||||
|
absent_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,),
|
||||||
|
measurement_to_ha=lambda x: x != clusters.PowerSource.Enums.BatChargeLevel.kOk,
|
||||||
),
|
),
|
||||||
}
|
]
|
||||||
|
@ -1,30 +0,0 @@
|
|||||||
"""All mappings of Matter devices to Home Assistant platforms."""
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
|
|
||||||
from homeassistant.const import Platform
|
|
||||||
|
|
||||||
from .binary_sensor import DEVICE_ENTITY as BINARY_SENSOR_DEVICE_ENTITY
|
|
||||||
from .light import DEVICE_ENTITY as LIGHT_DEVICE_ENTITY
|
|
||||||
from .sensor import DEVICE_ENTITY as SENSOR_DEVICE_ENTITY
|
|
||||||
from .switch import DEVICE_ENTITY as SWITCH_DEVICE_ENTITY
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from matter_server.client.models.device_types import DeviceType
|
|
||||||
|
|
||||||
from .entity import MatterEntityDescriptionBaseClass
|
|
||||||
|
|
||||||
|
|
||||||
DEVICE_PLATFORM: dict[
|
|
||||||
Platform,
|
|
||||||
dict[
|
|
||||||
type[DeviceType],
|
|
||||||
MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass],
|
|
||||||
],
|
|
||||||
] = {
|
|
||||||
Platform.BINARY_SENSOR: BINARY_SENSOR_DEVICE_ENTITY,
|
|
||||||
Platform.LIGHT: LIGHT_DEVICE_ENTITY,
|
|
||||||
Platform.SENSOR: SENSOR_DEVICE_ENTITY,
|
|
||||||
Platform.SWITCH: SWITCH_DEVICE_ENTITY,
|
|
||||||
}
|
|
115
homeassistant/components/matter/discovery.py
Normal file
115
homeassistant/components/matter/discovery.py
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
"""Map Matter Nodes and Attributes to Home Assistant entities."""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Generator
|
||||||
|
|
||||||
|
from chip.clusters.Objects import ClusterAttributeDescriptor
|
||||||
|
from matter_server.client.models.node import MatterEndpoint
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.core import callback
|
||||||
|
|
||||||
|
from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
|
||||||
|
from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS
|
||||||
|
from .models import MatterDiscoverySchema, MatterEntityInfo
|
||||||
|
from .sensor import DISCOVERY_SCHEMAS as SENSOR_SCHEMAS
|
||||||
|
from .switch import DISCOVERY_SCHEMAS as SWITCH_SCHEMAS
|
||||||
|
|
||||||
|
DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = {
|
||||||
|
Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS,
|
||||||
|
Platform.LIGHT: LIGHT_SCHEMAS,
|
||||||
|
Platform.SENSOR: SENSOR_SCHEMAS,
|
||||||
|
Platform.SWITCH: SWITCH_SCHEMAS,
|
||||||
|
}
|
||||||
|
SUPPORTED_PLATFORMS = tuple(DISCOVERY_SCHEMAS.keys())
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def iter_schemas() -> Generator[MatterDiscoverySchema, None, None]:
|
||||||
|
"""Iterate over all available discovery schemas."""
|
||||||
|
for platform_schemas in DISCOVERY_SCHEMAS.values():
|
||||||
|
yield from platform_schemas
|
||||||
|
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def async_discover_entities(
|
||||||
|
endpoint: MatterEndpoint,
|
||||||
|
) -> Generator[MatterEntityInfo, None, None]:
|
||||||
|
"""Run discovery on MatterEndpoint and return matching MatterEntityInfo(s)."""
|
||||||
|
discovered_attributes: set[type[ClusterAttributeDescriptor]] = set()
|
||||||
|
device_info = endpoint.device_info
|
||||||
|
for schema in iter_schemas():
|
||||||
|
# abort if attribute(s) already discovered
|
||||||
|
if any(x in schema.required_attributes for x in discovered_attributes):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# check vendor_id
|
||||||
|
if (
|
||||||
|
schema.vendor_id is not None
|
||||||
|
and device_info.vendorID not in schema.vendor_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# check product_name
|
||||||
|
if (
|
||||||
|
schema.product_name is not None
|
||||||
|
and device_info.productName not in schema.product_name
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# check required device_type
|
||||||
|
if schema.device_type is not None and not any(
|
||||||
|
x in schema.device_type for x in endpoint.device_types
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# check absent device_type
|
||||||
|
if schema.not_device_type is not None and any(
|
||||||
|
x in schema.not_device_type for x in endpoint.device_types
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# check endpoint_id
|
||||||
|
if (
|
||||||
|
schema.endpoint_id is not None
|
||||||
|
and endpoint.endpoint_id not in schema.endpoint_id
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# check required attributes
|
||||||
|
if schema.required_attributes is not None and not all(
|
||||||
|
endpoint.has_attribute(None, val_schema)
|
||||||
|
for val_schema in schema.required_attributes
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# check for values that may not be present
|
||||||
|
if schema.absent_attributes is not None and any(
|
||||||
|
endpoint.has_attribute(None, val_schema)
|
||||||
|
for val_schema in schema.absent_attributes
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# all checks passed, this value belongs to an entity
|
||||||
|
|
||||||
|
attributes_to_watch = list(schema.required_attributes)
|
||||||
|
if schema.optional_attributes:
|
||||||
|
# check optional attributes
|
||||||
|
for optional_attribute in schema.optional_attributes:
|
||||||
|
if optional_attribute in attributes_to_watch:
|
||||||
|
continue
|
||||||
|
if endpoint.has_attribute(None, optional_attribute):
|
||||||
|
attributes_to_watch.append(optional_attribute)
|
||||||
|
|
||||||
|
yield MatterEntityInfo(
|
||||||
|
endpoint=endpoint,
|
||||||
|
platform=schema.platform,
|
||||||
|
attributes_to_watch=attributes_to_watch,
|
||||||
|
entity_description=schema.entity_description,
|
||||||
|
entity_class=schema.entity_class,
|
||||||
|
measurement_to_ha=schema.measurement_to_ha,
|
||||||
|
)
|
||||||
|
|
||||||
|
# prevent re-discovery of the same attributes
|
||||||
|
if not schema.allow_multi:
|
||||||
|
discovered_attributes.update(attributes_to_watch)
|
@ -3,90 +3,77 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from abc import abstractmethod
|
from abc import abstractmethod
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
|
||||||
import logging
|
import logging
|
||||||
from typing import TYPE_CHECKING, Any, cast
|
from typing import TYPE_CHECKING, Any, cast
|
||||||
|
|
||||||
from chip.clusters.Objects import ClusterAttributeDescriptor
|
from chip.clusters.Objects import ClusterAttributeDescriptor
|
||||||
from matter_server.client.models.device_type_instance import MatterDeviceTypeInstance
|
|
||||||
from matter_server.client.models.node_device import AbstractMatterNodeDevice
|
|
||||||
from matter_server.common.helpers.util import create_attribute_path
|
from matter_server.common.helpers.util import create_attribute_path
|
||||||
from matter_server.common.models import EventType, ServerInfoMessage
|
from matter_server.common.models import EventType, ServerInfoMessage
|
||||||
|
|
||||||
from homeassistant.core import callback
|
from homeassistant.core import callback
|
||||||
from homeassistant.helpers.entity import DeviceInfo, Entity, EntityDescription
|
from homeassistant.helpers.entity import DeviceInfo, Entity
|
||||||
|
|
||||||
from .const import DOMAIN, ID_TYPE_DEVICE_ID
|
from .const import DOMAIN, ID_TYPE_DEVICE_ID
|
||||||
from .helpers import get_device_id, get_operational_instance_id
|
from .helpers import get_device_id
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from matter_server.client import MatterClient
|
from matter_server.client import MatterClient
|
||||||
|
from matter_server.client.models.node import MatterEndpoint
|
||||||
|
|
||||||
|
from .discovery import MatterEntityInfo
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__name__)
|
LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MatterEntityDescription:
|
|
||||||
"""Mixin to map a matter device to a Home Assistant entity."""
|
|
||||||
|
|
||||||
entity_cls: type[MatterEntity]
|
|
||||||
subscribe_attributes: tuple
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class MatterEntityDescriptionBaseClass(EntityDescription, MatterEntityDescription):
|
|
||||||
"""For typing a base class that inherits from both entity descriptions."""
|
|
||||||
|
|
||||||
|
|
||||||
class MatterEntity(Entity):
|
class MatterEntity(Entity):
|
||||||
"""Entity class for Matter devices."""
|
"""Entity class for Matter devices."""
|
||||||
|
|
||||||
entity_description: MatterEntityDescriptionBaseClass
|
|
||||||
_attr_should_poll = False
|
_attr_should_poll = False
|
||||||
_attr_has_entity_name = True
|
_attr_has_entity_name = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
matter_client: MatterClient,
|
matter_client: MatterClient,
|
||||||
node_device: AbstractMatterNodeDevice,
|
endpoint: MatterEndpoint,
|
||||||
device_type_instance: MatterDeviceTypeInstance,
|
entity_info: MatterEntityInfo,
|
||||||
entity_description: MatterEntityDescriptionBaseClass,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Initialize the entity."""
|
"""Initialize the entity."""
|
||||||
self.matter_client = matter_client
|
self.matter_client = matter_client
|
||||||
self._node_device = node_device
|
self._endpoint = endpoint
|
||||||
self._device_type_instance = device_type_instance
|
self._entity_info = entity_info
|
||||||
self.entity_description = entity_description
|
self.entity_description = entity_info.entity_description
|
||||||
self._unsubscribes: list[Callable] = []
|
self._unsubscribes: list[Callable] = []
|
||||||
# for fast lookups we create a mapping to the attribute paths
|
# for fast lookups we create a mapping to the attribute paths
|
||||||
self._attributes_map: dict[type, str] = {}
|
self._attributes_map: dict[type, str] = {}
|
||||||
# The server info is set when the client connects to the server.
|
# The server info is set when the client connects to the server.
|
||||||
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
|
server_info = cast(ServerInfoMessage, self.matter_client.server_info)
|
||||||
# create unique_id based on "Operational Instance Name" and endpoint/device type
|
# create unique_id based on "Operational Instance Name" and endpoint/device type
|
||||||
|
node_device_id = get_device_id(server_info, endpoint)
|
||||||
self._attr_unique_id = (
|
self._attr_unique_id = (
|
||||||
f"{get_operational_instance_id(server_info, self._node_device.node())}-"
|
f"{node_device_id}-"
|
||||||
f"{device_type_instance.endpoint.endpoint_id}-"
|
f"{endpoint.endpoint_id}-"
|
||||||
f"{device_type_instance.device_type.device_type}"
|
f"{entity_info.entity_description.key}-"
|
||||||
|
f"{entity_info.primary_attribute.cluster_id}-"
|
||||||
|
f"{entity_info.primary_attribute.attribute_id}"
|
||||||
)
|
)
|
||||||
node_device_id = get_device_id(server_info, node_device)
|
|
||||||
self._attr_device_info = DeviceInfo(
|
self._attr_device_info = DeviceInfo(
|
||||||
identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
|
identifiers={(DOMAIN, f"{ID_TYPE_DEVICE_ID}_{node_device_id}")}
|
||||||
)
|
)
|
||||||
self._attr_available = self._node_device.node().available
|
self._attr_available = self._endpoint.node.available
|
||||||
|
|
||||||
async def async_added_to_hass(self) -> None:
|
async def async_added_to_hass(self) -> None:
|
||||||
"""Handle being added to Home Assistant."""
|
"""Handle being added to Home Assistant."""
|
||||||
await super().async_added_to_hass()
|
await super().async_added_to_hass()
|
||||||
|
|
||||||
# Subscribe to attribute updates.
|
# Subscribe to attribute updates.
|
||||||
for attr_cls in self.entity_description.subscribe_attributes:
|
for attr_cls in self._entity_info.attributes_to_watch:
|
||||||
attr_path = self.get_matter_attribute_path(attr_cls)
|
attr_path = self.get_matter_attribute_path(attr_cls)
|
||||||
self._attributes_map[attr_cls] = attr_path
|
self._attributes_map[attr_cls] = attr_path
|
||||||
self._unsubscribes.append(
|
self._unsubscribes.append(
|
||||||
self.matter_client.subscribe(
|
self.matter_client.subscribe(
|
||||||
callback=self._on_matter_event,
|
callback=self._on_matter_event,
|
||||||
event_filter=EventType.ATTRIBUTE_UPDATED,
|
event_filter=EventType.ATTRIBUTE_UPDATED,
|
||||||
node_filter=self._device_type_instance.node.node_id,
|
node_filter=self._endpoint.node.node_id,
|
||||||
attr_path_filter=attr_path,
|
attr_path_filter=attr_path,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
@ -95,7 +82,7 @@ class MatterEntity(Entity):
|
|||||||
self.matter_client.subscribe(
|
self.matter_client.subscribe(
|
||||||
callback=self._on_matter_event,
|
callback=self._on_matter_event,
|
||||||
event_filter=EventType.NODE_UPDATED,
|
event_filter=EventType.NODE_UPDATED,
|
||||||
node_filter=self._device_type_instance.node.node_id,
|
node_filter=self._endpoint.node.node_id,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -110,7 +97,7 @@ class MatterEntity(Entity):
|
|||||||
@callback
|
@callback
|
||||||
def _on_matter_event(self, event: EventType, data: Any = None) -> None:
|
def _on_matter_event(self, event: EventType, data: Any = None) -> None:
|
||||||
"""Call on update."""
|
"""Call on update."""
|
||||||
self._attr_available = self._device_type_instance.node.available
|
self._attr_available = self._endpoint.node.available
|
||||||
self._update_from_device()
|
self._update_from_device()
|
||||||
self.async_write_ha_state()
|
self.async_write_ha_state()
|
||||||
|
|
||||||
@ -124,14 +111,13 @@ class MatterEntity(Entity):
|
|||||||
self, attribute: type[ClusterAttributeDescriptor]
|
self, attribute: type[ClusterAttributeDescriptor]
|
||||||
) -> Any:
|
) -> Any:
|
||||||
"""Get current value for given attribute."""
|
"""Get current value for given attribute."""
|
||||||
return self._device_type_instance.get_attribute_value(None, attribute)
|
return self._endpoint.get_attribute_value(None, attribute)
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def get_matter_attribute_path(
|
def get_matter_attribute_path(
|
||||||
self, attribute: type[ClusterAttributeDescriptor]
|
self, attribute: type[ClusterAttributeDescriptor]
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return AttributePath by providing the endpoint and Attribute class."""
|
"""Return AttributePath by providing the endpoint and Attribute class."""
|
||||||
endpoint = self._device_type_instance.endpoint.endpoint_id
|
|
||||||
return create_attribute_path(
|
return create_attribute_path(
|
||||||
endpoint, attribute.cluster_id, attribute.attribute_id
|
self._endpoint.endpoint_id, attribute.cluster_id, attribute.attribute_id
|
||||||
)
|
)
|
||||||
|
@ -11,8 +11,7 @@ from homeassistant.helpers import device_registry as dr
|
|||||||
from .const import DOMAIN, ID_TYPE_DEVICE_ID
|
from .const import DOMAIN, ID_TYPE_DEVICE_ID
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from matter_server.client.models.node import MatterNode
|
from matter_server.client.models.node import MatterEndpoint, MatterNode
|
||||||
from matter_server.client.models.node_device import AbstractMatterNodeDevice
|
|
||||||
from matter_server.common.models import ServerInfoMessage
|
from matter_server.common.models import ServerInfoMessage
|
||||||
|
|
||||||
from .adapter import MatterAdapter
|
from .adapter import MatterAdapter
|
||||||
@ -50,15 +49,21 @@ def get_operational_instance_id(
|
|||||||
|
|
||||||
def get_device_id(
|
def get_device_id(
|
||||||
server_info: ServerInfoMessage,
|
server_info: ServerInfoMessage,
|
||||||
node_device: AbstractMatterNodeDevice,
|
endpoint: MatterEndpoint,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Return HA device_id for the given MatterNodeDevice."""
|
"""Return HA device_id for the given MatterEndpoint."""
|
||||||
operational_instance_id = get_operational_instance_id(
|
operational_instance_id = get_operational_instance_id(server_info, endpoint.node)
|
||||||
server_info, node_device.node()
|
# Append endpoint ID if this endpoint is a bridged or composed device
|
||||||
)
|
if endpoint.is_composed_device:
|
||||||
# Append nodedevice(type) to differentiate between a root node
|
compose_parent = endpoint.node.get_compose_parent(endpoint.endpoint_id)
|
||||||
# and bridge within Home Assistant devices.
|
assert compose_parent is not None
|
||||||
return f"{operational_instance_id}-{node_device.__class__.__name__}"
|
postfix = str(compose_parent.endpoint_id)
|
||||||
|
elif endpoint.is_bridged_device:
|
||||||
|
postfix = str(endpoint.endpoint_id)
|
||||||
|
else:
|
||||||
|
# this should be compatible with previous versions
|
||||||
|
postfix = "MatterNodeDevice"
|
||||||
|
return f"{operational_instance_id}-{postfix}"
|
||||||
|
|
||||||
|
|
||||||
async def get_node_from_device_entry(
|
async def get_node_from_device_entry(
|
||||||
@ -91,8 +96,8 @@ async def get_node_from_device_entry(
|
|||||||
(
|
(
|
||||||
node
|
node
|
||||||
for node in await matter_client.get_nodes()
|
for node in await matter_client.get_nodes()
|
||||||
for node_device in node.node_devices
|
for endpoint in node.endpoints.values()
|
||||||
if get_device_id(server_info, node_device) == device_id
|
if get_device_id(server_info, endpoint) == device_id
|
||||||
),
|
),
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
|
@ -1,9 +1,6 @@
|
|||||||
"""Matter light."""
|
"""Matter light."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from enum import Enum
|
|
||||||
from functools import partial
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from chip.clusters import Objects as clusters
|
from chip.clusters import Objects as clusters
|
||||||
@ -24,8 +21,9 @@ from homeassistant.core import HomeAssistant, callback
|
|||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .const import LOGGER
|
from .const import LOGGER
|
||||||
from .entity import MatterEntity, MatterEntityDescriptionBaseClass
|
from .entity import MatterEntity
|
||||||
from .helpers import get_matter
|
from .helpers import get_matter
|
||||||
|
from .models import MatterDiscoverySchema
|
||||||
from .util import (
|
from .util import (
|
||||||
convert_to_hass_hs,
|
convert_to_hass_hs,
|
||||||
convert_to_hass_xy,
|
convert_to_hass_xy,
|
||||||
@ -34,32 +32,13 @@ from .util import (
|
|||||||
renormalize,
|
renormalize,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class MatterColorMode(Enum):
|
|
||||||
"""Matter color mode."""
|
|
||||||
|
|
||||||
HS = 0
|
|
||||||
XY = 1
|
|
||||||
COLOR_TEMP = 2
|
|
||||||
|
|
||||||
|
|
||||||
COLOR_MODE_MAP = {
|
COLOR_MODE_MAP = {
|
||||||
MatterColorMode.HS: ColorMode.HS,
|
clusters.ColorControl.Enums.ColorMode.kCurrentHueAndCurrentSaturation: ColorMode.HS,
|
||||||
MatterColorMode.XY: ColorMode.XY,
|
clusters.ColorControl.Enums.ColorMode.kCurrentXAndCurrentY: ColorMode.XY,
|
||||||
MatterColorMode.COLOR_TEMP: ColorMode.COLOR_TEMP,
|
clusters.ColorControl.Enums.ColorMode.kColorTemperature: ColorMode.COLOR_TEMP,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class MatterColorControlFeatures(Enum):
|
|
||||||
"""Matter color control features."""
|
|
||||||
|
|
||||||
HS = 0 # Hue and saturation (Optional if device is color capable)
|
|
||||||
EHUE = 1 # Enhanced hue and saturation (Optional if device is color capable)
|
|
||||||
COLOR_LOOP = 2 # Color loop (Optional if device is color capable)
|
|
||||||
XY = 3 # XY (Mandatory if device is color capable)
|
|
||||||
COLOR_TEMP = 4 # Color temperature (Mandatory if device is color capable)
|
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
config_entry: ConfigEntry,
|
config_entry: ConfigEntry,
|
||||||
@ -73,63 +52,37 @@ async def async_setup_entry(
|
|||||||
class MatterLight(MatterEntity, LightEntity):
|
class MatterLight(MatterEntity, LightEntity):
|
||||||
"""Representation of a Matter light."""
|
"""Representation of a Matter light."""
|
||||||
|
|
||||||
entity_description: MatterLightEntityDescription
|
entity_description: LightEntityDescription
|
||||||
|
|
||||||
def _supports_feature(
|
|
||||||
self, feature_map: int, feature: MatterColorControlFeatures
|
|
||||||
) -> bool:
|
|
||||||
"""Return if device supports given feature."""
|
|
||||||
|
|
||||||
return (feature_map & (1 << feature.value)) != 0
|
|
||||||
|
|
||||||
def _supports_color_mode(self, color_feature: MatterColorControlFeatures) -> bool:
|
|
||||||
"""Return if device supports given color mode."""
|
|
||||||
|
|
||||||
feature_map = self.get_matter_attribute_value(
|
|
||||||
clusters.ColorControl.Attributes.FeatureMap,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert isinstance(feature_map, int)
|
|
||||||
|
|
||||||
return self._supports_feature(feature_map, color_feature)
|
|
||||||
|
|
||||||
def _supports_hs_color(self) -> bool:
|
|
||||||
"""Return if device supports hs color."""
|
|
||||||
|
|
||||||
return self._supports_color_mode(MatterColorControlFeatures.HS)
|
|
||||||
|
|
||||||
def _supports_xy_color(self) -> bool:
|
|
||||||
"""Return if device supports xy color."""
|
|
||||||
|
|
||||||
return self._supports_color_mode(MatterColorControlFeatures.XY)
|
|
||||||
|
|
||||||
def _supports_color_temperature(self) -> bool:
|
|
||||||
"""Return if device supports color temperature."""
|
|
||||||
|
|
||||||
return self._supports_color_mode(MatterColorControlFeatures.COLOR_TEMP)
|
|
||||||
|
|
||||||
def _supports_brightness(self) -> bool:
|
|
||||||
"""Return if device supports brightness."""
|
|
||||||
|
|
||||||
|
@property
|
||||||
|
def supports_color(self) -> bool:
|
||||||
|
"""Return if the device supports color control."""
|
||||||
|
if not self._attr_supported_color_modes:
|
||||||
|
return False
|
||||||
return (
|
return (
|
||||||
clusters.LevelControl.Attributes.CurrentLevel
|
ColorMode.HS in self._attr_supported_color_modes
|
||||||
in self.entity_description.subscribe_attributes
|
or ColorMode.XY in self._attr_supported_color_modes
|
||||||
)
|
)
|
||||||
|
|
||||||
def _supports_color(self) -> bool:
|
@property
|
||||||
"""Return if device supports color."""
|
def supports_color_temperature(self) -> bool:
|
||||||
|
"""Return if the device supports color temperature control."""
|
||||||
|
if not self._attr_supported_color_modes:
|
||||||
|
return False
|
||||||
|
return ColorMode.COLOR_TEMP in self._attr_supported_color_modes
|
||||||
|
|
||||||
return (
|
@property
|
||||||
clusters.ColorControl.Attributes.ColorMode
|
def supports_brightness(self) -> bool:
|
||||||
in self.entity_description.subscribe_attributes
|
"""Return if the device supports bridghtness control."""
|
||||||
)
|
if not self._attr_supported_color_modes:
|
||||||
|
return False
|
||||||
|
return ColorMode.BRIGHTNESS in self._attr_supported_color_modes
|
||||||
|
|
||||||
async def _set_xy_color(self, xy_color: tuple[float, float]) -> None:
|
async def _set_xy_color(self, xy_color: tuple[float, float]) -> None:
|
||||||
"""Set xy color."""
|
"""Set xy color."""
|
||||||
|
|
||||||
matter_xy = convert_to_matter_xy(xy_color)
|
matter_xy = convert_to_matter_xy(xy_color)
|
||||||
|
|
||||||
LOGGER.debug("Setting xy color to %s", matter_xy)
|
|
||||||
await self.send_device_command(
|
await self.send_device_command(
|
||||||
clusters.ColorControl.Commands.MoveToColor(
|
clusters.ColorControl.Commands.MoveToColor(
|
||||||
colorX=int(matter_xy[0]),
|
colorX=int(matter_xy[0]),
|
||||||
@ -144,7 +97,6 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
|
|
||||||
matter_hs = convert_to_matter_hs(hs_color)
|
matter_hs = convert_to_matter_hs(hs_color)
|
||||||
|
|
||||||
LOGGER.debug("Setting hs color to %s", matter_hs)
|
|
||||||
await self.send_device_command(
|
await self.send_device_command(
|
||||||
clusters.ColorControl.Commands.MoveToHueAndSaturation(
|
clusters.ColorControl.Commands.MoveToHueAndSaturation(
|
||||||
hue=int(matter_hs[0]),
|
hue=int(matter_hs[0]),
|
||||||
@ -157,7 +109,6 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
async def _set_color_temp(self, color_temp: int) -> None:
|
async def _set_color_temp(self, color_temp: int) -> None:
|
||||||
"""Set color temperature."""
|
"""Set color temperature."""
|
||||||
|
|
||||||
LOGGER.debug("Setting color temperature to %s", color_temp)
|
|
||||||
await self.send_device_command(
|
await self.send_device_command(
|
||||||
clusters.ColorControl.Commands.MoveToColorTemperature(
|
clusters.ColorControl.Commands.MoveToColorTemperature(
|
||||||
colorTemperature=color_temp,
|
colorTemperature=color_temp,
|
||||||
@ -169,8 +120,7 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
async def _set_brightness(self, brightness: int) -> None:
|
async def _set_brightness(self, brightness: int) -> None:
|
||||||
"""Set brightness."""
|
"""Set brightness."""
|
||||||
|
|
||||||
LOGGER.debug("Setting brightness to %s", brightness)
|
level_control = self._endpoint.get_cluster(clusters.LevelControl)
|
||||||
level_control = self._device_type_instance.get_cluster(clusters.LevelControl)
|
|
||||||
|
|
||||||
assert level_control is not None
|
assert level_control is not None
|
||||||
|
|
||||||
@ -207,7 +157,7 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Got xy color %s for %s",
|
"Got xy color %s for %s",
|
||||||
xy_color,
|
xy_color,
|
||||||
self._device_type_instance,
|
self.entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return xy_color
|
return xy_color
|
||||||
@ -231,7 +181,7 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Got hs color %s for %s",
|
"Got hs color %s for %s",
|
||||||
hs_color,
|
hs_color,
|
||||||
self._device_type_instance,
|
self.entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return hs_color
|
return hs_color
|
||||||
@ -248,7 +198,7 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Got color temperature %s for %s",
|
"Got color temperature %s for %s",
|
||||||
color_temp,
|
color_temp,
|
||||||
self._device_type_instance,
|
self.entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return int(color_temp)
|
return int(color_temp)
|
||||||
@ -256,7 +206,7 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
def _get_brightness(self) -> int:
|
def _get_brightness(self) -> int:
|
||||||
"""Get brightness from matter."""
|
"""Get brightness from matter."""
|
||||||
|
|
||||||
level_control = self._device_type_instance.get_cluster(clusters.LevelControl)
|
level_control = self._endpoint.get_cluster(clusters.LevelControl)
|
||||||
|
|
||||||
# We should not get here if brightness is not supported.
|
# We should not get here if brightness is not supported.
|
||||||
assert level_control is not None
|
assert level_control is not None
|
||||||
@ -264,7 +214,7 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
LOGGER.debug( # type: ignore[unreachable]
|
LOGGER.debug( # type: ignore[unreachable]
|
||||||
"Got brightness %s for %s",
|
"Got brightness %s for %s",
|
||||||
level_control.currentLevel,
|
level_control.currentLevel,
|
||||||
self._device_type_instance,
|
self.entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return round(
|
return round(
|
||||||
@ -284,10 +234,12 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
|
|
||||||
assert color_mode is not None
|
assert color_mode is not None
|
||||||
|
|
||||||
ha_color_mode = COLOR_MODE_MAP[MatterColorMode(color_mode)]
|
ha_color_mode = COLOR_MODE_MAP[color_mode]
|
||||||
|
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Got color mode (%s) for %s", ha_color_mode, self._device_type_instance
|
"Got color mode (%s) for %s",
|
||||||
|
ha_color_mode,
|
||||||
|
self.entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
return ha_color_mode
|
return ha_color_mode
|
||||||
@ -295,8 +247,8 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
async def send_device_command(self, command: Any) -> None:
|
async def send_device_command(self, command: Any) -> None:
|
||||||
"""Send device command."""
|
"""Send device command."""
|
||||||
await self.matter_client.send_device_command(
|
await self.matter_client.send_device_command(
|
||||||
node_id=self._device_type_instance.node.node_id,
|
node_id=self._endpoint.node.node_id,
|
||||||
endpoint_id=self._device_type_instance.endpoint_id,
|
endpoint_id=self._endpoint.endpoint_id,
|
||||||
command=command,
|
command=command,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -308,15 +260,14 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
color_temp = kwargs.get(ATTR_COLOR_TEMP)
|
color_temp = kwargs.get(ATTR_COLOR_TEMP)
|
||||||
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
brightness = kwargs.get(ATTR_BRIGHTNESS)
|
||||||
|
|
||||||
if self._supports_color():
|
if hs_color is not None and self.supports_color:
|
||||||
if hs_color is not None and self._supports_hs_color():
|
await self._set_hs_color(hs_color)
|
||||||
await self._set_hs_color(hs_color)
|
elif xy_color is not None:
|
||||||
elif xy_color is not None and self._supports_xy_color():
|
await self._set_xy_color(xy_color)
|
||||||
await self._set_xy_color(xy_color)
|
elif color_temp is not None and self.supports_color_temperature:
|
||||||
elif color_temp is not None and self._supports_color_temperature():
|
await self._set_color_temp(color_temp)
|
||||||
await self._set_color_temp(color_temp)
|
|
||||||
|
|
||||||
if brightness is not None and self._supports_brightness():
|
if brightness is not None and self.supports_brightness:
|
||||||
await self._set_brightness(brightness)
|
await self._set_brightness(brightness)
|
||||||
return
|
return
|
||||||
|
|
||||||
@ -334,106 +285,80 @@ class MatterLight(MatterEntity, LightEntity):
|
|||||||
def _update_from_device(self) -> None:
|
def _update_from_device(self) -> None:
|
||||||
"""Update from device."""
|
"""Update from device."""
|
||||||
|
|
||||||
supports_color = self._supports_color()
|
|
||||||
supports_color_temperature = (
|
|
||||||
self._supports_color_temperature() if supports_color else False
|
|
||||||
)
|
|
||||||
supports_brightness = self._supports_brightness()
|
|
||||||
|
|
||||||
if self._attr_supported_color_modes is None:
|
if self._attr_supported_color_modes is None:
|
||||||
supported_color_modes = set()
|
# work out what (color)features are supported
|
||||||
if supports_color:
|
supported_color_modes: set[ColorMode] = set()
|
||||||
supported_color_modes.add(ColorMode.XY)
|
# brightness support
|
||||||
if self._supports_hs_color():
|
if self._entity_info.endpoint.has_attribute(
|
||||||
supported_color_modes.add(ColorMode.HS)
|
None, clusters.LevelControl.Attributes.CurrentLevel
|
||||||
|
):
|
||||||
if supports_color_temperature:
|
|
||||||
supported_color_modes.add(ColorMode.COLOR_TEMP)
|
|
||||||
|
|
||||||
if supports_brightness:
|
|
||||||
supported_color_modes.add(ColorMode.BRIGHTNESS)
|
supported_color_modes.add(ColorMode.BRIGHTNESS)
|
||||||
|
# colormode(s)
|
||||||
|
if self._entity_info.endpoint.has_attribute(
|
||||||
|
None, clusters.ColorControl.Attributes.ColorMode
|
||||||
|
):
|
||||||
|
# device has some color support, check which color modes
|
||||||
|
# are supported with the featuremap on the ColorControl cluster
|
||||||
|
color_feature_map = self.get_matter_attribute_value(
|
||||||
|
clusters.ColorControl.Attributes.FeatureMap,
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
color_feature_map
|
||||||
|
& clusters.ColorControl.Attributes.CurrentHue.attribute_id
|
||||||
|
):
|
||||||
|
supported_color_modes.add(ColorMode.HS)
|
||||||
|
if (
|
||||||
|
color_feature_map
|
||||||
|
& clusters.ColorControl.Attributes.CurrentX.attribute_id
|
||||||
|
):
|
||||||
|
supported_color_modes.add(ColorMode.XY)
|
||||||
|
|
||||||
self._attr_supported_color_modes = (
|
# color temperature support detection using the featuremap is not reliable
|
||||||
supported_color_modes if supported_color_modes else None
|
# (temporary?) fallback to checking the value
|
||||||
|
if (
|
||||||
|
self.get_matter_attribute_value(
|
||||||
|
clusters.ColorControl.Attributes.ColorTemperatureMireds
|
||||||
|
)
|
||||||
|
is not None
|
||||||
|
):
|
||||||
|
supported_color_modes.add(ColorMode.COLOR_TEMP)
|
||||||
|
|
||||||
|
self._attr_supported_color_modes = supported_color_modes
|
||||||
|
|
||||||
|
LOGGER.debug(
|
||||||
|
"Supported color modes: %s for %s",
|
||||||
|
self._attr_supported_color_modes,
|
||||||
|
self.entity_id,
|
||||||
)
|
)
|
||||||
|
|
||||||
LOGGER.debug(
|
# set current values
|
||||||
"Supported color modes: %s for %s",
|
|
||||||
self._attr_supported_color_modes,
|
|
||||||
self._device_type_instance,
|
|
||||||
)
|
|
||||||
|
|
||||||
if supports_color:
|
if self.supports_color:
|
||||||
self._attr_color_mode = self._get_color_mode()
|
self._attr_color_mode = self._get_color_mode()
|
||||||
if self._attr_color_mode == ColorMode.HS:
|
if self._attr_color_mode == ColorMode.HS:
|
||||||
self._attr_hs_color = self._get_hs_color()
|
self._attr_hs_color = self._get_hs_color()
|
||||||
else:
|
else:
|
||||||
self._attr_xy_color = self._get_xy_color()
|
self._attr_xy_color = self._get_xy_color()
|
||||||
|
|
||||||
if supports_color_temperature:
|
if self.supports_color_temperature:
|
||||||
self._attr_color_temp = self._get_color_temperature()
|
self._attr_color_temp = self._get_color_temperature()
|
||||||
|
|
||||||
self._attr_is_on = self.get_matter_attribute_value(
|
self._attr_is_on = self.get_matter_attribute_value(
|
||||||
clusters.OnOff.Attributes.OnOff
|
clusters.OnOff.Attributes.OnOff
|
||||||
)
|
)
|
||||||
|
|
||||||
if supports_brightness:
|
if self.supports_brightness:
|
||||||
self._attr_brightness = self._get_brightness()
|
self._attr_brightness = self._get_brightness()
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||||
class MatterLightEntityDescription(
|
DISCOVERY_SCHEMAS = [
|
||||||
LightEntityDescription,
|
MatterDiscoverySchema(
|
||||||
MatterEntityDescriptionBaseClass,
|
platform=Platform.LIGHT,
|
||||||
):
|
entity_description=LightEntityDescription(key="ExtendedMatterLight"),
|
||||||
"""Matter light entity description."""
|
entity_class=MatterLight,
|
||||||
|
required_attributes=(clusters.OnOff.Attributes.OnOff,),
|
||||||
|
optional_attributes=(
|
||||||
# You can't set default values on inherited data classes
|
|
||||||
MatterLightEntityDescriptionFactory = partial(
|
|
||||||
MatterLightEntityDescription, entity_cls=MatterLight
|
|
||||||
)
|
|
||||||
|
|
||||||
# Mapping of a Matter Device type to Light Entity Description.
|
|
||||||
# A Matter device type (instance) can consist of multiple attributes.
|
|
||||||
# For example a Color Light which has an attribute to control brightness
|
|
||||||
# but also for color.
|
|
||||||
|
|
||||||
DEVICE_ENTITY: dict[
|
|
||||||
type[device_types.DeviceType],
|
|
||||||
MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass],
|
|
||||||
] = {
|
|
||||||
device_types.OnOffLight: MatterLightEntityDescriptionFactory(
|
|
||||||
key=device_types.OnOffLight,
|
|
||||||
subscribe_attributes=(clusters.OnOff.Attributes.OnOff,),
|
|
||||||
),
|
|
||||||
device_types.DimmableLight: MatterLightEntityDescriptionFactory(
|
|
||||||
key=device_types.DimmableLight,
|
|
||||||
subscribe_attributes=(
|
|
||||||
clusters.OnOff.Attributes.OnOff,
|
|
||||||
clusters.LevelControl.Attributes.CurrentLevel,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
device_types.DimmablePlugInUnit: MatterLightEntityDescriptionFactory(
|
|
||||||
key=device_types.DimmablePlugInUnit,
|
|
||||||
subscribe_attributes=(
|
|
||||||
clusters.OnOff.Attributes.OnOff,
|
|
||||||
clusters.LevelControl.Attributes.CurrentLevel,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
device_types.ColorTemperatureLight: MatterLightEntityDescriptionFactory(
|
|
||||||
key=device_types.ColorTemperatureLight,
|
|
||||||
subscribe_attributes=(
|
|
||||||
clusters.OnOff.Attributes.OnOff,
|
|
||||||
clusters.LevelControl.Attributes.CurrentLevel,
|
|
||||||
clusters.ColorControl.Attributes.ColorMode,
|
|
||||||
clusters.ColorControl.Attributes.ColorTemperatureMireds,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
device_types.ExtendedColorLight: MatterLightEntityDescriptionFactory(
|
|
||||||
key=device_types.ExtendedColorLight,
|
|
||||||
subscribe_attributes=(
|
|
||||||
clusters.OnOff.Attributes.OnOff,
|
|
||||||
clusters.LevelControl.Attributes.CurrentLevel,
|
clusters.LevelControl.Attributes.CurrentLevel,
|
||||||
clusters.ColorControl.Attributes.ColorMode,
|
clusters.ColorControl.Attributes.ColorMode,
|
||||||
clusters.ColorControl.Attributes.CurrentHue,
|
clusters.ColorControl.Attributes.CurrentHue,
|
||||||
@ -442,5 +367,7 @@ DEVICE_ENTITY: dict[
|
|||||||
clusters.ColorControl.Attributes.CurrentY,
|
clusters.ColorControl.Attributes.CurrentY,
|
||||||
clusters.ColorControl.Attributes.ColorTemperatureMireds,
|
clusters.ColorControl.Attributes.ColorTemperatureMireds,
|
||||||
),
|
),
|
||||||
|
# restrict device type to prevent discovery in switch platform
|
||||||
|
not_device_type=(device_types.OnOffPlugInUnit,),
|
||||||
),
|
),
|
||||||
}
|
]
|
||||||
|
@ -6,5 +6,5 @@
|
|||||||
"dependencies": ["websocket_api"],
|
"dependencies": ["websocket_api"],
|
||||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"requirements": ["python-matter-server==3.0.0"]
|
"requirements": ["python-matter-server==3.1.0"]
|
||||||
}
|
}
|
||||||
|
109
homeassistant/components/matter/models.py
Normal file
109
homeassistant/components/matter/models.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
"""Models used for the Matter integration."""
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import asdict, dataclass
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from chip.clusters import Objects as clusters
|
||||||
|
from chip.clusters.Objects import ClusterAttributeDescriptor
|
||||||
|
from matter_server.client.models.device_types import DeviceType
|
||||||
|
from matter_server.client.models.node import MatterEndpoint
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
|
from homeassistant.helpers.entity import EntityDescription
|
||||||
|
|
||||||
|
|
||||||
|
class DataclassMustHaveAtLeastOne:
|
||||||
|
"""A dataclass that must have at least one input parameter that is not None."""
|
||||||
|
|
||||||
|
def __post_init__(self) -> None:
|
||||||
|
"""Post dataclass initialization."""
|
||||||
|
if all(val is None for val in asdict(self).values()):
|
||||||
|
raise ValueError("At least one input parameter must not be None")
|
||||||
|
|
||||||
|
|
||||||
|
SensorValueTypes = type[
|
||||||
|
clusters.uint | int | clusters.Nullable | clusters.float32 | float
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatterEntityInfo:
|
||||||
|
"""Info discovered from (primary) Matter Attribute to create entity."""
|
||||||
|
|
||||||
|
# MatterEndpoint to which the value(s) belongs
|
||||||
|
endpoint: MatterEndpoint
|
||||||
|
|
||||||
|
# the home assistant platform for which an entity should be created
|
||||||
|
platform: Platform
|
||||||
|
|
||||||
|
# All attributes that need to be watched by entity (incl. primary)
|
||||||
|
attributes_to_watch: list[type[ClusterAttributeDescriptor]]
|
||||||
|
|
||||||
|
# the entity description to use
|
||||||
|
entity_description: EntityDescription
|
||||||
|
|
||||||
|
# entity class to use to instantiate the entity
|
||||||
|
entity_class: type
|
||||||
|
|
||||||
|
# [optional] function to call to convert the value from the primary attribute
|
||||||
|
measurement_to_ha: Callable[[SensorValueTypes], SensorValueTypes] | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def primary_attribute(self) -> type[ClusterAttributeDescriptor]:
|
||||||
|
"""Return Primary Attribute belonging to the entity."""
|
||||||
|
return self.attributes_to_watch[0]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MatterDiscoverySchema:
|
||||||
|
"""Matter discovery schema.
|
||||||
|
|
||||||
|
The Matter endpoint and it's (primary) Attribute for an entity must match these conditions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
# specify the hass platform for which this scheme applies (e.g. light, sensor)
|
||||||
|
platform: Platform
|
||||||
|
|
||||||
|
# platform-specific entity description
|
||||||
|
entity_description: EntityDescription
|
||||||
|
|
||||||
|
# entity class to use to instantiate the entity
|
||||||
|
entity_class: type
|
||||||
|
|
||||||
|
# DISCOVERY OPTIONS
|
||||||
|
|
||||||
|
# [required] attributes that ALL need to be present
|
||||||
|
# on the node for this scheme to pass (minimal one == primary)
|
||||||
|
required_attributes: tuple[type[ClusterAttributeDescriptor], ...]
|
||||||
|
|
||||||
|
# [optional] the value's endpoint must contain this devicetype(s)
|
||||||
|
device_type: tuple[type[DeviceType] | DeviceType, ...] | None = None
|
||||||
|
|
||||||
|
# [optional] the value's endpoint must NOT contain this devicetype(s)
|
||||||
|
not_device_type: tuple[type[DeviceType] | DeviceType, ...] | None = None
|
||||||
|
|
||||||
|
# [optional] the endpoint's vendor_id must match ANY of these values
|
||||||
|
vendor_id: tuple[int, ...] | None = None
|
||||||
|
|
||||||
|
# [optional] the endpoint's product_name must match ANY of these values
|
||||||
|
product_name: tuple[str, ...] | None = None
|
||||||
|
|
||||||
|
# [optional] the attribute's endpoint_id must match ANY of these values
|
||||||
|
endpoint_id: tuple[int, ...] | None = None
|
||||||
|
|
||||||
|
# [optional] additional attributes that MAY NOT be present
|
||||||
|
# on the node for this scheme to pass
|
||||||
|
absent_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None
|
||||||
|
|
||||||
|
# [optional] additional attributes that may be present
|
||||||
|
# these attributes are copied over to attributes_to_watch and
|
||||||
|
# are not discovered by other entities
|
||||||
|
optional_attributes: tuple[type[ClusterAttributeDescriptor], ...] | None = None
|
||||||
|
|
||||||
|
# [optional] bool to specify if this primary value may be discovered
|
||||||
|
# by multiple platforms
|
||||||
|
allow_multi: bool = False
|
||||||
|
|
||||||
|
# [optional] function to call to convert the value from the primary attribute
|
||||||
|
measurement_to_ha: Callable[[Any], Any] | None = None
|
@ -1,13 +1,8 @@
|
|||||||
"""Matter sensors."""
|
"""Matter sensors."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import partial
|
|
||||||
|
|
||||||
from chip.clusters import Objects as clusters
|
from chip.clusters import Objects as clusters
|
||||||
from chip.clusters.Types import Nullable, NullValue
|
from chip.clusters.Types import Nullable, NullValue
|
||||||
from matter_server.client.models import device_types
|
|
||||||
|
|
||||||
from homeassistant.components.sensor import (
|
from homeassistant.components.sensor import (
|
||||||
SensorDeviceClass,
|
SensorDeviceClass,
|
||||||
@ -27,8 +22,9 @@ from homeassistant.const import (
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .entity import MatterEntity, MatterEntityDescriptionBaseClass
|
from .entity import MatterEntity
|
||||||
from .helpers import get_matter
|
from .helpers import get_matter
|
||||||
|
from .models import MatterDiscoverySchema
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@ -45,94 +41,94 @@ class MatterSensor(MatterEntity, SensorEntity):
|
|||||||
"""Representation of a Matter sensor."""
|
"""Representation of a Matter sensor."""
|
||||||
|
|
||||||
_attr_state_class = SensorStateClass.MEASUREMENT
|
_attr_state_class = SensorStateClass.MEASUREMENT
|
||||||
entity_description: MatterSensorEntityDescription
|
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def _update_from_device(self) -> None:
|
def _update_from_device(self) -> None:
|
||||||
"""Update from device."""
|
"""Update from device."""
|
||||||
measurement: Nullable | float | None
|
value: Nullable | float | None
|
||||||
measurement = self.get_matter_attribute_value(
|
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
|
||||||
# We always subscribe to a single value
|
if value in (None, NullValue):
|
||||||
self.entity_description.subscribe_attributes[0],
|
value = None
|
||||||
)
|
elif value_convert := self._entity_info.measurement_to_ha:
|
||||||
|
value = value_convert(value)
|
||||||
if measurement == NullValue or measurement is None:
|
self._attr_native_value = value
|
||||||
measurement = None
|
|
||||||
else:
|
|
||||||
measurement = self.entity_description.measurement_to_ha(measurement)
|
|
||||||
|
|
||||||
self._attr_native_value = measurement
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||||
class MatterSensorEntityDescriptionMixin:
|
DISCOVERY_SCHEMAS = [
|
||||||
"""Required fields for sensor device mapping."""
|
MatterDiscoverySchema(
|
||||||
|
platform=Platform.SENSOR,
|
||||||
measurement_to_ha: Callable[[float], float]
|
entity_description=SensorEntityDescription(
|
||||||
|
key="TemperatureSensor",
|
||||||
|
name="Temperature",
|
||||||
@dataclass
|
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
||||||
class MatterSensorEntityDescription(
|
device_class=SensorDeviceClass.TEMPERATURE,
|
||||||
SensorEntityDescription,
|
|
||||||
MatterEntityDescriptionBaseClass,
|
|
||||||
MatterSensorEntityDescriptionMixin,
|
|
||||||
):
|
|
||||||
"""Matter Sensor entity description."""
|
|
||||||
|
|
||||||
|
|
||||||
# You can't set default values on inherited data classes
|
|
||||||
MatterSensorEntityDescriptionFactory = partial(
|
|
||||||
MatterSensorEntityDescription, entity_cls=MatterSensor
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
DEVICE_ENTITY: dict[
|
|
||||||
type[device_types.DeviceType],
|
|
||||||
MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass],
|
|
||||||
] = {
|
|
||||||
device_types.TemperatureSensor: MatterSensorEntityDescriptionFactory(
|
|
||||||
key=device_types.TemperatureSensor,
|
|
||||||
name="Temperature",
|
|
||||||
measurement_to_ha=lambda x: x / 100,
|
|
||||||
subscribe_attributes=(
|
|
||||||
clusters.TemperatureMeasurement.Attributes.MeasuredValue,
|
|
||||||
),
|
),
|
||||||
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
|
entity_class=MatterSensor,
|
||||||
device_class=SensorDeviceClass.TEMPERATURE,
|
required_attributes=(clusters.TemperatureMeasurement.Attributes.MeasuredValue,),
|
||||||
),
|
|
||||||
device_types.PressureSensor: MatterSensorEntityDescriptionFactory(
|
|
||||||
key=device_types.PressureSensor,
|
|
||||||
name="Pressure",
|
|
||||||
measurement_to_ha=lambda x: x / 10,
|
|
||||||
subscribe_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,),
|
|
||||||
native_unit_of_measurement=UnitOfPressure.KPA,
|
|
||||||
device_class=SensorDeviceClass.PRESSURE,
|
|
||||||
),
|
|
||||||
device_types.FlowSensor: MatterSensorEntityDescriptionFactory(
|
|
||||||
key=device_types.FlowSensor,
|
|
||||||
name="Flow",
|
|
||||||
measurement_to_ha=lambda x: x / 10,
|
|
||||||
subscribe_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,),
|
|
||||||
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
|
||||||
),
|
|
||||||
device_types.HumiditySensor: MatterSensorEntityDescriptionFactory(
|
|
||||||
key=device_types.HumiditySensor,
|
|
||||||
name="Humidity",
|
|
||||||
measurement_to_ha=lambda x: x / 100,
|
measurement_to_ha=lambda x: x / 100,
|
||||||
subscribe_attributes=(
|
),
|
||||||
|
MatterDiscoverySchema(
|
||||||
|
platform=Platform.SENSOR,
|
||||||
|
entity_description=SensorEntityDescription(
|
||||||
|
key="PressureSensor",
|
||||||
|
name="Pressure",
|
||||||
|
native_unit_of_measurement=UnitOfPressure.KPA,
|
||||||
|
device_class=SensorDeviceClass.PRESSURE,
|
||||||
|
),
|
||||||
|
entity_class=MatterSensor,
|
||||||
|
required_attributes=(clusters.PressureMeasurement.Attributes.MeasuredValue,),
|
||||||
|
measurement_to_ha=lambda x: x / 10,
|
||||||
|
),
|
||||||
|
MatterDiscoverySchema(
|
||||||
|
platform=Platform.SENSOR,
|
||||||
|
entity_description=SensorEntityDescription(
|
||||||
|
key="FlowSensor",
|
||||||
|
name="Flow",
|
||||||
|
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
|
||||||
|
device_class=SensorDeviceClass.WATER, # what is the device class here ?
|
||||||
|
),
|
||||||
|
entity_class=MatterSensor,
|
||||||
|
required_attributes=(clusters.FlowMeasurement.Attributes.MeasuredValue,),
|
||||||
|
measurement_to_ha=lambda x: x / 10,
|
||||||
|
),
|
||||||
|
MatterDiscoverySchema(
|
||||||
|
platform=Platform.SENSOR,
|
||||||
|
entity_description=SensorEntityDescription(
|
||||||
|
key="HumiditySensor",
|
||||||
|
name="Humidity",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
device_class=SensorDeviceClass.HUMIDITY,
|
||||||
|
),
|
||||||
|
entity_class=MatterSensor,
|
||||||
|
required_attributes=(
|
||||||
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
|
clusters.RelativeHumidityMeasurement.Attributes.MeasuredValue,
|
||||||
),
|
),
|
||||||
native_unit_of_measurement=PERCENTAGE,
|
measurement_to_ha=lambda x: x / 100,
|
||||||
device_class=SensorDeviceClass.HUMIDITY,
|
|
||||||
),
|
),
|
||||||
device_types.LightSensor: MatterSensorEntityDescriptionFactory(
|
MatterDiscoverySchema(
|
||||||
key=device_types.LightSensor,
|
platform=Platform.SENSOR,
|
||||||
name="Light",
|
entity_description=SensorEntityDescription(
|
||||||
measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1),
|
key="LightSensor",
|
||||||
subscribe_attributes=(
|
name="Illuminance",
|
||||||
clusters.IlluminanceMeasurement.Attributes.MeasuredValue,
|
native_unit_of_measurement=LIGHT_LUX,
|
||||||
|
device_class=SensorDeviceClass.ILLUMINANCE,
|
||||||
),
|
),
|
||||||
native_unit_of_measurement=LIGHT_LUX,
|
entity_class=MatterSensor,
|
||||||
device_class=SensorDeviceClass.ILLUMINANCE,
|
required_attributes=(clusters.IlluminanceMeasurement.Attributes.MeasuredValue,),
|
||||||
|
measurement_to_ha=lambda x: round(pow(10, ((x - 1) / 10000)), 1),
|
||||||
),
|
),
|
||||||
}
|
MatterDiscoverySchema(
|
||||||
|
platform=Platform.SENSOR,
|
||||||
|
entity_description=SensorEntityDescription(
|
||||||
|
key="PowerSource",
|
||||||
|
name="Battery",
|
||||||
|
native_unit_of_measurement=PERCENTAGE,
|
||||||
|
device_class=SensorDeviceClass.BATTERY,
|
||||||
|
),
|
||||||
|
entity_class=MatterSensor,
|
||||||
|
required_attributes=(clusters.PowerSource.Attributes.BatPercentRemaining,),
|
||||||
|
# value has double precision
|
||||||
|
measurement_to_ha=lambda x: int(x / 2),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
@ -1,8 +1,6 @@
|
|||||||
"""Matter switches."""
|
"""Matter switches."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from functools import partial
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from chip.clusters import Objects as clusters
|
from chip.clusters import Objects as clusters
|
||||||
@ -18,8 +16,9 @@ from homeassistant.const import Platform
|
|||||||
from homeassistant.core import HomeAssistant, callback
|
from homeassistant.core import HomeAssistant, callback
|
||||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
|
||||||
from .entity import MatterEntity, MatterEntityDescriptionBaseClass
|
from .entity import MatterEntity
|
||||||
from .helpers import get_matter
|
from .helpers import get_matter
|
||||||
|
from .models import MatterDiscoverySchema
|
||||||
|
|
||||||
|
|
||||||
async def async_setup_entry(
|
async def async_setup_entry(
|
||||||
@ -35,21 +34,19 @@ async def async_setup_entry(
|
|||||||
class MatterSwitch(MatterEntity, SwitchEntity):
|
class MatterSwitch(MatterEntity, SwitchEntity):
|
||||||
"""Representation of a Matter switch."""
|
"""Representation of a Matter switch."""
|
||||||
|
|
||||||
entity_description: MatterSwitchEntityDescription
|
|
||||||
|
|
||||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||||
"""Turn switch on."""
|
"""Turn switch on."""
|
||||||
await self.matter_client.send_device_command(
|
await self.matter_client.send_device_command(
|
||||||
node_id=self._device_type_instance.node.node_id,
|
node_id=self._endpoint.node.node_id,
|
||||||
endpoint_id=self._device_type_instance.endpoint_id,
|
endpoint_id=self._endpoint.endpoint_id,
|
||||||
command=clusters.OnOff.Commands.On(),
|
command=clusters.OnOff.Commands.On(),
|
||||||
)
|
)
|
||||||
|
|
||||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||||
"""Turn switch off."""
|
"""Turn switch off."""
|
||||||
await self.matter_client.send_device_command(
|
await self.matter_client.send_device_command(
|
||||||
node_id=self._device_type_instance.node.node_id,
|
node_id=self._endpoint.node.node_id,
|
||||||
endpoint_id=self._device_type_instance.endpoint_id,
|
endpoint_id=self._endpoint.endpoint_id,
|
||||||
command=clusters.OnOff.Commands.Off(),
|
command=clusters.OnOff.Commands.Off(),
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -57,31 +54,21 @@ class MatterSwitch(MatterEntity, SwitchEntity):
|
|||||||
def _update_from_device(self) -> None:
|
def _update_from_device(self) -> None:
|
||||||
"""Update from device."""
|
"""Update from device."""
|
||||||
self._attr_is_on = self.get_matter_attribute_value(
|
self._attr_is_on = self.get_matter_attribute_value(
|
||||||
clusters.OnOff.Attributes.OnOff
|
self._entity_info.primary_attribute
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||||
class MatterSwitchEntityDescription(
|
DISCOVERY_SCHEMAS = [
|
||||||
SwitchEntityDescription,
|
MatterDiscoverySchema(
|
||||||
MatterEntityDescriptionBaseClass,
|
platform=Platform.SWITCH,
|
||||||
):
|
entity_description=SwitchEntityDescription(
|
||||||
"""Matter Switch entity description."""
|
key="MatterPlug", device_class=SwitchDeviceClass.OUTLET
|
||||||
|
),
|
||||||
|
entity_class=MatterSwitch,
|
||||||
# You can't set default values on inherited data classes
|
required_attributes=(clusters.OnOff.Attributes.OnOff,),
|
||||||
MatterSwitchEntityDescriptionFactory = partial(
|
# restrict device type to prevent discovery by light
|
||||||
MatterSwitchEntityDescription, entity_cls=MatterSwitch
|
# platform which also uses OnOff cluster
|
||||||
)
|
not_device_type=(device_types.OnOffLight, device_types.DimmableLight),
|
||||||
|
|
||||||
|
|
||||||
DEVICE_ENTITY: dict[
|
|
||||||
type[device_types.DeviceType],
|
|
||||||
MatterEntityDescriptionBaseClass | list[MatterEntityDescriptionBaseClass],
|
|
||||||
] = {
|
|
||||||
device_types.OnOffPlugInUnit: MatterSwitchEntityDescriptionFactory(
|
|
||||||
key=device_types.OnOffPlugInUnit,
|
|
||||||
subscribe_attributes=(clusters.OnOff.Attributes.OnOff,),
|
|
||||||
device_class=SwitchDeviceClass.OUTLET,
|
|
||||||
),
|
),
|
||||||
}
|
]
|
||||||
|
@ -2081,7 +2081,7 @@ python-kasa==0.5.1
|
|||||||
# python-lirc==1.2.3
|
# python-lirc==1.2.3
|
||||||
|
|
||||||
# homeassistant.components.matter
|
# homeassistant.components.matter
|
||||||
python-matter-server==3.0.0
|
python-matter-server==3.1.0
|
||||||
|
|
||||||
# homeassistant.components.xiaomi_miio
|
# homeassistant.components.xiaomi_miio
|
||||||
python-miio==0.5.12
|
python-miio==0.5.12
|
||||||
|
@ -1480,7 +1480,7 @@ python-juicenet==1.1.0
|
|||||||
python-kasa==0.5.1
|
python-kasa==0.5.1
|
||||||
|
|
||||||
# homeassistant.components.matter
|
# homeassistant.components.matter
|
||||||
python-matter-server==3.0.0
|
python-matter-server==3.1.0
|
||||||
|
|
||||||
# homeassistant.components.xiaomi_miio
|
# homeassistant.components.xiaomi_miio
|
||||||
python-miio==0.5.12
|
python-miio==0.5.12
|
||||||
|
@ -31,7 +31,7 @@ async def test_contact_sensor(
|
|||||||
"""Test contact sensor."""
|
"""Test contact sensor."""
|
||||||
state = hass.states.get("binary_sensor.mock_contact_sensor_contact")
|
state = hass.states.get("binary_sensor.mock_contact_sensor_contact")
|
||||||
assert state
|
assert state
|
||||||
assert state.state == "on"
|
assert state.state == "off"
|
||||||
|
|
||||||
set_node_attribute(contact_sensor_node, 1, 69, 0, False)
|
set_node_attribute(contact_sensor_node, 1, 69, 0, False)
|
||||||
await trigger_subscription_callback(
|
await trigger_subscription_callback(
|
||||||
@ -40,7 +40,7 @@ async def test_contact_sensor(
|
|||||||
|
|
||||||
state = hass.states.get("binary_sensor.mock_contact_sensor_contact")
|
state = hass.states.get("binary_sensor.mock_contact_sensor_contact")
|
||||||
assert state
|
assert state
|
||||||
assert state.state == "off"
|
assert state.state == "on"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture(name="occupancy_sensor_node")
|
@pytest.fixture(name="occupancy_sensor_node")
|
||||||
|
@ -26,7 +26,7 @@ async def test_get_device_id(
|
|||||||
node = await setup_integration_with_node_fixture(
|
node = await setup_integration_with_node_fixture(
|
||||||
hass, "device_diagnostics", matter_client
|
hass, "device_diagnostics", matter_client
|
||||||
)
|
)
|
||||||
device_id = get_device_id(matter_client.server_info, node.node_devices[0])
|
device_id = get_device_id(matter_client.server_info, node.endpoints[0])
|
||||||
|
|
||||||
assert device_id == "00000000000004D2-0000000000000005-MatterNodeDevice"
|
assert device_id == "00000000000004D2-0000000000000005-MatterNodeDevice"
|
||||||
|
|
||||||
|
@ -297,10 +297,14 @@ async def test_extended_color_light(
|
|||||||
matter_client.send_device_command.assert_has_calls(
|
matter_client.send_device_command.assert_has_calls(
|
||||||
[
|
[
|
||||||
call(
|
call(
|
||||||
node_id=light_node.node_id,
|
node_id=1,
|
||||||
endpoint_id=1,
|
endpoint_id=1,
|
||||||
command=clusters.ColorControl.Commands.MoveToHueAndSaturation(
|
command=clusters.ColorControl.Commands.MoveToColor(
|
||||||
hue=0, saturation=0, transitionTime=0
|
colorX=21168,
|
||||||
|
colorY=21561,
|
||||||
|
transitionTime=0,
|
||||||
|
optionsMask=0,
|
||||||
|
optionsOverride=0,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
call(
|
call(
|
||||||
|
@ -121,14 +121,14 @@ async def test_light_sensor(
|
|||||||
light_sensor_node: MatterNode,
|
light_sensor_node: MatterNode,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test light sensor."""
|
"""Test light sensor."""
|
||||||
state = hass.states.get("sensor.mock_light_sensor_light")
|
state = hass.states.get("sensor.mock_light_sensor_illuminance")
|
||||||
assert state
|
assert state
|
||||||
assert state.state == "1.3"
|
assert state.state == "1.3"
|
||||||
|
|
||||||
set_node_attribute(light_sensor_node, 1, 1024, 0, 3000)
|
set_node_attribute(light_sensor_node, 1, 1024, 0, 3000)
|
||||||
await trigger_subscription_callback(hass, matter_client)
|
await trigger_subscription_callback(hass, matter_client)
|
||||||
|
|
||||||
state = hass.states.get("sensor.mock_light_sensor_light")
|
state = hass.states.get("sensor.mock_light_sensor_illuminance")
|
||||||
assert state
|
assert state
|
||||||
assert state.state == "2.0"
|
assert state.state == "2.0"
|
||||||
|
|
||||||
|
Loading…
x
Reference in New Issue
Block a user