Use SmartThings custom setpoint bounds for air conditioners (#180304)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
StellarSea
2026-08-31 08:50:48 +00:00
committed by Franck Nijhof
co-authored by Claude Opus 5
parent 9347967c34
commit 750c1cb2d1
3 changed files with 112 additions and 20 deletions
@@ -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
@@ -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',
@@ -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',
@@ -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',
@@ -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',
@@ -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>,
@@ -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,
}),
+66 -2
View File
@@ -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,