diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 63e57b50a8f64a..bc6ed4951310c7 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -29,6 +29,10 @@ from homeassistant.helpers import device_registry as dr from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.util.unit_conversion import ( + TemperatureConverter, + TemperatureDeltaConverter, +) from . import FullDevice, SmartThingsConfigEntry from .const import DOMAIN, MAIN, UNIT_MAP @@ -411,6 +415,7 @@ def __init__(self, client: SmartThings, device: FullDevice) -> None: Capability.THERMOSTAT_COOLING_SETPOINT, Capability.TEMPERATURE_MEASUREMENT, Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, + Capability.CUSTOM_THERMOSTAT_SETPOINT_CONTROL, Capability.DEMAND_RESPONSE_LOAD_CONTROL, }, ) @@ -608,27 +613,77 @@ def _get_setpoint_range_value(self, key: str) -> float | None: return None return setpoint_range.get(key) + @property + def _setpoint_range_unit(self) -> str: + """Return the unit the cooling setpoint range is reported in.""" + if ( + unit := self._internal_state[Capability.THERMOSTAT_COOLING_SETPOINT][ + Attribute.COOLING_SETPOINT_RANGE + ].unit + ) is None: + return self.temperature_unit + return UNIT_MAP[unit] + @property @override def target_temperature_step(self) -> float | None: """Return the supported step of target temperature.""" - return self._get_setpoint_range_value("step") + if (step := self._get_setpoint_range_value("step")) is None: + return None + return TemperatureDeltaConverter.convert( + 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: - return DEFAULT_MIN_TEMP - return minimum + if (minimum := self._get_setpoint_range_value("minimum")) is not None: + return TemperatureConverter.convert( + 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( + 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: - return DEFAULT_MAX_TEMP - return maximum + if (maximum := self._get_setpoint_range_value("maximum")) is not None: + return TemperatureConverter.convert( + 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( + DEFAULT_MAX_TEMP, UnitOfTemperature.CELSIUS, self.temperature_unit + ) @property @override diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index fc8330524d3f4e..656e751c449710 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 be68cc0da838e1..6ab9a812898767 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -51,6 +51,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.util.unit_system import US_CUSTOMARY_SYSTEM from . import ( set_attribute_value, @@ -823,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( @@ -843,6 +845,108 @@ async def test_ac_setpoint_range_update( assert state.attributes[ATTR_TARGET_TEMP_STEP] == 1 +@pytest.mark.parametrize("device_fixture", ["aux_ac"]) +async def test_ac_default_temperature_range_in_device_unit( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the default temperature range is expressed in the unit of the device.""" + hass.config.units = US_CUSTOMARY_SYSTEM + devices.get_device_status.return_value[MAIN][Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit = "F" + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.aux_a_c_on_off") + assert state.attributes[ATTR_MIN_TEMP] == 45 + assert state.attributes[ATTR_MAX_TEMP] == 95 + + +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_01001"]) +async def test_ac_setpoint_range_converted_to_device_unit( + hass: HomeAssistant, + devices: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the setpoint range is converted when it uses another unit.""" + hass.config.units = US_CUSTOMARY_SYSTEM + devices.get_device_status.return_value[MAIN][Capability.TEMPERATURE_MEASUREMENT][ + Attribute.TEMPERATURE + ].unit = "F" + + await setup_integration(hass, mock_config_entry) + + state = hass.states.get("climate.theater_aire_dormitorio_principal") + assert state.attributes[ATTR_MIN_TEMP] == 61 + assert state.attributes[ATTR_MAX_TEMP] == 86 + 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,