diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 3e520adce62..5d9a7aaf477 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -35,6 +35,9 @@ "activated_carbon_filter_condition": { "default": "mdi:filter-check" }, + "operational_state": { + "default": "mdi:play-pause" + }, "valve_position": { "default": "mdi:valve" } diff --git a/homeassistant/components/matter/sensor.py b/homeassistant/components/matter/sensor.py index 499eb20aa59..e10f081d497 100644 --- a/homeassistant/components/matter/sensor.py +++ b/homeassistant/components/matter/sensor.py @@ -4,6 +4,7 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime +from typing import TYPE_CHECKING, cast from chip.clusters import Objects as clusters from chip.clusters.Types import Nullable, NullValue @@ -37,6 +38,7 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.util import slugify from .entity import MatterEntity, MatterEntityDescription from .helpers import get_matter @@ -61,6 +63,15 @@ CONTAMINATION_STATE_MAP = { } +OPERATIONAL_STATE_MAP = { + # enum with known Operation state values which we can translate + clusters.OperationalState.Enums.OperationalStateEnum.kStopped: "stopped", + clusters.OperationalState.Enums.OperationalStateEnum.kRunning: "running", + clusters.OperationalState.Enums.OperationalStateEnum.kPaused: "paused", + clusters.OperationalState.Enums.OperationalStateEnum.kError: "error", +} + + async def async_setup_entry( hass: HomeAssistant, config_entry: ConfigEntry, @@ -93,6 +104,42 @@ class MatterSensor(MatterEntity, SensorEntity): self._attr_native_value = value +class MatterOperationalStateSensor(MatterSensor): + """Representation of a sensor for Matter Operational State.""" + + states_map: dict[int, str] + + @callback + def _update_from_device(self) -> None: + """Update from device.""" + # the operational state list is a list of the possible operational states + # this is a dynamic list and is condition, device and manufacturer specific + # therefore it is not possible to provide a fixed list of options + # or to provide a mapping to a translateable string for all options + operational_state_list = self.get_matter_attribute_value( + clusters.OperationalState.Attributes.OperationalStateList + ) + if TYPE_CHECKING: + operational_state_list = cast( + list[clusters.OperationalState.Structs.OperationalStateStruct], + operational_state_list, + ) + states_map: dict[int, str] = {} + for state in operational_state_list: + # prefer translateable (known) state from mapping, + # fallback to the raw state label as given by the device/manufacturer + states_map[state.operationalStateID] = OPERATIONAL_STATE_MAP.get( + state.operationalStateID, slugify(state.operationalStateLabel) + ) + self.states_map = states_map + self._attr_options = list(states_map.values()) + self._attr_native_value = states_map.get( + self.get_matter_attribute_value( + clusters.OperationalState.Attributes.OperationalState + ) + ) + + # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ MatterDiscoverySchema( @@ -582,8 +629,7 @@ DISCOVERY_SCHEMAS = [ key="SmokeCOAlarmContaminationState", translation_key="contamination_state", device_class=SensorDeviceClass.ENUM, - # convert to set first to remove the duplicate unknown value - options=list(set(CONTAMINATION_STATE_MAP.values())), + options=list(CONTAMINATION_STATE_MAP.values()), measurement_to_ha=CONTAMINATION_STATE_MAP.get, ), entity_class=MatterSensor, @@ -601,4 +647,17 @@ DISCOVERY_SCHEMAS = [ entity_class=MatterSensor, required_attributes=(clusters.SmokeCoAlarm.Attributes.ExpiryDate,), ), + MatterDiscoverySchema( + platform=Platform.SENSOR, + entity_description=MatterSensorEntityDescription( + key="OperationalState", + device_class=SensorDeviceClass.ENUM, + translation_key="operational_state", + ), + entity_class=MatterOperationalStateSensor, + required_attributes=( + clusters.OperationalState.Attributes.OperationalState, + clusters.OperationalState.Attributes.OperationalStateList, + ), + ), ] diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index d7258c02f95..fdff15ce0a4 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -210,6 +210,15 @@ "hepa_filter_condition": { "name": "Hepa filter condition" }, + "operational_state": { + "name": "Operational state", + "state": { + "stopped": "Stopped", + "running": "Running", + "paused": "[%key:common::state::paused%]", + "error": "Error" + } + }, "switch_current_position": { "name": "Current switch position" }, diff --git a/tests/components/matter/fixtures/nodes/silabs-dishwasher.json b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json new file mode 100644 index 00000000000..f060066e100 --- /dev/null +++ b/tests/components/matter/fixtures/nodes/silabs-dishwasher.json @@ -0,0 +1,657 @@ +{ + "node_id": 54, + "date_commissioned": "2024-08-15T07:14:29.055273", + "last_interview": "2024-08-15T11:36:27.830863", + "interview_version": 6, + "available": true, + "is_bridge": false, + "attributes": { + "0/29/0": [ + { + "0": 22, + "1": 1 + } + ], + "0/29/1": [ + 29, 31, 40, 42, 43, 44, 45, 48, 49, 50, 51, 52, 53, 60, 62, 63, 64, 65 + ], + "0/29/2": [41], + "0/29/3": [1, 2], + "0/29/65532": 0, + "0/29/65533": 2, + "0/29/65528": [], + "0/29/65529": [], + "0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/31/0": [ + { + "254": 1 + }, + { + "254": 1 + }, + { + "254": 2 + }, + { + "1": 5, + "2": 2, + "3": [112233], + "4": null, + "254": 3 + } + ], + "0/31/1": [], + "0/31/2": 4, + "0/31/3": 3, + "0/31/4": 4, + "0/31/65532": 0, + "0/31/65533": 1, + "0/31/65528": [], + "0/31/65529": [], + "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/40/0": 17, + "0/40/1": "Silabs", + "0/40/2": 65521, + "0/40/3": "Dishwasher", + "0/40/4": 32773, + "0/40/5": "", + "0/40/6": "**REDACTED**", + "0/40/7": 1, + "0/40/8": "TEST_VERSION", + "0/40/9": 1, + "0/40/10": "1", + "0/40/11": "20200101", + "0/40/12": "Dishwasher", + "0/40/13": "Dishwasher", + "0/40/14": "", + "0/40/15": "", + "0/40/16": false, + "0/40/18": "**REDACTED**", + "0/40/19": { + "0": 3, + "1": 3 + }, + "0/40/21": 16973824, + "0/40/22": 1, + "0/40/65532": 0, + "0/40/65533": 3, + "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, 18, 19, 21, 22, + 65528, 65529, 65531, 65532, 65533 + ], + "0/42/0": [ + { + "1": 556220604, + "2": 0, + "254": 1 + } + ], + "0/42/1": true, + "0/42/2": 1, + "0/42/3": null, + "0/42/65532": 0, + "0/42/65533": 1, + "0/42/65528": [], + "0/42/65529": [0], + "0/42/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/43/0": "en-US", + "0/43/1": [ + "en-US", + "de-DE", + "fr-FR", + "en-GB", + "es-ES", + "zh-CN", + "it-IT", + "ja-JP" + ], + "0/43/65532": 0, + "0/43/65533": 1, + "0/43/65528": [], + "0/43/65529": [], + "0/43/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "0/44/0": 0, + "0/44/1": 0, + "0/44/2": [0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 7], + "0/44/65532": 0, + "0/44/65533": 1, + "0/44/65528": [], + "0/44/65529": [], + "0/44/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/45/0": 1, + "0/45/65532": 0, + "0/45/65533": 1, + "0/45/65528": [], + "0/45/65529": [], + "0/45/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/48/0": 0, + "0/48/1": { + "0": 60, + "1": 900 + }, + "0/48/2": 0, + "0/48/3": 0, + "0/48/4": true, + "0/48/65532": 0, + "0/48/65533": 1, + "0/48/65528": [1, 3, 5], + "0/48/65529": [0, 2, 4], + "0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], + "0/49/0": 1, + "0/49/1": [], + "0/49/2": 10, + "0/49/3": 20, + "0/49/4": true, + "0/49/5": 0, + "0/49/6": "**REDACTED**", + "0/49/7": null, + "0/49/9": 10, + "0/49/10": 4, + "0/49/65532": 2, + "0/49/65533": 2, + "0/49/65528": [1, 5, 7], + "0/49/65529": [0, 3, 4, 6, 8], + "0/49/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 9, 10, 65528, 65529, 65531, 65532, 65533 + ], + "0/50/65532": 0, + "0/50/65533": 1, + "0/50/65528": [1], + "0/50/65529": [0], + "0/50/65531": [65528, 65529, 65531, 65532, 65533], + "0/51/0": [], + "0/51/1": 6, + "0/51/2": 10, + "0/51/3": 4, + "0/51/4": 1, + "0/51/5": [], + "0/51/6": [], + "0/51/7": [], + "0/51/8": false, + "0/51/65532": 0, + "0/51/65533": 2, + "0/51/65528": [2], + "0/51/65529": [0, 1], + "0/51/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 65528, 65529, 65531, 65532, 65533 + ], + "0/52/0": [ + { + "0": 3, + "1": "UART", + "3": 128 + }, + { + "0": 9, + "1": "DishWash", + "3": 766 + }, + { + "0": 2, + "1": "OT Stack", + "3": 719 + }, + { + "0": 12, + "1": "Bluetoot", + "3": 40 + }, + { + "0": 1, + "1": "Bluetoot", + "3": 282 + }, + { + "0": 11, + "1": "Bluetoot", + "3": 210 + }, + { + "0": 8, + "1": "shell", + "3": 323 + }, + { + "0": 6, + "1": "Tmr Svc", + "3": 594 + }, + { + "0": 5, + "1": "IDLE", + "3": 266 + }, + { + "0": 7, + "1": "CHIP", + "3": 705 + } + ], + "0/52/1": 100824, + "0/52/2": 16984, + "0/52/3": 4294959062, + "0/52/65532": 1, + "0/52/65533": 1, + "0/52/65528": [], + "0/52/65529": [0], + "0/52/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/53/0": 25, + "0/53/1": 5, + "0/53/2": "**REDACTED**", + "0/53/3": 39055, + "0/53/4": 12054125955590472924, + "0/53/5": "**REDACTED**", + "0/53/6": 0, + "0/53/7": [], + "0/53/8": [], + "0/53/9": 1773502518, + "0/53/10": 64, + "0/53/11": 88, + "0/53/12": 225, + "0/53/13": 22, + "0/53/14": 1, + "0/53/15": 0, + "0/53/16": 1, + "0/53/17": 0, + "0/53/18": 0, + "0/53/19": 1, + "0/53/20": 0, + "0/53/21": 0, + "0/53/22": 693, + "0/53/23": 686, + "0/53/24": 7, + "0/53/25": 686, + "0/53/26": 686, + "0/53/27": 7, + "0/53/28": 693, + "0/53/29": 0, + "0/53/30": 0, + "0/53/31": 0, + "0/53/32": 0, + "0/53/33": 61, + "0/53/34": 0, + "0/53/35": 0, + "0/53/36": 2, + "0/53/37": 0, + "0/53/38": 0, + "0/53/39": 87, + "0/53/40": 87, + "0/53/41": 0, + "0/53/42": 86, + "0/53/43": 0, + "0/53/44": 0, + "0/53/45": 0, + "0/53/46": 0, + "0/53/47": 0, + "0/53/48": 0, + "0/53/49": 1, + "0/53/50": 0, + "0/53/51": 0, + "0/53/52": 0, + "0/53/53": 0, + "0/53/54": 0, + "0/53/55": 0, + "0/53/56": 0, + "0/53/57": 0, + "0/53/58": 0, + "0/53/59": { + "0": 672, + "1": 8335 + }, + "0/53/60": "AB//wA==", + "0/53/61": { + "0": true, + "1": false, + "2": true, + "3": true, + "4": true, + "5": true, + "6": false, + "7": true, + "8": true, + "9": true, + "10": true, + "11": true + }, + "0/53/62": [], + "0/53/65532": 15, + "0/53/65533": 2, + "0/53/65528": [], + "0/53/65529": [0], + "0/53/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, + 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, + 57, 58, 59, 60, 61, 62, 65528, 65529, 65531, 65532, 65533 + ], + "0/60/0": 0, + "0/60/1": null, + "0/60/2": null, + "0/60/65532": 0, + "0/60/65533": 1, + "0/60/65528": [], + "0/60/65529": [0, 1, 2], + "0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533], + "0/62/0": [], + "0/62/1": [], + "0/62/2": 5, + "0/62/3": 3, + "0/62/4": [], + "0/62/5": 3, + "0/62/65532": 0, + "0/62/65533": 1, + "0/62/65528": [1, 3, 5, 8], + "0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11], + "0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "0/63/0": [], + "0/63/1": [], + "0/63/2": 4, + "0/63/3": 3, + "0/63/65532": 0, + "0/63/65533": 2, + "0/63/65528": [2, 5], + "0/63/65529": [0, 1, 3, 4], + "0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "0/64/0": [ + { + "0": "room", + "1": "bedroom 2" + }, + { + "0": "orientation", + "1": "North" + }, + { + "0": "floor", + "1": "2" + }, + { + "0": "direction", + "1": "up" + } + ], + "0/64/65532": 0, + "0/64/65533": 1, + "0/64/65528": [], + "0/64/65529": [], + "0/64/65531": [0, 65528, 65529, 65531, 65532, 65533], + "0/65/0": [], + "0/65/65532": 0, + "0/65/65533": 1, + "0/65/65528": [], + "0/65/65529": [], + "0/65/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/3/0": 0, + "1/3/1": 2, + "1/3/65532": 0, + "1/3/65533": 4, + "1/3/65528": [], + "1/3/65529": [0, 64], + "1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/29/0": [ + { + "0": 117, + "1": 1 + } + ], + "1/29/1": [3, 29, 30, 89, 96], + "1/29/2": [], + "1/29/3": [], + "1/29/65532": 0, + "1/29/65533": 2, + "1/29/65528": [], + "1/29/65529": [], + "1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "1/30/0": [], + "1/30/65532": 0, + "1/30/65533": 1, + "1/30/65528": [], + "1/30/65529": [], + "1/30/65531": [0, 65528, 65529, 65531, 65532, 65533], + "1/89/0": null, + "1/89/1": null, + "1/89/65532": null, + "1/89/65533": 2, + "1/89/65528": [1], + "1/89/65529": [0], + "1/89/65531": [0, 1, 65528, 65529, 65531, 65532, 65533], + "1/96/0": null, + "1/96/1": null, + "1/96/3": [ + { + "0": 0 + }, + { + "0": 1 + }, + { + "0": 2 + }, + { + "0": 3 + }, + { + "0": 8, + "1": "Extra state" + } + ], + "1/96/4": 0, + "1/96/5": { + "0": 0 + }, + "1/96/65532": 0, + "1/96/65533": 1, + "1/96/65528": [4], + "1/96/65529": [0, 1, 2, 3], + "1/96/65531": [0, 1, 3, 4, 5, 65528, 65529, 65531, 65532, 65533], + "2/29/0": [ + { + "0": 1296, + "1": 1 + } + ], + "2/29/1": [29, 144, 145, 156], + "2/29/2": [], + "2/29/3": [], + "2/29/65532": 0, + "2/29/65533": 2, + "2/29/65528": [], + "2/29/65529": [], + "2/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533], + "2/144/0": 2, + "2/144/1": 3, + "2/144/2": [ + { + "0": 5, + "1": true, + "2": -50000000, + "3": 50000000, + "4": [ + { + "0": -50000000, + "1": -10000000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -9999999, + "1": 9999999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 10000000, + "1": 50000000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 2, + "1": true, + "2": -100000, + "3": 100000, + "4": [ + { + "0": -100000, + "1": -5000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -4999, + "1": 4999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 5000, + "1": 100000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + }, + { + "0": 1, + "1": true, + "2": -500000, + "3": 500000, + "4": [ + { + "0": -500000, + "1": -100000, + "2": 5000, + "3": 2000, + "4": 3000 + }, + { + "0": -99999, + "1": 99999, + "2": 1000, + "3": 100, + "4": 500 + }, + { + "0": 100000, + "1": 500000, + "2": 5000, + "3": 2000, + "4": 3000 + } + ] + } + ], + "2/144/3": [ + { + "0": 0, + "1": 0, + "2": 300, + "7": 101, + "8": 101, + "9": 101, + "10": 101 + }, + { + "0": 1, + "1": 0, + "2": 500, + "7": 101, + "8": 101, + "9": 101, + "10": 101 + }, + { + "0": 2, + "1": 0, + "2": 1000, + "7": 101, + "8": 101, + "9": 101, + "10": 101 + } + ], + "2/144/4": 120000, + "2/144/5": 0, + "2/144/6": 0, + "2/144/7": 0, + "2/144/8": 0, + "2/144/9": 0, + "2/144/10": 0, + "2/144/11": 120000, + "2/144/12": 0, + "2/144/13": 0, + "2/144/14": 60, + "2/144/15": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/16": [ + { + "0": 1, + "1": 100000 + } + ], + "2/144/17": 9800, + "2/144/18": 0, + "2/144/65532": 31, + "2/144/65533": 1, + "2/144/65528": [], + "2/144/65529": [], + "2/144/65531": [ + 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 65528, + 65529, 65531, 65532, 65533 + ], + "2/145/0": { + "0": 14, + "1": true, + "2": 0, + "3": 1000000000000000, + "4": [ + { + "0": 0, + "1": 1000000000000000, + "2": 500, + "3": 50 + } + ] + }, + "2/145/1": { + "0": 0, + "1": 9, + "2": 12, + "3": 9649, + "4": 12530 + }, + "2/145/5": { + "0": 0, + "1": 0, + "2": 0, + "3": 0 + }, + "2/145/65532": 5, + "2/145/65533": 1, + "2/145/65528": [], + "2/145/65529": [], + "2/145/65531": [0, 1, 5, 65528, 65529, 65531, 65532, 65533], + "2/156/0": [0, 1, 2], + "2/156/1": null, + "2/156/65532": 12, + "2/156/65533": 1, + "2/156/65528": [], + "2/156/65529": [], + "2/156/65531": [0, 1, 65528, 65529, 65531, 65532, 65533] + }, + "attribute_subscriptions": [] +} diff --git a/tests/components/matter/test_sensor.py b/tests/components/matter/test_sensor.py index 61234e6afcd..dd0e52b8c7c 100644 --- a/tests/components/matter/test_sensor.py +++ b/tests/components/matter/test_sensor.py @@ -122,6 +122,16 @@ async def air_purifier_node_fixture( ) +@pytest.fixture(name="dishwasher_node") +async def dishwasher_node_fixture( + hass: HomeAssistant, matter_client: MagicMock +) -> MatterNode: + """Fixture for an dishwasher node.""" + return await setup_integration_with_node_fixture( + hass, "silabs-dishwasher", matter_client + ) + + # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) async def test_sensor_null_value( @@ -622,3 +632,29 @@ async def test_smoke_alarm( state = hass.states.get("sensor.smoke_sensor_voltage") assert state assert state.state == "0.0" + + +async def test_operational_state_sensor( + hass: HomeAssistant, + matter_client: MagicMock, + dishwasher_node: MatterNode, +) -> None: + """Test dishwasher sensor.""" + # OperationalState Cluster / OperationalState attribute (1/96/4) + state = hass.states.get("sensor.dishwasher_operational_state") + assert state + assert state.state == "stopped" + assert state.attributes["options"] == [ + "stopped", + "running", + "paused", + "error", + "extra_state", + ] + + set_node_attribute(dishwasher_node, 1, 96, 4, 8) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get("sensor.dishwasher_operational_state") + assert state + assert state.state == "extra_state"