mirror of
https://github.com/home-assistant/core.git
synced 2026-09-25 17:04:04 -04:00
Guard against out-of-spec values from Matter devices (#182034)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user