diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 6f6f87cfc86cb0..a67d5dcc8ebc66 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -16,6 +16,10 @@ ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + PRESET_AWAY, + PRESET_HOME, + PRESET_NONE, + PRESET_SLEEP, ClimateEntity, ClimateEntityDescription, ClimateEntityFeature, @@ -42,6 +46,18 @@ HVACMode.FAN_ONLY: 7, } +# Map of Matter PresetScenarioEnum to HA standard preset constants or custom names +# This ensures presets are translated correctly using HA's translation system. +# kUserDefined scenarios always use device-provided names. +PRESET_SCENARIO_TO_HA_PRESET: dict[int, str] = { + clusters.Thermostat.Enums.PresetScenarioEnum.kOccupied: PRESET_HOME, + clusters.Thermostat.Enums.PresetScenarioEnum.kUnoccupied: PRESET_AWAY, + clusters.Thermostat.Enums.PresetScenarioEnum.kSleep: PRESET_SLEEP, + clusters.Thermostat.Enums.PresetScenarioEnum.kWake: "wake", + clusters.Thermostat.Enums.PresetScenarioEnum.kVacation: "vacation", + clusters.Thermostat.Enums.PresetScenarioEnum.kGoingToSleep: "going_to_sleep", +} + SINGLE_SETPOINT_DEVICES: set[tuple[int, int]] = { # Some devices only have a single setpoint while the matter spec # assumes that you need separate setpoints for heating and cooling. @@ -159,7 +175,6 @@ } SystemModeEnum = clusters.Thermostat.Enums.SystemModeEnum -ControlSequenceEnum = clusters.Thermostat.Enums.ControlSequenceOfOperationEnum ThermostatFeature = clusters.Thermostat.Bitmaps.Feature @@ -195,10 +210,22 @@ class MatterClimate(MatterEntity, ClimateEntity): _attr_temperature_unit: str = UnitOfTemperature.CELSIUS _attr_hvac_mode: HVACMode = HVACMode.OFF + _matter_presets: list[clusters.Thermostat.Structs.PresetStruct] + _attr_preset_mode: str | None = None + _attr_preset_modes: list[str] | None = None _feature_map: int | None = None _platform_translation_key = "thermostat" + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize the climate entity.""" + # Initialize preset handle mapping as instance attribute before calling super().__init__() + # because MatterEntity.__init__() calls _update_from_device() which needs this attribute + self._matter_presets = [] + self._preset_handle_by_name: dict[str, bytes | None] = {} + self._preset_name_by_handle: dict[bytes | None, str] = {} + super().__init__(*args, **kwargs) + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) @@ -243,6 +270,34 @@ async def async_set_temperature(self, **kwargs: Any) -> None: matter_attribute=clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, ) + async def async_set_preset_mode(self, preset_mode: str) -> None: + """Set new preset mode.""" + preset_handle = self._preset_handle_by_name[preset_mode] + + command = clusters.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_handle + ) + await self.send_device_command(command) + + # Optimistic update is required because Matter devices usually confirm + # preset changes asynchronously via a later attribute subscription. + # Additionally, some devices based on connectedhomeip do not send a + # subscription report for ActivePresetHandle after SetActivePresetRequest + # because thermostat-server-presets.cpp/SetActivePreset() updates the + # value without notifying the reporting engine. Keep this optimistic + # update as a workaround for that SDK bug and for normal report delays. + # Reference: project-chip/connectedhomeip, + # src/app/clusters/thermostat-server/thermostat-server-presets.cpp. + self._attr_preset_mode = preset_mode + self.async_write_ha_state() + + # Keep the local ActivePresetHandle in sync until subscription update. + active_preset_path = create_attribute_path_from_attribute( + endpoint_id=self._endpoint.endpoint_id, + attribute=clusters.Thermostat.Attributes.ActivePresetHandle, + ) + self._endpoint.set_attribute_value(active_preset_path, preset_handle) + async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -267,10 +322,10 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: def _update_from_device(self) -> None: """Update from device.""" self._calculate_features() + self._attr_current_temperature = self._get_temperature_in_degrees( clusters.Thermostat.Attributes.LocalTemperature ) - self._attr_current_humidity = ( int(raw_measured_humidity) / HUMIDITY_SCALING_FACTOR if ( @@ -282,6 +337,81 @@ def _update_from_device(self) -> None: else None ) + self._update_presets() + + self._update_hvac_mode_and_action() + self._update_target_temperatures() + self._update_temperature_limits() + + @callback + def _update_presets(self) -> None: + """Update preset modes and active preset.""" + # Check if the device supports presets feature before attempting to load. + # Use the already computed supported features instead of re-reading + # the FeatureMap attribute to keep a single source of truth and avoid + # casting None when the attribute is temporarily unavailable. + supported_features = self._attr_supported_features or 0 + if not (supported_features & ClimateEntityFeature.PRESET_MODE): + # Device does not support presets, skip preset update + self._preset_handle_by_name.clear() + self._preset_name_by_handle.clear() + self._attr_preset_modes = [] + self._attr_preset_mode = None + return + + self._matter_presets = ( + self.get_matter_attribute_value(clusters.Thermostat.Attributes.Presets) + or [] + ) + # Build preset mapping: use device-provided name if available, else generate unique name + self._preset_handle_by_name.clear() + self._preset_name_by_handle.clear() + if self._matter_presets: + used_names = set() + for i, preset in enumerate(self._matter_presets, start=1): + preset_translation = PRESET_SCENARIO_TO_HA_PRESET.get( + preset.presetScenario + ) + if preset_translation: + preset_name = preset_translation.lower() + else: + name = str(preset.name) if preset.name is not None else "" + name = name.strip() + if name: + preset_name = name + else: + # Ensure fallback name is unique + j = i + preset_name = f"Preset{j}" + while preset_name in used_names: + j += 1 + preset_name = f"Preset{j}" + used_names.add(preset_name) + preset_handle = ( + preset.presetHandle + if isinstance(preset.presetHandle, (bytes, type(None))) + else None + ) + self._preset_handle_by_name[preset_name] = preset_handle + self._preset_name_by_handle[preset_handle] = preset_name + + # Always include PRESET_NONE to allow users to clear the preset + self._preset_handle_by_name[PRESET_NONE] = None + self._preset_name_by_handle[None] = PRESET_NONE + + self._attr_preset_modes = list(self._preset_handle_by_name) + + # Update active preset mode + active_preset_handle = self.get_matter_attribute_value( + clusters.Thermostat.Attributes.ActivePresetHandle + ) + self._attr_preset_mode = self._preset_name_by_handle.get( + active_preset_handle, PRESET_NONE + ) + + @callback + def _update_hvac_mode_and_action(self) -> None: + """Update HVAC mode and action from device.""" if self.get_matter_attribute_value(clusters.OnOff.Attributes.OnOff) is False: # special case: the appliance has a dedicated Power switch on the OnOff cluster # if the mains power is off - treat it as if the HVAC mode is off @@ -333,7 +463,10 @@ def _update_from_device(self) -> None: self._attr_hvac_action = HVACAction.FAN else: self._attr_hvac_action = HVACAction.OFF - # update target temperature high/low + + @callback + def _update_target_temperatures(self) -> None: + """Update target temperature or temperature range.""" supports_range = ( self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE @@ -359,6 +492,9 @@ def _update_from_device(self) -> None: clusters.Thermostat.Attributes.OccupiedHeatingSetpoint ) + @callback + def _update_temperature_limits(self) -> None: + """Update min and max temperature limits.""" # update min_temp if self._attr_hvac_mode == HVACMode.COOL: attribute = clusters.Thermostat.Attributes.AbsMinCoolSetpointLimit @@ -398,6 +534,9 @@ def _calculate_features( self._attr_supported_features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF ) + if feature_map & ThermostatFeature.kPresets: + self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE + # determine supported hvac modes if feature_map & ThermostatFeature.kHeating: self._attr_hvac_modes.append(HVACMode.HEAT) if feature_map & ThermostatFeature.kCooling: @@ -440,9 +579,13 @@ def _get_temperature_in_degrees( optional_attributes=( clusters.Thermostat.Attributes.FeatureMap, clusters.Thermostat.Attributes.ControlSequenceOfOperation, + clusters.Thermostat.Attributes.NumberOfPresets, clusters.Thermostat.Attributes.Occupancy, clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + clusters.Thermostat.Attributes.Presets, + clusters.Thermostat.Attributes.PresetTypes, + clusters.Thermostat.Attributes.ActivePresetHandle, clusters.Thermostat.Attributes.SystemMode, clusters.Thermostat.Attributes.ThermostatRunningMode, clusters.Thermostat.Attributes.ThermostatRunningState, diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 4c9a52ce53412b..1d54b8130f31a0 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -145,7 +145,16 @@ }, "climate": { "thermostat": { - "name": "Thermostat" + "name": "Thermostat", + "state_attributes": { + "preset_mode": { + "state": { + "going_to_sleep": "Going to sleep", + "vacation": "Vacation", + "wake": "Wake" + } + } + } } }, "cover": { diff --git a/tests/components/matter/fixtures/nodes/eve_thermo_v5.json b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json index 1d3c4f018fec79..01923529ed9598 100644 --- a/tests/components/matter/fixtures/nodes/eve_thermo_v5.json +++ b/tests/components/matter/fixtures/nodes/eve_thermo_v5.json @@ -501,7 +501,7 @@ } ], "1/513/74": 8, - "1/513/78": null, + "1/513/78": "AQ==", "1/513/80": [ { "0": "AQ==", diff --git a/tests/components/matter/snapshots/test_climate.ambr b/tests/components/matter/snapshots/test_climate.ambr index 43b1d92b610073..d3be86fa827a04 100644 --- a/tests/components/matter/snapshots/test_climate.ambr +++ b/tests/components/matter/snapshots/test_climate.ambr @@ -206,6 +206,16 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, + 'preset_modes': list([ + 'home', + 'away', + 'sleep', + 'wake', + 'vacation', + 'going_to_sleep', + 'Eco', + 'none', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -231,7 +241,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-000000000000000C-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -248,7 +258,18 @@ ]), 'max_temp': 30.0, 'min_temp': 10.0, - 'supported_features': , + 'preset_mode': 'home', + 'preset_modes': list([ + 'home', + 'away', + 'sleep', + 'wake', + 'vacation', + 'going_to_sleep', + 'Eco', + 'none', + ]), + 'supported_features': , 'temperature': 17.5, }), 'context': , @@ -482,6 +503,11 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, + 'preset_modes': list([ + 'home', + 'away', + 'none', + ]), }), 'config_entry_id': , 'config_subentry_id': , @@ -507,7 +533,7 @@ 'platform': 'matter', 'previous_unique_id': None, 'suggested_object_id': None, - 'supported_features': , + 'supported_features': , 'translation_key': 'thermostat', 'unique_id': '00000000000004D2-0000000000000096-MatterNodeDevice-1-MatterThermostat-513-0', 'unit_of_measurement': None, @@ -527,7 +553,13 @@ ]), 'max_temp': 32.0, 'min_temp': 7.0, - 'supported_features': , + 'preset_mode': 'none', + 'preset_modes': list([ + 'home', + 'away', + 'none', + ]), + 'supported_features': , 'target_temp_high': 26.0, 'target_temp_low': 20.0, 'temperature': None, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index 8c133802c35bba..2c859bfee7a8be 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -8,10 +8,17 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.climate import ClimateEntityFeature, HVACAction, HVACMode +from homeassistant.components.climate import ( + PRESET_NONE, + ClimateEntityFeature, + HVACAction, + HVACMode, +) from homeassistant.const import Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_component import DATA_INSTANCES from .common import ( set_node_attribute, @@ -316,6 +323,35 @@ async def test_thermostat_service_calls( ) matter_client.write_attribute.reset_mock() + # test changing only target_temp_high when target_temp_low stays the same + set_node_attribute(matter_node, 1, 513, 18, 1000) + set_node_attribute(matter_node, 1, 513, 17, 2500) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.attributes["target_temp_high"] == 25 + assert state.attributes["target_temp_low"] == 10 + + await hass.services.async_call( + "climate", + "set_temperature", + { + "entity_id": "climate.longan_link_hvac", + "target_temp_low": 10, # Same as current + "target_temp_high": 28, # Different from current + }, + blocking=True, + ) + + # Only target_temp_high should be written since target_temp_low hasn't changed + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( + node_id=matter_node.node_id, + attribute_path="1/513/17", + value=2800, + ) + matter_client.write_attribute.reset_mock() + # test change HAVC mode to heat await hass.services.async_call( "climate", @@ -419,3 +455,317 @@ async def test_room_airconditioner( await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.room_airconditioner") assert state.attributes["supported_features"] & ClimateEntityFeature.TURN_ON + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) +async def test_eve_thermo_v5_presets( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test Eve Thermo v5 thermostat presets attributes and state updates.""" + # test entity attributes + entity_id = "climate.eve_thermo_20ecd1701" + state = hass.states.get(entity_id) + assert state + + # test supported features correctly parsed + mask = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.PRESET_MODE + ) + assert state.attributes["supported_features"] & mask == mask + + # Test preset modes parsed correctly from Eve Thermo v5 + # Should use HA standard presets for known ones, original names for others + # PRESET_NONE is always included to allow users to clear the preset + assert state.attributes["preset_modes"] == [ + "home", + "away", + "sleep", + "wake", + "vacation", + "going_to_sleep", + "Eco", + PRESET_NONE, + ] + assert state.attributes["preset_mode"] == "home" + + # Get presets from the node for dynamic testing + presets_attribute = matter_node.endpoints[1].get_attribute_value( + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + ) + preset_by_name = {preset.name: preset.presetHandle for preset in presets_attribute} + + # test set_preset_mode with "home" preset (HA standard) + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "home", + }, + 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.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Home"] + ), + ) + # Verify preset_mode is optimistically updated + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "home" + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with "away" preset (HA standard) + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "away", + }, + 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.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Away"] + ), + ) + # Verify preset_mode is optimistically updated + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "away" + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with "eco" preset (custom, device-provided name) + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Eco", + }, + 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.Thermostat.Commands.SetActivePresetRequest( + presetHandle=preset_by_name["Eco"] + ), + ) + matter_client.send_device_command.reset_mock() + + # test set_preset_mode with invalid preset mode + # The climate platform validates preset modes before calling our method + + # Get current state to derive expected modes + state = hass.states.get(entity_id) + assert state + expected_modes = ", ".join(state.attributes["preset_modes"]) + + with pytest.raises(ServiceValidationError) as err: + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "InvalidPreset", + }, + blocking=True, + ) + + assert err.value.translation_key == "not_valid_preset_mode" + assert err.value.translation_placeholders == { + "mode": "InvalidPreset", + "modes": expected_modes, + } + + # Ensure no command was sent for invalid preset + assert matter_client.send_device_command.call_count == 0 + # Test that preset_mode is updated when ActivePresetHandle is set from device + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + preset_by_name["Home"], + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "home" + + # Test that preset_mode is updated when ActivePresetHandle changes to different preset + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + preset_by_name["Away"], + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "away" + + # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE + + # Test that users can set preset_mode to PRESET_NONE to clear the active preset + matter_client.send_device_command.reset_mock() + # First set a preset so we have something to clear + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "home", + }, + blocking=True, + ) + matter_client.send_device_command.reset_mock() + + # Now call set_preset_mode with PRESET_NONE to clear it + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": PRESET_NONE, + }, + blocking=True, + ) + + # Verify the command was sent with null value to clear the preset + 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.Thermostat.Commands.SetActivePresetRequest(presetHandle=None), + ) + # Verify preset_mode is optimistically updated to PRESET_NONE + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE + + +@pytest.mark.parametrize("node_fixture", ["longan_link_thermostat"]) +async def test_hvac_mode_error_on_unsupported_mode( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test HVAC mode error when calling entity method directly with unsupported mode.""" + entity_id = "climate.longan_link_hvac" + + # Get the entity object directly via component using DATA_INSTANCES helper + component = hass.data.get(DATA_INSTANCES, {}).get(Platform.CLIMATE) + assert component is not None + + entity = component.get_entity(entity_id) + assert entity is not None + + # Test calling async_set_hvac_mode directly with an unsupported HVAC mode string + # We pass a string that's not in HVAC_SYSTEM_MODE_MAP + with pytest.raises(ValueError, match="Unsupported hvac mode"): + await entity.async_set_hvac_mode("unsupported_mode") + + # Ensure no command was sent + assert matter_client.write_attribute.call_count == 0 + + +@pytest.mark.parametrize("node_fixture", ["eve_thermo_v5"]) +async def test_preset_mode_with_unnamed_preset( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test preset mode when a preset has no name or empty name. + + This tests the fallback preset naming case where a preset does not have + a mapped presetScenario and also has no device-provided name, requiring + the fallback Preset{i} naming pattern. + """ + entity_id = "climate.eve_thermo_20ecd1701" + + # Get current presets from the node + presets_attribute = matter_node.endpoints[1].get_attribute_value( + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + ) + + assert presets_attribute is not None + + # Add a new preset with unmapped scenario (e.g., 255) and no name + new_preset = clusters.Thermostat.Structs.PresetStruct( + presetHandle=b"\xff", + presetScenario=255, # Unmapped scenario + name="", # Empty name + ) + presets_attribute.append(new_preset) + + # Update the node with the new preset list + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.Presets.attribute_id, + presets_attribute, + ) + + # Trigger subscription callback to update entity + await trigger_subscription_callback(hass, matter_client) + + # Verify the preset was added with the fallback name "Preset8" + state = hass.states.get(entity_id) + assert state + assert "Preset8" in state.attributes["preset_modes"] + + # Test that the unnamed preset can be set as active + await hass.services.async_call( + "climate", + "set_preset_mode", + { + "entity_id": entity_id, + "preset_mode": "Preset8", + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == "Preset8" + + # Test that preset_mode is PRESET_NONE when ActivePresetHandle is cleared + set_node_attribute( + matter_node, + 1, + 513, + clusters.Thermostat.Attributes.ActivePresetHandle.attribute_id, + None, + ) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get(entity_id) + assert state + assert state.attributes["preset_mode"] == PRESET_NONE