diff --git a/homeassistant/components/matter/climate.py b/homeassistant/components/matter/climate.py index 1b949d3ebfb..163d2c23dcb 100644 --- a/homeassistant/components/matter/climate.py +++ b/homeassistant/components/matter/climate.py @@ -42,7 +42,33 @@ HVAC_SYSTEM_MODE_MAP = { HVACMode.HEAT_COOL: 1, HVACMode.COOL: 3, HVACMode.HEAT: 4, + HVACMode.DRY: 8, + HVACMode.FAN_ONLY: 7, } + +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. + # We were told this is just some legacy inheritance from zigbee specs. + # In the list below specify tuples of (vendorid, productid) of devices for + # which we just need a single setpoint to control both heating and cooling. + (0x1209, 0x8007), +} + +SUPPORT_DRY_MODE_DEVICES: set[tuple[int, int]] = { + # The Matter spec is missing a feature flag if the device supports a dry mode. + # In the list below specify tuples of (vendorid, productid) of devices that + # support dry mode. + (0x1209, 0x8007), +} + +SUPPORT_FAN_MODE_DEVICES: set[tuple[int, int]] = { + # The Matter spec is missing a feature flag if the device supports a fan-only mode. + # In the list below specify tuples of (vendorid, productid) of devices that + # support fan-only mode. + (0x1209, 0x8007), +} + SystemModeEnum = clusters.Thermostat.Enums.ThermostatSystemMode ControlSequenceEnum = clusters.Thermostat.Enums.ThermostatControlSequence ThermostatFeature = clusters.Thermostat.Bitmaps.Feature @@ -85,80 +111,91 @@ class MatterClimate(MatterEntity, ClimateEntity): ) -> None: """Initialize the Matter climate entity.""" super().__init__(matter_client, endpoint, entity_info) + product_id = self._endpoint.node.device_info.productID + vendor_id = self._endpoint.node.device_info.vendorID # set hvac_modes based on feature map self._attr_hvac_modes: list[HVACMode] = [HVACMode.OFF] feature_map = int( self.get_matter_attribute_value(clusters.Thermostat.Attributes.FeatureMap) ) + self._attr_supported_features = ( + ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF + ) if feature_map & ThermostatFeature.kHeating: self._attr_hvac_modes.append(HVACMode.HEAT) if feature_map & ThermostatFeature.kCooling: self._attr_hvac_modes.append(HVACMode.COOL) + if (vendor_id, product_id) in SUPPORT_DRY_MODE_DEVICES: + self._attr_hvac_modes.append(HVACMode.DRY) + if (vendor_id, product_id) in SUPPORT_FAN_MODE_DEVICES: + self._attr_hvac_modes.append(HVACMode.FAN_ONLY) if feature_map & ThermostatFeature.kAutoMode: self._attr_hvac_modes.append(HVACMode.HEAT_COOL) - self._attr_supported_features = ( - ClimateEntityFeature.TARGET_TEMPERATURE - | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE - | ClimateEntityFeature.TURN_OFF - ) + # only enable temperature_range feature if the device actually supports that + + if (vendor_id, product_id) not in SINGLE_SETPOINT_DEVICES: + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) if any(mode for mode in self.hvac_modes if mode != HVACMode.OFF): self._attr_supported_features |= ClimateEntityFeature.TURN_ON async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_hvac_mode: HVACMode | None = kwargs.get(ATTR_HVAC_MODE) + target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE) + target_temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if target_hvac_mode is not None: await self.async_set_hvac_mode(target_hvac_mode) - current_mode = target_hvac_mode or self.hvac_mode - command = None - if current_mode in (HVACMode.HEAT, HVACMode.COOL): - # when current mode is either heat or cool, the temperature arg must be provided. - temperature: float | None = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: - raise ValueError("Temperature must be provided") - if self.target_temperature is None: - raise ValueError("Current target_temperature should not be None") - command = self._create_optional_setpoint_command( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool - if current_mode == HVACMode.COOL - else clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, - temperature, - self.target_temperature, - ) - elif current_mode == HVACMode.HEAT_COOL: - temperature_low: float | None = kwargs.get(ATTR_TARGET_TEMP_LOW) - temperature_high: float | None = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if temperature_low is None or temperature_high is None: - raise ValueError( - "temperature_low and temperature_high must be provided" + + if target_temperature is not None: + # single setpoint control + if self.target_temperature != target_temperature: + if current_mode == HVACMode.COOL: + matter_attribute = ( + clusters.Thermostat.Attributes.OccupiedCoolingSetpoint + ) + else: + matter_attribute = ( + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint + ) + 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=int(target_temperature * TEMPERATURE_SCALING_FACTOR), ) - if ( - self.target_temperature_low is None - or self.target_temperature_high is None - ): - raise ValueError( - "current target_temperature_low and target_temperature_high should not be None" + return + + if target_temperature_low is not None: + # multi setpoint control - low setpoint (heat) + if self.target_temperature_low != target_temperature_low: + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.Thermostat.Attributes.OccupiedHeatingSetpoint, + ), + value=int(target_temperature_low * TEMPERATURE_SCALING_FACTOR), ) - # due to ha send both high and low temperature, we need to check which one is changed - command = self._create_optional_setpoint_command( - clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, - temperature_low, - self.target_temperature_low, - ) - if command is None: - command = self._create_optional_setpoint_command( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, - temperature_high, - self.target_temperature_high, + + if target_temperature_high is not None: + # multi setpoint control - high setpoint (cool) + if self.target_temperature_high != target_temperature_high: + await self.matter_client.write_attribute( + node_id=self._endpoint.node.node_id, + attribute_path=create_attribute_path_from_attribute( + self._endpoint.endpoint_id, + clusters.Thermostat.Attributes.OccupiedCoolingSetpoint, + ), + value=int(target_temperature_high * TEMPERATURE_SCALING_FACTOR), ) - if command: - await self.matter_client.send_device_command( - node_id=self._endpoint.node.node_id, - endpoint_id=self._endpoint.endpoint_id, - command=command, - ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" @@ -201,6 +238,10 @@ class MatterClimate(MatterEntity, ClimateEntity): self._attr_hvac_mode = HVACMode.COOL case SystemModeEnum.kHeat | SystemModeEnum.kEmergencyHeat: self._attr_hvac_mode = HVACMode.HEAT + case SystemModeEnum.kFanOnly: + self._attr_hvac_mode = HVACMode.FAN_ONLY + case SystemModeEnum.kDry: + self._attr_hvac_mode = HVACMode.DRY case _: self._attr_hvac_mode = HVACMode.OFF # running state is an optional attribute @@ -271,24 +312,6 @@ class MatterClimate(MatterEntity, ClimateEntity): return float(value) / TEMPERATURE_SCALING_FACTOR return None - @staticmethod - def _create_optional_setpoint_command( - mode: clusters.Thermostat.Enums.SetpointAdjustMode | int, - target_temp: float, - current_target_temp: float, - ) -> clusters.Thermostat.Commands.SetpointRaiseLower | None: - """Create a setpoint command if the target temperature is different from the current one.""" - - temp_diff = int((target_temp - current_target_temp) * 10) - - if temp_diff == 0: - return None - - return clusters.Thermostat.Commands.SetpointRaiseLower( - mode, - temp_diff, - ) - # Discovery schema(s) to map Matter Attributes to HA entities DISCOVERY_SCHEMAS = [ diff --git a/tests/components/matter/fixtures/nodes/room-airconditioner.json b/tests/components/matter/fixtures/nodes/room-airconditioner.json index 11c29b0d8f4..770e217e68c 100644 --- a/tests/components/matter/fixtures/nodes/room-airconditioner.json +++ b/tests/components/matter/fixtures/nodes/room-airconditioner.json @@ -43,9 +43,9 @@ "0/31/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533], "0/40/0": 17, "0/40/1": "TEST_VENDOR", - "0/40/2": 65521, + "0/40/2": 4617, "0/40/3": "Room AirConditioner", - "0/40/4": 32774, + "0/40/4": 32775, "0/40/5": "", "0/40/6": "**REDACTED**", "0/40/7": 0, diff --git a/tests/components/matter/test_climate.py b/tests/components/matter/test_climate.py index de4626ef3d1..2b3ae922fb2 100644 --- a/tests/components/matter/test_climate.py +++ b/tests/components/matter/test_climate.py @@ -8,6 +8,7 @@ from matter_server.common.helpers.util import create_attribute_path_from_attribu import pytest from homeassistant.components.climate import HVACAction, HVACMode +from homeassistant.components.climate.const import ClimateEntityFeature from homeassistant.core import HomeAssistant from .common import ( @@ -37,67 +38,30 @@ async def room_airconditioner( # This tests needs to be adjusted to remove lingering tasks @pytest.mark.parametrize("expected_lingering_tasks", [True]) -async def test_thermostat( +async def test_thermostat_base( hass: HomeAssistant, matter_client: MagicMock, thermostat: MatterNode, ) -> None: - """Test thermostat.""" - # test default temp range + """Test thermostat base attributes and state updates.""" + # test entity attributes state = hass.states.get("climate.longan_link_hvac") assert state assert state.attributes["min_temp"] == 7 assert state.attributes["max_temp"] == 35 - - # test set temperature when target temp is None assert state.attributes["temperature"] is None assert state.state == HVACMode.COOL - with pytest.raises( - ValueError, match="Current target_temperature should not be None" - ): - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "temperature": 22.5, - }, - blocking=True, - ) - with pytest.raises(ValueError, match="Temperature must be provided"): - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 26, - }, - blocking=True, - ) - # change system mode to heat_cool - set_node_attribute(thermostat, 1, 513, 28, 1) - await trigger_subscription_callback(hass, matter_client) - with pytest.raises( - ValueError, - match="current target_temperature_low and target_temperature_high should not be None", - ): - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.state == HVACMode.HEAT_COOL - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 26, - }, - blocking=True, - ) + # test supported features correctly parsed + # including temperature_range support + mask = ( + ClimateEntityFeature.TARGET_TEMPERATURE + | ClimateEntityFeature.TURN_OFF + | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) + assert state.attributes["supported_features"] & mask == mask - # initial state + # test common state updates from device set_node_attribute(thermostat, 1, 513, 3, 1600) set_node_attribute(thermostat, 1, 513, 4, 3000) set_node_attribute(thermostat, 1, 513, 5, 1600) @@ -121,18 +85,6 @@ async def test_thermostat( assert state assert state.state == HVACMode.OFF - set_node_attribute(thermostat, 1, 513, 28, 7) - await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.state == HVACMode.FAN_ONLY - - set_node_attribute(thermostat, 1, 513, 28, 8) - await trigger_subscription_callback(hass, matter_client) - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.state == HVACMode.DRY - # test running state update from device set_node_attribute(thermostat, 1, 513, 41, 1) await trigger_subscription_callback(hass, matter_client) @@ -198,6 +150,19 @@ async def test_thermostat( assert state assert state.attributes["temperature"] == 20 + +# This tests needs to be adjusted to remove lingering tasks +@pytest.mark.parametrize("expected_lingering_tasks", [True]) +async def test_thermostat_service_calls( + hass: HomeAssistant, + matter_client: MagicMock, + thermostat: MatterNode, +) -> None: + """Test climate platform service calls.""" + # test single-setpoint temperature adjustment when cool mode is active + state = hass.states.get("climate.longan_link_hvac") + assert state + assert state.state == HVACMode.COOL await hass.services.async_call( "climate", "set_temperature", @@ -208,133 +173,87 @@ async def test_thermostat( blocking=True, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, - 50, - ), + attribute_path="1/513/17", + value=2500, ) - matter_client.send_device_command.reset_mock() + matter_client.write_attribute.reset_mock() - # change system mode to cool - set_node_attribute(thermostat, 1, 513, 28, 3) + # ensure that no command is executed when the temperature is the same + set_node_attribute(thermostat, 1, 513, 17, 2500) await trigger_subscription_callback(hass, matter_client) + await hass.services.async_call( + "climate", + "set_temperature", + { + "entity_id": "climate.longan_link_hvac", + "temperature": 25, + }, + blocking=True, + ) + assert matter_client.write_attribute.call_count == 0 + matter_client.write_attribute.reset_mock() + + # test single-setpoint temperature adjustment when heat mode is active + set_node_attribute(thermostat, 1, 513, 28, 4) + await trigger_subscription_callback(hass, matter_client) state = hass.states.get("climate.longan_link_hvac") assert state - assert state.state == HVACMode.COOL - - # change occupied cooling setpoint to 18 - set_node_attribute(thermostat, 1, 513, 17, 1800) - await trigger_subscription_callback(hass, matter_client) - - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.attributes["temperature"] == 18 + assert state.state == HVACMode.HEAT await hass.services.async_call( "climate", "set_temperature", { "entity_id": "climate.longan_link_hvac", - "temperature": 16, + "temperature": 20, }, blocking=True, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_count == 1 + assert matter_client.write_attribute.call_args == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, -20 - ), + attribute_path="1/513/18", + value=2000, ) - matter_client.send_device_command.reset_mock() + matter_client.write_attribute.reset_mock() - # change system mode to heat_cool + # test dual setpoint temperature adjustments when heat_cool mode is active set_node_attribute(thermostat, 1, 513, 28, 1) await trigger_subscription_callback(hass, matter_client) - with pytest.raises( - ValueError, match="temperature_low and temperature_high must be provided" - ): - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "temperature": 18, - }, - blocking=True, - ) - state = hass.states.get("climate.longan_link_hvac") assert state assert state.state == HVACMode.HEAT_COOL - # change occupied cooling setpoint to 18 - set_node_attribute(thermostat, 1, 513, 17, 2500) - await trigger_subscription_callback(hass, matter_client) - # change occupied heating setpoint to 18 - set_node_attribute(thermostat, 1, 513, 18, 1700) - await trigger_subscription_callback(hass, matter_client) - - state = hass.states.get("climate.longan_link_hvac") - assert state - assert state.attributes["target_temp_low"] == 17 - assert state.attributes["target_temp_high"] == 25 - - # change target_temp_low to 18 await hass.services.async_call( "climate", "set_temperature", { "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 25, + "target_temp_low": 10, + "target_temp_high": 30, }, blocking=True, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kHeat, 10 - ), + attribute_path="1/513/18", + value=1000, ) - matter_client.send_device_command.reset_mock() - set_node_attribute(thermostat, 1, 513, 18, 1800) - await trigger_subscription_callback(hass, matter_client) - - # change target_temp_high to 26 - await hass.services.async_call( - "climate", - "set_temperature", - { - "entity_id": "climate.longan_link_hvac", - "target_temp_low": 18, - "target_temp_high": 26, - }, - blocking=True, - ) - - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_args_list[1] == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, 10 - ), + attribute_path="1/513/17", + value=3000, ) - matter_client.send_device_command.reset_mock() - set_node_attribute(thermostat, 1, 513, 17, 2600) - await trigger_subscription_callback(hass, matter_client) + matter_client.write_attribute.reset_mock() + # test change HAVC mode to heat await hass.services.async_call( "climate", "set_hvac_mode", @@ -356,17 +275,6 @@ async def test_thermostat( ) matter_client.send_device_command.reset_mock() - with pytest.raises(ValueError, match="Unsupported hvac mode dry in Matter"): - await hass.services.async_call( - "climate", - "set_hvac_mode", - { - "entity_id": "climate.longan_link_hvac", - "hvac_mode": HVACMode.DRY, - }, - blocking=True, - ) - # change target_temp and hvac_mode in the same call matter_client.send_device_command.reset_mock() matter_client.write_attribute.reset_mock() @@ -380,8 +288,8 @@ async def test_thermostat( }, blocking=True, ) - assert matter_client.write_attribute.call_count == 1 - assert matter_client.write_attribute.call_args == call( + assert matter_client.write_attribute.call_count == 2 + assert matter_client.write_attribute.call_args_list[0] == call( node_id=thermostat.node_id, attribute_path=create_attribute_path_from_attribute( endpoint_id=1, @@ -389,14 +297,12 @@ async def test_thermostat( ), value=3, ) - assert matter_client.send_device_command.call_count == 1 - assert matter_client.send_device_command.call_args == call( + assert matter_client.write_attribute.call_args_list[1] == call( node_id=thermostat.node_id, - endpoint_id=1, - command=clusters.Thermostat.Commands.SetpointRaiseLower( - clusters.Thermostat.Enums.SetpointAdjustMode.kCool, -40 - ), + attribute_path="1/513/17", + value=2200, ) + matter_client.write_attribute.reset_mock() # This tests needs to be adjusted to remove lingering tasks @@ -412,3 +318,31 @@ async def test_room_airconditioner( assert state.attributes["current_temperature"] == 20 assert state.attributes["min_temp"] == 16 assert state.attributes["max_temp"] == 32 + + # test supported features correctly parsed + # WITHOUT temperature_range support + mask = ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TURN_OFF + assert state.attributes["supported_features"] & mask == mask + + # test supported HVAC modes include fan and dry modes + assert state.attributes["hvac_modes"] == [ + HVACMode.OFF, + HVACMode.HEAT, + HVACMode.COOL, + HVACMode.DRY, + HVACMode.FAN_ONLY, + HVACMode.HEAT_COOL, + ] + # test fan-only hvac mode + set_node_attribute(room_airconditioner, 1, 513, 28, 7) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.room_airconditioner") + assert state + assert state.state == HVACMode.FAN_ONLY + + # test dry hvac mode + set_node_attribute(room_airconditioner, 1, 513, 28, 8) + await trigger_subscription_callback(hass, matter_client) + state = hass.states.get("climate.room_airconditioner") + assert state + assert state.state == HVACMode.DRY