Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 62 additions & 7 deletions homeassistant/components/smartthings/climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
)
Expand Down Expand Up @@ -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
Expand Down
24 changes: 12 additions & 12 deletions tests/components/smartthings/snapshots/test_climate.ambr
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,8 @@
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 35,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 7,
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16,
<ClimateEntityCapabilityAttribute.PRESET_MODES: 'preset_modes'>: list([
'none',
'wind_free',
Expand Down Expand Up @@ -412,8 +412,8 @@
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 35,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 7,
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16,
<ClimateEntityStateAttribute.PRESET_MODE: 'preset_mode'>: 'wind_free',
<ClimateEntityCapabilityAttribute.PRESET_MODES: 'preset_modes'>: list([
'none',
Expand Down Expand Up @@ -454,8 +454,8 @@
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 35,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 7,
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16,
<ClimateEntityCapabilityAttribute.PRESET_MODES: 'preset_modes'>: list([
'none',
'sleep',
Expand Down Expand Up @@ -529,8 +529,8 @@
<HVACMode.AUTO: 'auto'>,
<HVACMode.HEAT: 'heat'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 35,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 7,
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16,
<ClimateEntityStateAttribute.PRESET_MODE: 'preset_mode'>: 'none',
<ClimateEntityCapabilityAttribute.PRESET_MODES: 'preset_modes'>: list([
'none',
Expand Down Expand Up @@ -709,8 +709,8 @@
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.AUTO: 'auto'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 35,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 7,
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
Expand Down Expand Up @@ -762,8 +762,8 @@
<HVACMode.FAN_ONLY: 'fan_only'>,
<HVACMode.AUTO: 'auto'>,
]),
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 35,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 7,
<ClimateEntityCapabilityAttribute.MAX_TEMP: 'max_temp'>: 30,
<ClimateEntityCapabilityAttribute.MIN_TEMP: 'min_temp'>: 16,
<EntityStateAttribute.SUPPORTED_FEATURES: 'supported_features'>: <ClimateEntityFeature: 393>,
<ClimateEntityStateAttribute.TARGET_TEMPERATURE: 'temperature'>: 18,
}),
Expand Down
108 changes: 106 additions & 2 deletions tests/components/smartthings/test_climate.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand All @@ -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,
Expand Down
Loading