diff --git a/homeassistant/components/matter/discovery.py b/homeassistant/components/matter/discovery.py index 0b4bacf00ca..c971bf8465e 100644 --- a/homeassistant/components/matter/discovery.py +++ b/homeassistant/components/matter/discovery.py @@ -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, diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py new file mode 100644 index 00000000000..3a1faa6dcbe --- /dev/null +++ b/homeassistant/components/matter/event.py @@ -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, + ), + ), +] diff --git a/homeassistant/components/matter/manifest.json b/homeassistant/components/matter/manifest.json index 85434407a10..2237f0ade98 100644 --- a/homeassistant/components/matter/manifest.json +++ b/homeassistant/components/matter/manifest.json @@ -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"] } diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 61f1ca9180a..bfdba33327b 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -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" diff --git a/requirements_all.txt b/requirements_all.txt index d40375adfc0..1db78e0c099 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ddb0f2f221a..4701dafe91d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/matter/fixtures/nodes/generic-switch-multi.json b/tests/components/matter/fixtures/nodes/generic-switch-multi.json new file mode 100644 index 00000000000..15c93825307 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/generic-switch-multi.json @@ -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": [] +} diff --git a/tests/components/matter/fixtures/nodes/generic-switch.json b/tests/components/matter/fixtures/nodes/generic-switch.json new file mode 100644 index 00000000000..30763c88e5b --- /dev/null +++ b/tests/components/matter/fixtures/nodes/generic-switch.json @@ -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": [] +} diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py new file mode 100644 index 00000000000..911dd0fe389 --- /dev/null +++ b/tests/components/matter/test_event.py @@ -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"