Add Eve Thermo TRV Matter features (#135635)

* Add Eve Thermo Matter features

* Update homeassistant/components/matter/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/matter/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/matter/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Add Eve Thermo Child lock test

* Update homeassistant/components/matter/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Update homeassistant/components/matter/switch.py

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Implement thorough Child lock testing

* Apply suggestions from code review

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Marcel van der Veldt <m.vanderveldt@outlook.com>
This commit is contained in:
krakonos1602 2025-01-22 03:42:07 +01:00 committed by GitHub
parent 18ab882536
commit ffcb4d676b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 403 additions and 3 deletions

View File

@ -61,6 +61,15 @@
"battery_replacement_description": {
"default": "mdi:battery-sync-outline"
}
},
"switch": {
"child_lock": {
"default": "mdi:lock",
"state": {
"on": "mdi:lock",
"off": "mdi:lock-off"
}
}
}
}
}

View File

@ -15,7 +15,13 @@ from homeassistant.components.number import (
NumberMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory, Platform, UnitOfLength, UnitOfTime
from homeassistant.const import (
EntityCategory,
Platform,
UnitOfLength,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -155,4 +161,25 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterNumber,
required_attributes=(custom_clusters.EveCluster.Attributes.Altitude,),
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="EveTemperatureOffset",
device_class=NumberDeviceClass.TEMPERATURE,
entity_category=EntityCategory.CONFIG,
translation_key="temperature_offset",
native_max_value=25,
native_min_value=-25,
native_step=0.5,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
measurement_to_ha=lambda x: None if x is None else x / 10,
ha_to_native_value=lambda x: round(x * 10),
mode=NumberMode.BOX,
),
entity_class=MatterNumber,
required_attributes=(
clusters.Thermostat.Attributes.LocalTemperatureCalibration,
),
vendor_id=(4874,),
),
]

View File

@ -254,4 +254,25 @@ DISCOVERY_SCHEMAS = [
entity_class=MatterSelectEntity,
required_attributes=(clusters.SmokeCoAlarm.Attributes.SmokeSensitivityLevel,),
),
MatterDiscoverySchema(
platform=Platform.SELECT,
entity_description=MatterSelectEntityDescription(
key="TrvTemperatureDisplayMode",
entity_category=EntityCategory.CONFIG,
translation_key="temperature_display_mode",
options=["Celsius", "Fahrenheit"],
measurement_to_ha={
0: "Celsius",
1: "Fahrenheit",
}.get,
ha_to_native_value={
"Celsius": 0,
"Fahrenheit": 1,
}.get,
),
entity_class=MatterSelectEntity,
required_attributes=(
clusters.ThermostatUserInterfaceConfiguration.Attributes.TemperatureDisplayMode,
),
),
]

View File

@ -161,6 +161,9 @@
},
"altitude": {
"name": "Altitude above Sea Level"
},
"temperature_offset": {
"name": "Temperature offset"
}
},
"light": {
@ -196,6 +199,9 @@
"toggle": "[%key:common::action::toggle%]",
"previous": "Previous"
}
},
"temperature_display_mode": {
"name": "Temperature display mode"
}
},
"sensor": {
@ -256,6 +262,9 @@
},
"power": {
"name": "Power"
},
"child_lock": {
"name": "Child lock"
}
},
"vacuum": {

View File

@ -2,10 +2,12 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from chip.clusters import Objects as clusters
from matter_server.client.models import device_types
from matter_server.common.helpers.util import create_attribute_path_from_attribute
from homeassistant.components.switch import (
SwitchDeviceClass,
@ -13,11 +15,11 @@ from homeassistant.components.switch import (
SwitchEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.const import EntityCategory, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .entity import MatterEntity
from .entity import MatterEntity, MatterEntityDescription
from .helpers import get_matter
from .models import MatterDiscoverySchema
@ -61,6 +63,49 @@ class MatterSwitch(MatterEntity, SwitchEntity):
)
@dataclass(frozen=True)
class MatterNumericSwitchEntityDescription(
SwitchEntityDescription, MatterEntityDescription
):
"""Describe Matter Numeric Switch entities."""
class MatterNumericSwitch(MatterSwitch):
"""Representation of a Matter Enum Attribute as a Switch entity."""
entity_description: MatterNumericSwitchEntityDescription
async def _async_set_native_value(self, value: bool) -> None:
"""Update the current value."""
matter_attribute = self._entity_info.primary_attribute
if value_convert := self.entity_description.ha_to_native_value:
send_value = value_convert(value)
await self.matter_client.write_attribute(
node_id=self._endpoint.node.node_id,
attribute_path=create_attribute_path_from_attribute(
self._endpoint.endpoint_id,
matter_attribute,
),
value=send_value,
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn switch on."""
await self._async_set_native_value(True)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn switch off."""
await self._async_set_native_value(False)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
value = self.get_matter_attribute_value(self._entity_info.primary_attribute)
if value_convert := self.entity_description.measurement_to_ha:
value = value_convert(value)
self._attr_is_on = value
# Discovery schema(s) to map Matter Attributes to HA entities
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
@ -139,4 +184,25 @@ DISCOVERY_SCHEMAS = [
device_types.Speaker,
),
),
MatterDiscoverySchema(
platform=Platform.SWITCH,
entity_description=MatterNumericSwitchEntityDescription(
key="EveTrvChildLock",
entity_category=EntityCategory.CONFIG,
translation_key="child_lock",
measurement_to_ha={
0: False,
1: True,
}.get,
ha_to_native_value={
False: 0,
True: 1,
}.get,
),
entity_class=MatterNumericSwitch,
required_attributes=(
clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout,
),
vendor_id=(4874,),
),
]

View File

@ -388,6 +388,63 @@
'state': '1.0',
})
# ---
# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'max': 25,
'min': -25,
'mode': <NumberMode.BOX: 'box'>,
'step': 0.5,
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'number',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'number.eve_thermo_temperature_offset',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <NumberDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature offset',
'platform': 'matter',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'temperature_offset',
'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTemperatureOffset-513-16',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_numbers[eve_thermo][number.eve_thermo_temperature_offset-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'Eve Thermo Temperature offset',
'max': 25,
'min': -25,
'mode': <NumberMode.BOX: 'box'>,
'step': 0.5,
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'number.eve_thermo_temperature_offset',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0.0',
})
# ---
# name: test_numbers[eve_weather_sensor][number.eve_weather_altitude_above_sea_level-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -546,6 +546,61 @@
'state': 'previous',
})
# ---
# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'Celsius',
'Fahrenheit',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.eve_thermo_temperature_display_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Temperature display mode',
'platform': 'matter',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'temperature_display_mode',
'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0',
'unit_of_measurement': None,
})
# ---
# name: test_selects[eve_thermo][select.eve_thermo_temperature_display_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Thermo Temperature display mode',
'options': list([
'Celsius',
'Fahrenheit',
]),
}),
'context': <ANY>,
'entity_id': 'select.eve_thermo_temperature_display_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Celsius',
})
# ---
# name: test_selects[extended_color_light][select.mock_extended_color_light_lighting-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
@ -1573,6 +1628,61 @@
'state': 'previous',
})
# ---
# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': dict({
'options': list([
'Celsius',
'Fahrenheit',
]),
}),
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'select',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'select.longan_link_hvac_temperature_display_mode',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Temperature display mode',
'platform': 'matter',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'temperature_display_mode',
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-TrvTemperatureDisplayMode-516-0',
'unit_of_measurement': None,
})
# ---
# name: test_selects[thermostat][select.longan_link_hvac_temperature_display_mode-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Longan link HVAC Temperature display mode',
'options': list([
'Celsius',
'Fahrenheit',
]),
}),
'context': <ANY>,
'entity_id': 'select.longan_link_hvac_temperature_display_mode',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'Celsius',
})
# ---
# name: test_selects[vacuum_cleaner][select.mock_vacuum_clean_mode-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -187,6 +187,52 @@
'state': 'off',
})
# ---
# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.eve_thermo_child_lock',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Child lock',
'platform': 'matter',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'child_lock',
'unique_id': '00000000000004D2-0000000000000021-MatterNodeDevice-1-EveTrvChildLock-516-1',
'unit_of_measurement': None,
})
# ---
# name: test_switches[eve_thermo][switch.eve_thermo_child_lock-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Eve Thermo Child lock',
}),
'context': <ANY>,
'entity_id': 'switch.eve_thermo_child_lock',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@ -4,6 +4,7 @@ from unittest.mock import MagicMock, call
from chip.clusters import Objects as clusters
from matter_server.client.models.node import MatterNode
from matter_server.common.helpers.util import create_attribute_path_from_attribute
import pytest
from syrupy import SnapshotAssertion
@ -110,3 +111,57 @@ async def test_power_switch(hass: HomeAssistant, matter_node: MatterNode) -> Non
assert state
assert state.state == "off"
assert state.attributes["friendly_name"] == "Room AirConditioner Power"
@pytest.mark.parametrize("node_fixture", ["eve_thermo"])
async def test_numeric_switch(
hass: HomeAssistant,
matter_client: MagicMock,
matter_node: MatterNode,
) -> None:
"""Test numeric switch entity is discovered and working using an Eve Thermo fixture ."""
state = hass.states.get("switch.eve_thermo_child_lock")
assert state
assert state.state == "off"
# name should be derived from description attribute
assert state.attributes["friendly_name"] == "Eve Thermo Child lock"
# test attribute changes
set_node_attribute(matter_node, 1, 516, 1, 1)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("switch.eve_thermo_child_lock")
assert state.state == "on"
set_node_attribute(matter_node, 1, 516, 1, 0)
await trigger_subscription_callback(hass, matter_client)
state = hass.states.get("switch.eve_thermo_child_lock")
assert state.state == "off"
# test switch service
await hass.services.async_call(
"switch",
"turn_on",
{"entity_id": "switch.eve_thermo_child_lock"},
blocking=True,
)
assert matter_client.write_attribute.call_count == 1
assert matter_client.write_attribute.call_args_list[0] == call(
node_id=matter_node.node_id,
attribute_path=create_attribute_path_from_attribute(
endpoint_id=1,
attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout,
),
value=1,
)
await hass.services.async_call(
"switch",
"turn_off",
{"entity_id": "switch.eve_thermo_child_lock"},
blocking=True,
)
assert matter_client.write_attribute.call_count == 2
assert matter_client.write_attribute.call_args_list[1] == call(
node_id=matter_node.node_id,
attribute_path=create_attribute_path_from_attribute(
endpoint_id=1,
attribute=clusters.ThermostatUserInterfaceConfiguration.Attributes.KeypadLockout,
),
value=0,
)