Add Event platform to Matter (#97219)
This commit is contained in:
parent
d7af1e2d5d
commit
fd44bef39b
9 changed files with 483 additions and 3 deletions
|
@ -12,6 +12,7 @@ from homeassistant.core import callback
|
|||
from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS
|
||||
from .climate import DISCOVERY_SCHEMAS as CLIMATE_SENSOR_SCHEMAS
|
||||
from .cover import DISCOVERY_SCHEMAS as COVER_SCHEMAS
|
||||
from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
|
||||
from .light import DISCOVERY_SCHEMAS as LIGHT_SCHEMAS
|
||||
from .lock import DISCOVERY_SCHEMAS as LOCK_SCHEMAS
|
||||
from .models import MatterDiscoverySchema, MatterEntityInfo
|
||||
|
@ -22,6 +23,7 @@ DISCOVERY_SCHEMAS: dict[Platform, list[MatterDiscoverySchema]] = {
|
|||
Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS,
|
||||
Platform.CLIMATE: CLIMATE_SENSOR_SCHEMAS,
|
||||
Platform.COVER: COVER_SCHEMAS,
|
||||
Platform.EVENT: EVENT_SCHEMAS,
|
||||
Platform.LIGHT: LIGHT_SCHEMAS,
|
||||
Platform.LOCK: LOCK_SCHEMAS,
|
||||
Platform.SENSOR: SENSOR_SCHEMAS,
|
||||
|
|
135
homeassistant/components/matter/event.py
Normal file
135
homeassistant/components/matter/event.py
Normal file
|
@ -0,0 +1,135 @@
|
|||
"""Matter event entities from Node events."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from matter_server.client.models import device_types
|
||||
from matter_server.common.models import EventType, MatterNodeEvent
|
||||
|
||||
from homeassistant.components.event import (
|
||||
EventDeviceClass,
|
||||
EventEntity,
|
||||
EventEntityDescription,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from .entity import MatterEntity
|
||||
from .helpers import get_matter
|
||||
from .models import MatterDiscoverySchema
|
||||
|
||||
SwitchFeature = clusters.Switch.Bitmaps.SwitchFeature
|
||||
|
||||
EVENT_TYPES_MAP = {
|
||||
# mapping from raw event id's to translation keys
|
||||
0: "switch_latched", # clusters.Switch.Events.SwitchLatched
|
||||
1: "initial_press", # clusters.Switch.Events.InitialPress
|
||||
2: "long_press", # clusters.Switch.Events.LongPress
|
||||
3: "short_release", # clusters.Switch.Events.ShortRelease
|
||||
4: "long_release", # clusters.Switch.Events.LongRelease
|
||||
5: "multi_press_ongoing", # clusters.Switch.Events.MultiPressOngoing
|
||||
6: "multi_press_complete", # clusters.Switch.Events.MultiPressComplete
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Matter switches from Config Entry."""
|
||||
matter = get_matter(hass)
|
||||
matter.register_platform_handler(Platform.EVENT, async_add_entities)
|
||||
|
||||
|
||||
class MatterEventEntity(MatterEntity, EventEntity):
|
||||
"""Representation of a Matter Event entity."""
|
||||
|
||||
_attr_translation_key = "push"
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(*args, **kwargs)
|
||||
# fill the event types based on the features the switch supports
|
||||
event_types: list[str] = []
|
||||
feature_map = int(
|
||||
self.get_matter_attribute_value(clusters.Switch.Attributes.FeatureMap)
|
||||
)
|
||||
if feature_map & SwitchFeature.kLatchingSwitch:
|
||||
event_types.append("switch_latched")
|
||||
if feature_map & SwitchFeature.kMomentarySwitch:
|
||||
event_types.append("initial_press")
|
||||
if feature_map & SwitchFeature.kMomentarySwitchRelease:
|
||||
event_types.append("short_release")
|
||||
if feature_map & SwitchFeature.kMomentarySwitchLongPress:
|
||||
event_types.append("long_press_ongoing")
|
||||
event_types.append("long_release")
|
||||
if feature_map & SwitchFeature.kMomentarySwitchMultiPress:
|
||||
event_types.append("multi_press_ongoing")
|
||||
event_types.append("multi_press_complete")
|
||||
self._attr_event_types = event_types
|
||||
# the optional label attribute could be used to identify multiple buttons
|
||||
# e.g. in case of a dimmer switch with 4 buttons, each button
|
||||
# will have its own name, prefixed by the device name.
|
||||
if labels := self.get_matter_attribute_value(
|
||||
clusters.FixedLabel.Attributes.LabelList
|
||||
):
|
||||
for label in labels:
|
||||
if label.label == "Label":
|
||||
label_value: str = label.value
|
||||
# in the case the label is only the label id, prettify it a bit
|
||||
if label_value.isnumeric():
|
||||
self._attr_name = f"Button {label_value}"
|
||||
else:
|
||||
self._attr_name = label_value
|
||||
break
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle being added to Home Assistant."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# subscribe to NodeEvent events
|
||||
self._unsubscribes.append(
|
||||
self.matter_client.subscribe_events(
|
||||
callback=self._on_matter_node_event,
|
||||
event_filter=EventType.NODE_EVENT,
|
||||
node_filter=self._endpoint.node.node_id,
|
||||
)
|
||||
)
|
||||
|
||||
def _update_from_device(self) -> None:
|
||||
"""Call when Node attribute(s) changed."""
|
||||
|
||||
@callback
|
||||
def _on_matter_node_event(
|
||||
self, event: EventType, data: MatterNodeEvent
|
||||
) -> None: # noqa: F821
|
||||
"""Call on NodeEvent."""
|
||||
if data.endpoint_id != self._endpoint.endpoint_id:
|
||||
return
|
||||
self._trigger_event(EVENT_TYPES_MAP[data.event_id], data.data)
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
# Discovery schema(s) to map Matter Attributes to HA entities
|
||||
DISCOVERY_SCHEMAS = [
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.EVENT,
|
||||
entity_description=EventEntityDescription(
|
||||
key="GenericSwitch", device_class=EventDeviceClass.BUTTON, name=None
|
||||
),
|
||||
entity_class=MatterEventEntity,
|
||||
required_attributes=(
|
||||
clusters.Switch.Attributes.CurrentPosition,
|
||||
clusters.Switch.Attributes.FeatureMap,
|
||||
),
|
||||
device_type=(device_types.GenericSwitch,),
|
||||
optional_attributes=(
|
||||
clusters.Switch.Attributes.NumberOfPositions,
|
||||
clusters.FixedLabel.Attributes.LabelList,
|
||||
),
|
||||
),
|
||||
]
|
|
@ -6,5 +6,5 @@
|
|||
"dependencies": ["websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["python-matter-server==3.6.3"]
|
||||
"requirements": ["python-matter-server==3.7.0"]
|
||||
}
|
||||
|
|
|
@ -45,6 +45,23 @@
|
|||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"push": {
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"switch_latched": "Switch latched",
|
||||
"initial_press": "Initial press",
|
||||
"long_press": "Lomng press",
|
||||
"short_release": "Short release",
|
||||
"long_release": "Long release",
|
||||
"multi_press_ongoing": "Multi press ongoing",
|
||||
"multi_press_complete": "Multi press complete"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"flow": {
|
||||
"name": "Flow"
|
||||
|
|
|
@ -2119,7 +2119,7 @@ python-kasa[speedups]==0.5.3
|
|||
# python-lirc==1.2.3
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==3.6.3
|
||||
python-matter-server==3.7.0
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
|
|
|
@ -1557,7 +1557,7 @@ python-juicenet==1.1.0
|
|||
python-kasa[speedups]==0.5.3
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==3.6.3
|
||||
python-matter-server==3.7.0
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
|
|
117
tests/components/matter/fixtures/nodes/generic-switch-multi.json
Normal file
117
tests/components/matter/fixtures/nodes/generic-switch-multi.json
Normal file
|
@ -0,0 +1,117 @@
|
|||
{
|
||||
"node_id": 1,
|
||||
"date_commissioned": "2023-07-06T11:13:20.917394",
|
||||
"last_interview": "2023-07-06T11:13:20.917401",
|
||||
"interview_version": 2,
|
||||
"attributes": {
|
||||
"0/29/0": [
|
||||
{
|
||||
"deviceType": 22,
|
||||
"revision": 1
|
||||
}
|
||||
],
|
||||
"0/29/1": [
|
||||
4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63,
|
||||
64, 65
|
||||
],
|
||||
"0/29/2": [41],
|
||||
"0/29/3": [1],
|
||||
"0/29/65532": 0,
|
||||
"0/29/65533": 1,
|
||||
"0/29/65528": [],
|
||||
"0/29/65529": [],
|
||||
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/40/0": 1,
|
||||
"0/40/1": "Nabu Casa",
|
||||
"0/40/2": 65521,
|
||||
"0/40/3": "Mock GenericSwitch",
|
||||
"0/40/4": 32768,
|
||||
"0/40/5": "Mock Generic Switch",
|
||||
"0/40/6": "XX",
|
||||
"0/40/7": 0,
|
||||
"0/40/8": "v1.0",
|
||||
"0/40/9": 1,
|
||||
"0/40/10": "prerelease",
|
||||
"0/40/11": "20230707",
|
||||
"0/40/12": "",
|
||||
"0/40/13": "",
|
||||
"0/40/14": "",
|
||||
"0/40/15": "TEST_SN",
|
||||
"0/40/16": false,
|
||||
"0/40/17": true,
|
||||
"0/40/18": "mock-generic-switch",
|
||||
"0/40/19": {
|
||||
"caseSessionsPerFabric": 3,
|
||||
"subscriptionsPerFabric": 3
|
||||
},
|
||||
"0/40/65532": 0,
|
||||
"0/40/65533": 1,
|
||||
"0/40/65528": [],
|
||||
"0/40/65529": [],
|
||||
"0/40/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||
65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"1/3/65529": [0, 64],
|
||||
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/29/0": [
|
||||
{
|
||||
"deviceType": 15,
|
||||
"revision": 1
|
||||
}
|
||||
],
|
||||
"1/29/1": [3, 29, 59],
|
||||
"1/29/2": [],
|
||||
"1/29/3": [],
|
||||
"1/29/65532": 0,
|
||||
"1/29/65533": 1,
|
||||
"1/29/65528": [],
|
||||
"1/29/65529": [],
|
||||
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/59/65529": [],
|
||||
"1/59/0": 2,
|
||||
"1/59/65533": 1,
|
||||
"1/59/1": 0,
|
||||
"1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/59/65532": 14,
|
||||
"1/59/65528": [],
|
||||
"1/64/0": [
|
||||
{
|
||||
"label": "Label",
|
||||
"value": "1"
|
||||
}
|
||||
],
|
||||
|
||||
"2/3/65529": [0, 64],
|
||||
"2/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"2/29/0": [
|
||||
{
|
||||
"deviceType": 15,
|
||||
"revision": 1
|
||||
}
|
||||
],
|
||||
"2/29/1": [3, 29, 59],
|
||||
"2/29/2": [],
|
||||
"2/29/3": [],
|
||||
"2/29/65532": 0,
|
||||
"2/29/65533": 1,
|
||||
"2/29/65528": [],
|
||||
"2/29/65529": [],
|
||||
"2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"2/59/65529": [],
|
||||
"2/59/0": 2,
|
||||
"2/59/65533": 1,
|
||||
"2/59/1": 0,
|
||||
"2/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"2/59/65532": 14,
|
||||
"2/59/65528": [],
|
||||
"2/64/0": [
|
||||
{
|
||||
"label": "Label",
|
||||
"value": "Fancy Button"
|
||||
}
|
||||
]
|
||||
},
|
||||
"available": true,
|
||||
"attribute_subscriptions": []
|
||||
}
|
81
tests/components/matter/fixtures/nodes/generic-switch.json
Normal file
81
tests/components/matter/fixtures/nodes/generic-switch.json
Normal file
|
@ -0,0 +1,81 @@
|
|||
{
|
||||
"node_id": 1,
|
||||
"date_commissioned": "2023-07-06T11:13:20.917394",
|
||||
"last_interview": "2023-07-06T11:13:20.917401",
|
||||
"interview_version": 2,
|
||||
"attributes": {
|
||||
"0/29/0": [
|
||||
{
|
||||
"deviceType": 22,
|
||||
"revision": 1
|
||||
}
|
||||
],
|
||||
"0/29/1": [
|
||||
4, 29, 31, 40, 42, 43, 44, 48, 49, 50, 51, 52, 53, 54, 55, 59, 60, 62, 63,
|
||||
64, 65
|
||||
],
|
||||
"0/29/2": [41],
|
||||
"0/29/3": [1],
|
||||
"0/29/65532": 0,
|
||||
"0/29/65533": 1,
|
||||
"0/29/65528": [],
|
||||
"0/29/65529": [],
|
||||
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/40/0": 1,
|
||||
"0/40/1": "Nabu Casa",
|
||||
"0/40/2": 65521,
|
||||
"0/40/3": "Mock GenericSwitch",
|
||||
"0/40/4": 32768,
|
||||
"0/40/5": "Mock Generic Switch",
|
||||
"0/40/6": "XX",
|
||||
"0/40/7": 0,
|
||||
"0/40/8": "v1.0",
|
||||
"0/40/9": 1,
|
||||
"0/40/10": "prerelease",
|
||||
"0/40/11": "20230707",
|
||||
"0/40/12": "",
|
||||
"0/40/13": "",
|
||||
"0/40/14": "",
|
||||
"0/40/15": "TEST_SN",
|
||||
"0/40/16": false,
|
||||
"0/40/17": true,
|
||||
"0/40/18": "mock-generic-switch",
|
||||
"0/40/19": {
|
||||
"caseSessionsPerFabric": 3,
|
||||
"subscriptionsPerFabric": 3
|
||||
},
|
||||
"0/40/65532": 0,
|
||||
"0/40/65533": 1,
|
||||
"0/40/65528": [],
|
||||
"0/40/65529": [],
|
||||
"0/40/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||
65528, 65529, 65531, 65532, 65533
|
||||
],
|
||||
"1/3/65529": [0, 64],
|
||||
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/29/0": [
|
||||
{
|
||||
"deviceType": 15,
|
||||
"revision": 1
|
||||
}
|
||||
],
|
||||
"1/29/1": [3, 29, 59],
|
||||
"1/29/2": [],
|
||||
"1/29/3": [],
|
||||
"1/29/65532": 0,
|
||||
"1/29/65533": 1,
|
||||
"1/29/65528": [],
|
||||
"1/29/65529": [],
|
||||
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/59/65529": [],
|
||||
"1/59/0": 2,
|
||||
"1/59/65533": 1,
|
||||
"1/59/1": 0,
|
||||
"1/59/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/59/65532": 30,
|
||||
"1/59/65528": []
|
||||
},
|
||||
"available": true,
|
||||
"attribute_subscriptions": []
|
||||
}
|
128
tests/components/matter/test_event.py
Normal file
128
tests/components/matter/test_event.py
Normal file
|
@ -0,0 +1,128 @@
|
|||
"""Test Matter Event entities."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from matter_server.client.models.node import MatterNode
|
||||
from matter_server.common.models import EventType, MatterNodeEvent
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.event import (
|
||||
ATTR_EVENT_TYPE,
|
||||
ATTR_EVENT_TYPES,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .common import (
|
||||
setup_integration_with_node_fixture,
|
||||
trigger_subscription_callback,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="generic_switch_node")
|
||||
async def switch_node_fixture(
|
||||
hass: HomeAssistant, matter_client: MagicMock
|
||||
) -> MatterNode:
|
||||
"""Fixture for a GenericSwitch node."""
|
||||
return await setup_integration_with_node_fixture(
|
||||
hass, "generic-switch", matter_client
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(name="generic_switch_multi_node")
|
||||
async def multi_switch_node_fixture(
|
||||
hass: HomeAssistant, matter_client: MagicMock
|
||||
) -> MatterNode:
|
||||
"""Fixture for a GenericSwitch node with multiple buttons."""
|
||||
return await setup_integration_with_node_fixture(
|
||||
hass, "generic-switch-multi", matter_client
|
||||
)
|
||||
|
||||
|
||||
# This tests needs to be adjusted to remove lingering tasks
|
||||
@pytest.mark.parametrize("expected_lingering_tasks", [True])
|
||||
async def test_generic_switch_node(
|
||||
hass: HomeAssistant,
|
||||
matter_client: MagicMock,
|
||||
generic_switch_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test event entity for a GenericSwitch node."""
|
||||
state = hass.states.get("event.mock_generic_switch")
|
||||
assert state
|
||||
assert state.state == "unknown"
|
||||
# the switch endpoint has no label so the entity name should be the device itself
|
||||
assert state.name == "Mock Generic Switch"
|
||||
# check event_types from featuremap 30
|
||||
assert state.attributes[ATTR_EVENT_TYPES] == [
|
||||
"initial_press",
|
||||
"short_release",
|
||||
"long_press_ongoing",
|
||||
"long_release",
|
||||
"multi_press_ongoing",
|
||||
"multi_press_complete",
|
||||
]
|
||||
# trigger firing a new event from the device
|
||||
await trigger_subscription_callback(
|
||||
hass,
|
||||
matter_client,
|
||||
EventType.NODE_EVENT,
|
||||
MatterNodeEvent(
|
||||
node_id=generic_switch_node.node_id,
|
||||
endpoint_id=1,
|
||||
cluster_id=59,
|
||||
event_id=1,
|
||||
event_number=0,
|
||||
priority=1,
|
||||
timestamp=0,
|
||||
timestamp_type=0,
|
||||
data=None,
|
||||
),
|
||||
)
|
||||
state = hass.states.get("event.mock_generic_switch")
|
||||
assert state.attributes[ATTR_EVENT_TYPE] == "initial_press"
|
||||
# trigger firing a multi press event
|
||||
await trigger_subscription_callback(
|
||||
hass,
|
||||
matter_client,
|
||||
EventType.NODE_EVENT,
|
||||
MatterNodeEvent(
|
||||
node_id=generic_switch_node.node_id,
|
||||
endpoint_id=1,
|
||||
cluster_id=59,
|
||||
event_id=5,
|
||||
event_number=0,
|
||||
priority=1,
|
||||
timestamp=0,
|
||||
timestamp_type=0,
|
||||
data={"NewPosition": 3},
|
||||
),
|
||||
)
|
||||
state = hass.states.get("event.mock_generic_switch")
|
||||
assert state.attributes[ATTR_EVENT_TYPE] == "multi_press_ongoing"
|
||||
assert state.attributes["NewPosition"] == 3
|
||||
|
||||
|
||||
# This tests needs to be adjusted to remove lingering tasks
|
||||
@pytest.mark.parametrize("expected_lingering_tasks", [True])
|
||||
async def test_generic_switch_multi_node(
|
||||
hass: HomeAssistant,
|
||||
matter_client: MagicMock,
|
||||
generic_switch_multi_node: MatterNode,
|
||||
) -> None:
|
||||
"""Test event entity for a GenericSwitch node with multiple buttons."""
|
||||
state_button_1 = hass.states.get("event.mock_generic_switch_button_1")
|
||||
assert state_button_1
|
||||
assert state_button_1.state == "unknown"
|
||||
# name should be 'DeviceName Button 1' due to the label set to just '1'
|
||||
assert state_button_1.name == "Mock Generic Switch Button 1"
|
||||
# check event_types from featuremap 14
|
||||
assert state_button_1.attributes[ATTR_EVENT_TYPES] == [
|
||||
"initial_press",
|
||||
"short_release",
|
||||
"long_press_ongoing",
|
||||
"long_release",
|
||||
]
|
||||
# check button 2
|
||||
state_button_1 = hass.states.get("event.mock_generic_switch_fancy_button")
|
||||
assert state_button_1
|
||||
assert state_button_1.state == "unknown"
|
||||
# name should be 'DeviceName Fancy Button' due to the label set to 'Fancy Button'
|
||||
assert state_button_1.name == "Mock Generic Switch Fancy Button"
|
Loading…
Add table
Reference in a new issue