Guard against out-of-spec values from Matter devices (#182034)

This commit is contained in:
Franck Nijhof
2026-09-13 10:24:33 +02:00
committed by GitHub
parent 36bde370bf
commit fa9c573fc4
4 changed files with 192 additions and 15 deletions
+16 -2
View File
@@ -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
+25 -13
View File
@@ -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:
+72
View File
@@ -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"
+79
View File
@@ -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,
),
)