1
0
mirror of https://github.com/home-assistant/core.git synced 2026-05-16 05:21:35 +01:00
Files
core/tests/components/matter/test_entity.py
T

438 lines
16 KiB
Python

"""Test Matter entity behavior."""
from unittest.mock import MagicMock
from matter_server.client.models.node import MatterNode
from matter_server.common.models import EventType
import pytest
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .common import (
set_node_attribute,
setup_integration_with_node_fixture,
trigger_subscription_callback,
)
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize(
("node_fixture", "entity_id", "expected_translation_key", "expected_name"),
[
("mock_onoff_light", "light.mock_onoff_light", "light", "Mock OnOff Light"),
("mock_door_lock", "lock.mock_door_lock", "lock", "Mock Door Lock"),
("mock_thermostat", "climate.mock_thermostat", "thermostat", "Mock Thermostat"),
("mock_valve", "valve.mock_valve", "valve", "Mock Valve"),
("mock_fan", "fan.mocked_fan_switch", "fan", "Mocked Fan Switch"),
("eve_energy_plug", "switch.eve_energy_plug", "switch", "Eve Energy Plug"),
("mock_vacuum_cleaner", "vacuum.mock_vacuum", "vacuum", "Mock Vacuum"),
(
"silabs_water_heater",
"water_heater.water_heater",
"water_heater",
"Water Heater",
),
],
)
async def test_single_endpoint_platform_translation_key(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
entity_id: str,
expected_translation_key: str,
expected_name: str,
) -> None:
"""Test single-endpoint entities on platforms with _platform_translation_key.
The translation key must always be present for state_attributes translations
and icon translations. When there is no endpoint postfix, the entity name
should be suppressed (None) so only the device name is displayed.
"""
entry = entity_registry.async_get(entity_id)
assert entry is not None
assert entry.translation_key == expected_translation_key
# No original_name means the entity name is suppressed,
# so only the device name is shown
assert entry.original_name is None
state = hass.states.get(entity_id)
assert state is not None
# The friendly name should be just the device name (no entity name appended)
assert state.name == expected_name
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize("node_fixture", ["inovelli_vtm31"])
async def test_multi_endpoint_entity_translation_key(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that multi-endpoint entities have a translation key and a name postfix.
When a device has the same primary attribute on multiple endpoints,
the entity name gets postfixed with the endpoint ID. The translation key
must still always be set for translations.
"""
# Endpoint 1
entry_1 = entity_registry.async_get("light.inovelli_light_1")
assert entry_1 is not None
assert entry_1.translation_key == "light"
assert entry_1.original_name == "Light (1)"
state_1 = hass.states.get("light.inovelli_light_1")
assert state_1 is not None
assert state_1.name == "Inovelli Light (1)"
# Endpoint 6
entry_6 = entity_registry.async_get("light.inovelli_light_6")
assert entry_6 is not None
assert entry_6.translation_key == "light"
assert entry_6.original_name == "Light (6)"
state_6 = hass.states.get("light.inovelli_light_6")
assert state_6 is not None
assert state_6.name == "Inovelli Light (6)"
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize("node_fixture", ["eve_energy_20ecn4101"])
async def test_label_modified_entity_translation_key(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that label-modified entities have a translation key and a label postfix.
When a device uses Matter labels to differentiate endpoints,
the entity name gets the label as a postfix. The translation key
must still always be set for translations.
"""
# Top outlet
entry_top = entity_registry.async_get("switch.eve_energy_20ecn4101_switch_top")
assert entry_top is not None
assert entry_top.translation_key == "switch"
assert entry_top.original_name == "Switch (top)"
state_top = hass.states.get("switch.eve_energy_20ecn4101_switch_top")
assert state_top is not None
assert state_top.name == "Eve Energy 20ECN4101 Switch (top)"
# Bottom outlet
entry_bottom = entity_registry.async_get(
"switch.eve_energy_20ecn4101_switch_bottom"
)
assert entry_bottom is not None
assert entry_bottom.translation_key == "switch"
assert entry_bottom.original_name == "Switch (bottom)"
state_bottom = hass.states.get("switch.eve_energy_20ecn4101_switch_bottom")
assert state_bottom is not None
assert state_bottom.name == "Eve Energy 20ECN4101 Switch (bottom)"
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize("node_fixture", ["eve_thermo_v4"])
async def test_description_translation_key_not_overridden(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that a description-level translation key is not overridden.
When an entity description already sets translation_key (e.g. "child_lock"),
the _platform_translation_key logic should not override it. The entity keeps
its description-level translation key and name.
"""
entry = entity_registry.async_get("switch.eve_thermo_20ebp1701_child_lock")
assert entry is not None
# The description-level translation key should be preserved, not overridden
# by _platform_translation_key ("switch")
assert entry.translation_key == "child_lock"
assert entry.original_name == "Child lock"
state = hass.states.get("switch.eve_thermo_20ebp1701_child_lock")
assert state is not None
assert state.name == "Eve Thermo 20EBP1701 Child lock"
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize("node_fixture", ["air_quality_sensor"])
async def test_entity_name_from_description_translation_key(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test entity name derived from an explicit description translation key.
Sensor entities do not set _platform_translation_key on the platform class.
When the entity description sets translation_key explicitly, the entity name
is derived from that translation key.
"""
entry = entity_registry.async_get(
"sensor.lightfi_aq1_air_quality_sensor_air_quality"
)
assert entry is not None
assert entry.translation_key == "air_quality"
assert entry.original_name == "Air quality"
state = hass.states.get("sensor.lightfi_aq1_air_quality_sensor_air_quality")
assert state is not None
assert state.name == "lightfi-aq1-air-quality-sensor Air quality"
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize("node_fixture", ["mock_temperature_sensor"])
async def test_entity_name_from_device_class(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test entity name derived from device class when no translation key is set.
Sensor entities do not set _platform_translation_key on the platform class.
When the entity description also has no translation_key, the entity name
is derived from the device class instead.
"""
entry = entity_registry.async_get("sensor.mock_temperature_sensor_temperature")
assert entry is not None
assert entry.translation_key is None
# Name is derived from the device class
assert entry.original_name == "Temperature"
state = hass.states.get("sensor.mock_temperature_sensor_temperature")
assert state is not None
assert state.name == "Mock Temperature Sensor Temperature"
# ---------------------------------------------------------------------------
# Tests for _get_bridged_reachable(), availability initialisation and
# BridgedDeviceBasicInformation.Reachable subscription logic.
#
# Fixture used for bridge tests: "atios_knx_bridge" (node_id=62,
# is_bridge=True). Endpoint 29 carries the BridgedDeviceBasicInformation
# cluster (57) with the Reachable attribute (attribute_id=17, path "29/57/17")
# and exposes an electrical-power sensor entity.
#
# Entity used as proxy: sensor.electricity_monitor_ac_power
# ---------------------------------------------------------------------------
_BRIDGE_ENTITY_ID = "sensor.electricity_monitor_ac_power"
# AttributePath for BridgedDeviceBasicInformation.Reachable on endpoint 29.
# create_attribute_path(29, 57, 17) == "29/57/17"
_REACHABLE_ATTR_PATH = "29/57/17"
async def test_bridged_entity_unavailable_when_reachable_false_at_startup(
hass: HomeAssistant,
matter_client: MagicMock,
) -> None:
"""Test Entity.available is False when Reachable attribute is False.
When BridgedDeviceBasicInformation.Reachable is False the entity must be
unavailable from the moment it is created, even though the node itself is
online.
"""
await setup_integration_with_node_fixture(
hass,
"atios_knx_bridge",
matter_client,
# Override: Reachable = False at fixture load time.
{_REACHABLE_ATTR_PATH: False},
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_bridged_entity_becomes_unavailable_on_reachable_false(
hass: HomeAssistant,
matter_client: MagicMock,
) -> None:
"""Test entity becomes unavailable when Reachable attribute changes to False.
Sequence:
1. Setup with Reachable=True → entity available.
2. Set Reachable=False via set_node_attribute.
3. Fire ATTRIBUTE_UPDATED event → entity must become unavailable.
"""
matter_node = await setup_integration_with_node_fixture(
hass, "atios_knx_bridge", matter_client
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Simulate the bridge reporting the endpoint as unreachable.
set_node_attribute(matter_node, 29, 57, 17, False)
await trigger_subscription_callback(
hass,
matter_client,
event=EventType.ATTRIBUTE_UPDATED,
data=(matter_node.node_id, _REACHABLE_ATTR_PATH, False),
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
async def test_bridged_entity_recovers_when_reachable_true(
hass: HomeAssistant,
matter_client: MagicMock,
) -> None:
"""Test entity becomes available again when Reachable attribute returns to True.
Sequence:
1. Setup with Reachable=False → entity unavailable.
2. Set Reachable=True via set_node_attribute.
3. Fire ATTRIBUTE_UPDATED event → entity must become available.
"""
matter_node = await setup_integration_with_node_fixture(
hass,
"atios_knx_bridge",
matter_client,
{_REACHABLE_ATTR_PATH: False},
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Simulate the bridge reporting the endpoint as reachable again.
set_node_attribute(matter_node, 29, 57, 17, True)
await trigger_subscription_callback(
hass,
matter_client,
event=EventType.ATTRIBUTE_UPDATED,
data=(matter_node.node_id, _REACHABLE_ATTR_PATH, True),
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_bridged_entity_unavailable_when_node_goes_offline(
hass: HomeAssistant,
matter_client: MagicMock,
) -> None:
"""Test entity becomes unavailable when the bridge node goes offline.
Even if BridgedDeviceBasicInformation.Reachable is True, the entity must
become unavailable when node.available is False, because Entity.available
is computed as node.available AND BridgedDeviceBasicInformation.Reachable.
"""
matter_node = await setup_integration_with_node_fixture(
hass, "atios_knx_bridge", matter_client
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Take the whole node offline.
matter_node.node_data.available = False
await trigger_subscription_callback(
hass, matter_client, event=EventType.NODE_UPDATED, data=matter_node
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Bring the node back online.
matter_node.node_data.available = True
await trigger_subscription_callback(
hass, matter_client, event=EventType.NODE_UPDATED, data=matter_node
)
state = hass.states.get(_BRIDGE_ENTITY_ID)
assert state is not None
assert state.state != STATE_UNAVAILABLE
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize("node_fixture", ["mock_onoff_light"])
async def test_non_bridged_entity_availability_tracks_node(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test non-bridged entity availability tracks node.available only.
For an endpoint without BridgedDeviceBasicInformation.Reachable,
Entity.available equals node.available.
"""
entity_id = "light.mock_onoff_light"
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Take the node offline.
matter_node.node_data.available = False
await trigger_subscription_callback(
hass, matter_client, event=EventType.NODE_UPDATED, data=matter_node
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNAVAILABLE
# Bring the node back online.
matter_node.node_data.available = True
await trigger_subscription_callback(
hass, matter_client, event=EventType.NODE_UPDATED, data=matter_node
)
state = hass.states.get(entity_id)
assert state is not None
assert state.state != STATE_UNAVAILABLE
async def test_bridged_entity_subscribes_to_reachable_attribute(
hass: HomeAssistant,
matter_client: MagicMock,
) -> None:
"""Test that Entity subscribes to BridgedDeviceBasicInformation.Reachable.
When an endpoint has the BridgedDeviceBasicInformation.Reachable attribute
(i.e. has_attribute returns True), the entity must create an
ATTRIBUTE_UPDATED subscription for that attribute path so that reachability
changes trigger the matter event callback and update Entity.available.
"""
await setup_integration_with_node_fixture(hass, "atios_knx_bridge", matter_client)
subscribe_calls = matter_client.subscribe_events.call_args_list
assert any(
call.kwargs.get("attr_path_filter") == _REACHABLE_ATTR_PATH
and call.kwargs.get("event_filter") == EventType.ATTRIBUTE_UPDATED
for call in subscribe_calls
), (
f"Expected a subscribe_events call with attr_path_filter={_REACHABLE_ATTR_PATH!r} "
"and event_filter=ATTRIBUTE_UPDATED, but none was found."
)
@pytest.mark.usefixtures("matter_node")
@pytest.mark.parametrize("node_fixture", ["mock_onoff_light"])
async def test_non_bridged_entity_does_not_subscribe_to_reachable(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test that Entity does NOT subscribe to Reachable for non-bridge.
For an endpoint without BridgedDeviceBasicInformation.Reachable, no extra
subscription must be created for attribute path "*/57/17".
"""
subscribe_calls = matter_client.subscribe_events.call_args_list
# Endpoint 1 of mock_onoff_light has no cluster 57 at all.
# No subscription to any "*/57/17" path should exist.
assert not any(
isinstance(call.kwargs.get("attr_path_filter"), str)
and "/57/17" in call.kwargs["attr_path_filter"]
for call in subscribe_calls
), (
"Unexpected subscribe_events call for BridgedDeviceBasicInformation.Reachable on a non-bridged entity."
)