diff --git a/homeassistant/components/matter/event.py b/homeassistant/components/matter/event.py index ad4d9aa4d6ce..da1ca2d2eeb0 100644 --- a/homeassistant/components/matter/event.py +++ b/homeassistant/components/matter/event.py @@ -16,6 +16,7 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from .const import LOGGER from .entity import MatterEntity, MatterEntityDescription from .helpers import MatterConfigEntry from .models import MatterDiscoverySchema @@ -117,12 +118,25 @@ class MatterEventEntity(MatterEntity, EventEntity): """Call on NodeEvent.""" if data.endpoint_id != self._endpoint.endpoint_id: return + + # event ids are only unique within a cluster, and an endpoint can host + # more clusters than the switch this entity was made for + if data.cluster_id != clusters.Switch.id: + return + + event_type: str | None = EVENT_TYPES_MAP.get(data.event_id) if data.event_id == clusters.Switch.Events.MultiPressComplete.event_id: # multi press event presses = (data.data or {}).get("totalNumberOfPressesCounted", 1) event_type = f"multi_press_{presses}" - else: - event_type = EVENT_TYPES_MAP[data.event_id] + + if event_type is None: + LOGGER.debug( + "Ignoring unknown switch event id %s for %s", + data.event_id, + self.entity_id, + ) + return if event_type not in self.event_types: # this should not happen, but guard for bad things diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index b8769a0f191f..97d601c835b1 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -239,14 +239,16 @@ class MatterLight(MatterEntity, LightEntity): return hs_color - def _get_color_temperature(self) -> int: + def _get_color_temperature(self) -> int | None: """Get color temperature from matter.""" color_temp = self.get_matter_attribute_value( clusters.ColorControl.Attributes.ColorTemperatureMireds ) - assert color_temp is not None + if color_temp is None: + LOGGER.debug("Got no color temperature for %s", self.entity_id) + return None LOGGER.debug( "Got color temperature %s for %s", @@ -261,8 +263,10 @@ class MatterLight(MatterEntity, LightEntity): level_control = self._endpoint.get_cluster(clusters.LevelControl) - # We should not get here if brightness is not supported. - assert level_control is not None + if level_control is None: + # we should not get here if brightness is not supported + LOGGER.debug("Got no level control cluster for %s", self.entity_id) + return None LOGGER.debug( "Got brightness %s for %s", @@ -289,9 +293,15 @@ class MatterLight(MatterEntity, LightEntity): clusters.ColorControl.Attributes.ColorMode ) - assert color_mode is not None - - ha_color_mode = COLOR_MODE_MAP[color_mode] + if (ha_color_mode := COLOR_MODE_MAP.get(color_mode)) is None: + # ColorMode is nullable and a device is free to report a value + # outside of the enum, neither of which we can map to a color + LOGGER.debug( + "Got unexpected color mode (%s) for %s", + color_mode, + self.entity_id, + ) + return ColorMode.UNKNOWN LOGGER.debug( "Got color mode (%s) for %s", @@ -419,12 +429,14 @@ class MatterLight(MatterEntity, LightEntity): if self._supports_brightness: self._attr_brightness = self._get_brightness() - if ( - self._supports_color_temperature - and (color_temperature := self._get_color_temperature()) > 0 - ): - self._attr_color_temp_kelvin = color_util.color_temperature_mired_to_kelvin( - color_temperature + if self._supports_color_temperature: + # a device without a usable value has no color temperature to + # report, rather than the one it gave us last time + color_temperature = self._get_color_temperature() + self._attr_color_temp_kelvin = ( + color_util.color_temperature_mired_to_kelvin(color_temperature) + if color_temperature + else None ) if self._supports_color: diff --git a/tests/components/matter/test_event.py b/tests/components/matter/test_event.py index 6500d285d378..2df52a3971dd 100644 --- a/tests/components/matter/test_event.py +++ b/tests/components/matter/test_event.py @@ -119,3 +119,75 @@ async def test_generic_switch_multi_node( ) state = hass.states.get("event.mock_generic_switch_button_1") assert state.attributes[ATTR_EVENT_TYPE] == "multi_press_2" + + +@pytest.mark.parametrize("node_fixture", ["mock_generic_switch"]) +async def test_generic_switch_unknown_event( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test an event id that is not a switch event is ignored.""" + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_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_button") + last_event = state.state + + # an event id outside of the switch event id space + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_node.node_id, + endpoint_id=1, + cluster_id=59, + event_id=7, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data=None, + ), + ) + + state = hass.states.get("event.mock_generic_switch_button") + assert state.state == last_event + assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" + + # an event id that another cluster on the same endpoint uses, here the + # door lock LockOperation event, which shares its id with a long press + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_node.node_id, + endpoint_id=1, + cluster_id=257, + event_id=2, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data=None, + ), + ) + + state = hass.states.get("event.mock_generic_switch_button") + assert state.state == last_event + assert state.attributes[ATTR_EVENT_TYPE] == "initial_press" diff --git a/tests/components/matter/test_light.py b/tests/components/matter/test_light.py index 5b1a0806e955..4b67e8c7b8c7 100644 --- a/tests/components/matter/test_light.py +++ b/tests/components/matter/test_light.py @@ -1,8 +1,10 @@ """Test Matter lights.""" +from typing import Any from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters +from chip.clusters.Objects import NullValue from matter_server.client.models.node import MatterNode import pytest from syrupy.assertion import SnapshotAssertion @@ -486,3 +488,80 @@ async def test_extended_color_light( ] ) matter_client.send_device_command.reset_mock() + + +@pytest.mark.parametrize("node_fixture", ["color_temperature_light"]) +async def test_light_null_color_temperature( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test a light that stops reporting a color temperature.""" + entity_id = "light.mock_color_temperature_light" + + set_node_attribute(matter_node, 1, 768, 7, 300) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["color_temp_kelvin"] == 3333 + + set_node_attribute(matter_node, 1, 768, 7, NullValue) + await trigger_subscription_callback(hass, matter_client) + + # the last known value is not the current one, so it is not reported + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["color_temp_kelvin"] is None + + +@pytest.mark.parametrize( + "color_mode", + [ + pytest.param(NullValue, id="null"), + pytest.param(255, id="out_of_range"), + ], +) +@pytest.mark.parametrize("node_fixture", ["extended_color_light"]) +async def test_light_unexpected_color_mode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, + color_mode: Any, +) -> None: + """Test a light that reports a color mode we cannot map.""" + entity_id = "light.mock_extended_color_light" + + set_node_attribute(matter_node, 1, 768, 8, 0) + set_node_attribute(matter_node, 1, 8, 0, 128) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.attributes["color_mode"] == ColorMode.HS + + set_node_attribute(matter_node, 1, 768, 8, color_mode) + await trigger_subscription_callback(hass, matter_client) + + state = hass.states.get(entity_id) + assert state is not None + assert state.state == "on" + # the color the light is showing is anyone's guess, but it is still a light + assert state.attributes["color_mode"] == ColorMode.UNKNOWN + + await hass.services.async_call( + "light", + "turn_on", + {"entity_id": entity_id, "brightness": 128}, + blocking=True, + ) + + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=1, + command=clusters.LevelControl.Commands.MoveToLevelWithOnOff( + level=128, + transitionTime=0, + ), + )