From 9145c009f834befcf4a1021f2f7079afa323ec2b Mon Sep 17 00:00:00 2001 From: StellarSea Date: Mon, 31 Aug 2026 00:13:16 +0900 Subject: [PATCH] Use SmartThings custom setpoint bounds for air conditioners (#180304) Co-authored-by: Claude Opus 5 --- .../components/smartthings/climate.py | 40 +++++++++-- .../smartthings/snapshots/test_climate.ambr | 24 +++---- tests/components/smartthings/test_climate.py | 68 ++++++++++++++++++- 3 files changed, 112 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9ff307942241..bc6ed4951310 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -415,6 +415,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): Capability.THERMOSTAT_COOLING_SETPOINT, Capability.TEMPERATURE_MEASUREMENT, Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Capability.DEMAND_RESPONSE_LOAD_CONTROL, }, ) @@ -633,28 +634,55 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): step, self._setpoint_range_unit, self.temperature_unit ) + def _get_custom_setpoint(self, attribute: Attribute) -> float | None: + """Return a setpoint bound from the custom setpoint control capability.""" + if not self.supports_capability(Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL): + return None + setpoint = self.get_attribute_value( + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, attribute + ) + # Devices report -1000 when the bound is not available + if setpoint is None or setpoint == -1000: + return None + unit = self._internal_state[Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL][ + attribute + ].unit + return TemperatureConverter.convert( + setpoint, + UNIT_MAP[unit] if unit else self.temperature_unit, + self.temperature_unit, + ) + @property @override def min_temp(self) -> float: """Return the minimum temperature.""" - if (minimum := self._get_setpoint_range_value("minimum")) is None: + if (minimum := self._get_setpoint_range_value("minimum")) is not None: return TemperatureConverter.convert( - DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit + minimum, self._setpoint_range_unit, self.temperature_unit ) + if ( + minimum := self._get_custom_setpoint(Attribute.MINIMUM_SETPOINT) + ) is not None: + return minimum return TemperatureConverter.convert( - minimum, self._setpoint_range_unit, self.temperature_unit + DEFAULT_MIN_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit ) @property @override def max_temp(self) -> float: """Return the maximum temperature.""" - if (maximum := self._get_setpoint_range_value("maximum")) is None: + if (maximum := self._get_setpoint_range_value("maximum")) is not None: return TemperatureConverter.convert( - DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit + maximum, self._setpoint_range_unit, self.temperature_unit ) + if ( + maximum := self._get_custom_setpoint(Attribute.MAXIMUM_SETPOINT) + ) is not None: + return maximum return TemperatureConverter.convert( - maximum, self._setpoint_range_unit, self.temperature_unit + DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit ) @property diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index fc8330524d3f..656e751c4497 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -349,8 +349,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : list([ 'none', 'wind_free', @@ -412,8 +412,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : 'wind_free', : list([ 'none', @@ -454,8 +454,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : list([ 'none', 'sleep', @@ -529,8 +529,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : 'none', : list([ 'none', @@ -709,8 +709,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, }), 'config_entry_id': , 'config_subentry_id': , @@ -762,8 +762,8 @@ , , ]), - : 35, - : 7, + : 30, + : 16, : , : 18, }), diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 77b48acc339b..6ab9a8128987 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -824,9 +824,10 @@ async def test_ac_setpoint_range_update( """Test the setpoint range is used when the device reports one.""" await setup_integration(hass, mock_config_entry) + # Without a setpoint range the custom setpoint bounds are used state = hass.states.get("climate.theater_ac_office_granit") - assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP - assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP + assert state.attributes[ATTR_MIN_TEMP] == 16 + assert state.attributes[ATTR_MAX_TEMP] == 30 assert ATTR_TARGET_TEMP_STEP not in state.attributes await trigger_update( @@ -883,6 +884,69 @@ async def test_ac_setpoint_range_converted_to_device_unit( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1.8 +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"]) +async def test_ac_custom_setpoint_bounds( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the custom setpoint bounds are used when there is no setpoint range.""" + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.clim_salon") + assert state.attributes[ATTR_MIN_TEMP] == 16 + assert state.attributes[ATTR_MAX_TEMP] == 30 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"]) +@pytest.mark.parametrize("value", [None, -1000]) +async def test_ac_custom_setpoint_bounds_unavailable( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, + value: int | None, +) -> None: + """Test we fall back to the defaults when the custom bounds are unavailable.""" + set_attribute_value( + devices, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MINIMUM_SETPOINT, + value, + ) + set_attribute_value( + devices, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MAXIMUM_SETPOINT, + value, + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.clim_salon") + assert state.attributes[ATTR_MIN_TEMP] == DEFAULT_MIN_TEMP + assert state.attributes[ATTR_MAX_TEMP] == DEFAULT_MAX_TEMP + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_01001"]) +async def test_ac_setpoint_range_takes_precedence( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the setpoint range wins over the custom setpoint bounds.""" + set_attribute_value( + devices, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, + Attribute.MINIMUM_SETPOINT, + 5, + ) + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.theater_aire_dormitorio_principal") + assert state.attributes[ATTR_MIN_TEMP] == 16 + + @pytest.mark.parametrize("device_fixture", ["virtual_thermostat"]) async def test_thermostat_set_fan_mode( hass: HomeAssistant,