Skip to content

Commit 5b10734

Browse files
kbx81jesserockzbdraco
authored
Patch ESPHome client to handle climate UI correctly (#151897)
Co-authored-by: Jesse Hills <[email protected]> Co-authored-by: J. Nick Koston <[email protected]> Co-authored-by: J. Nick Koston <[email protected]> Co-authored-by: J. Nick Koston <[email protected]>
1 parent 46fa98e commit 5b10734

File tree

2 files changed

+212
-27
lines changed

2 files changed

+212
-27
lines changed

homeassistant/components/esphome/climate.py

Lines changed: 38 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,9 @@
5555
UnitOfTemperature,
5656
)
5757
from homeassistant.core import callback
58+
from homeassistant.exceptions import ServiceValidationError
5859

60+
from .const import DOMAIN
5961
from .entity import (
6062
EsphomeEntity,
6163
convert_api_error_ha_error,
@@ -161,11 +163,9 @@ def _on_static_info_update(self, static_info: EntityInfo) -> None:
161163
self._attr_max_temp = static_info.visual_max_temperature
162164
self._attr_min_humidity = round(static_info.visual_min_humidity)
163165
self._attr_max_humidity = round(static_info.visual_max_humidity)
164-
features = ClimateEntityFeature(0)
166+
features = ClimateEntityFeature.TARGET_TEMPERATURE
165167
if static_info.supports_two_point_target_temperature:
166168
features |= ClimateEntityFeature.TARGET_TEMPERATURE_RANGE
167-
else:
168-
features |= ClimateEntityFeature.TARGET_TEMPERATURE
169169
if static_info.supports_target_humidity:
170170
features |= ClimateEntityFeature.TARGET_HUMIDITY
171171
if self.preset_modes:
@@ -253,18 +253,31 @@ def current_humidity(self) -> int | None:
253253
@esphome_float_state_property
254254
def target_temperature(self) -> float | None:
255255
"""Return the temperature we try to reach."""
256-
return self._state.target_temperature
256+
if (
257+
not self._static_info.supports_two_point_target_temperature
258+
and self.hvac_mode != HVACMode.AUTO
259+
):
260+
return self._state.target_temperature
261+
if self.hvac_mode == HVACMode.HEAT:
262+
return self._state.target_temperature_low
263+
if self.hvac_mode == HVACMode.COOL:
264+
return self._state.target_temperature_high
265+
return None
257266

258267
@property
259268
@esphome_float_state_property
260269
def target_temperature_low(self) -> float | None:
261270
"""Return the lowbound target temperature we try to reach."""
271+
if self.hvac_mode == HVACMode.AUTO:
272+
return None
262273
return self._state.target_temperature_low
263274

264275
@property
265276
@esphome_float_state_property
266277
def target_temperature_high(self) -> float | None:
267278
"""Return the highbound target temperature we try to reach."""
279+
if self.hvac_mode == HVACMode.AUTO:
280+
return None
268281
return self._state.target_temperature_high
269282

270283
@property
@@ -282,7 +295,27 @@ async def async_set_temperature(self, **kwargs: Any) -> None:
282295
cast(HVACMode, kwargs[ATTR_HVAC_MODE])
283296
)
284297
if ATTR_TEMPERATURE in kwargs:
285-
data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
298+
if not self._static_info.supports_two_point_target_temperature:
299+
data["target_temperature"] = kwargs[ATTR_TEMPERATURE]
300+
else:
301+
hvac_mode = kwargs.get(ATTR_HVAC_MODE) or self.hvac_mode
302+
if hvac_mode == HVACMode.HEAT:
303+
data["target_temperature_low"] = kwargs[ATTR_TEMPERATURE]
304+
elif hvac_mode == HVACMode.COOL:
305+
data["target_temperature_high"] = kwargs[ATTR_TEMPERATURE]
306+
else:
307+
raise ServiceValidationError(
308+
translation_domain=DOMAIN,
309+
translation_key="action_call_failed",
310+
translation_placeholders={
311+
"call_name": "climate.set_temperature",
312+
"device_name": self._static_info.name,
313+
"error": (
314+
f"Setting target_temperature is only supported in "
315+
f"{HVACMode.HEAT} or {HVACMode.COOL} modes"
316+
),
317+
},
318+
)
286319
if ATTR_TARGET_TEMP_LOW in kwargs:
287320
data["target_temperature_low"] = kwargs[ATTR_TARGET_TEMP_LOW]
288321
if ATTR_TARGET_TEMP_HIGH in kwargs:

tests/components/esphome/test_climate.py

Lines changed: 174 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -75,11 +75,9 @@ async def test_climate_entity(
7575
swing_mode=ClimateSwingMode.BOTH,
7676
)
7777
]
78-
user_service = []
7978
await mock_generic_device_entry(
8079
mock_client=mock_client,
8180
entity_info=entity_info,
82-
user_service=user_service,
8381
states=states,
8482
)
8583
state = hass.states.get("climate.test_my_climate")
@@ -130,24 +128,32 @@ async def test_climate_entity_with_step_and_two_point(
130128
swing_mode=ClimateSwingMode.BOTH,
131129
)
132130
]
133-
user_service = []
134131
await mock_generic_device_entry(
135132
mock_client=mock_client,
136133
entity_info=entity_info,
137-
user_service=user_service,
138134
states=states,
139135
)
140136
state = hass.states.get("climate.test_my_climate")
141137
assert state is not None
142138
assert state.state == HVACMode.COOL
143139

144-
with pytest.raises(ServiceValidationError):
145-
await hass.services.async_call(
146-
CLIMATE_DOMAIN,
147-
SERVICE_SET_TEMPERATURE,
148-
{ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25},
149-
blocking=True,
150-
)
140+
await hass.services.async_call(
141+
CLIMATE_DOMAIN,
142+
SERVICE_SET_TEMPERATURE,
143+
{ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 25},
144+
blocking=True,
145+
)
146+
147+
mock_client.climate_command.assert_has_calls(
148+
[
149+
call(
150+
key=1,
151+
target_temperature_high=25.0,
152+
device_id=0,
153+
)
154+
]
155+
)
156+
mock_client.climate_command.reset_mock()
151157

152158
await hass.services.async_call(
153159
CLIMATE_DOMAIN,
@@ -210,11 +216,9 @@ async def test_climate_entity_with_step_and_target_temp(
210216
swing_mode=ClimateSwingMode.BOTH,
211217
)
212218
]
213-
user_service = []
214219
await mock_generic_device_entry(
215220
mock_client=mock_client,
216221
entity_info=entity_info,
217-
user_service=user_service,
218222
states=states,
219223
)
220224
state = hass.states.get("climate.test_my_climate")
@@ -366,11 +370,9 @@ async def test_climate_entity_with_humidity(
366370
target_humidity=25.7,
367371
)
368372
]
369-
user_service = []
370373
await mock_generic_device_entry(
371374
mock_client=mock_client,
372375
entity_info=entity_info,
373-
user_service=user_service,
374376
states=states,
375377
)
376378
state = hass.states.get("climate.test_my_climate")
@@ -394,6 +396,162 @@ async def test_climate_entity_with_humidity(
394396
mock_client.climate_command.reset_mock()
395397

396398

399+
async def test_climate_entity_with_heat(
400+
hass: HomeAssistant,
401+
mock_client: APIClient,
402+
mock_generic_device_entry: MockGenericDeviceEntryType,
403+
) -> None:
404+
"""Test a generic climate entity with heat."""
405+
entity_info = [
406+
ClimateInfo(
407+
object_id="myclimate",
408+
key=1,
409+
name="my climate",
410+
supports_current_temperature=True,
411+
supports_two_point_target_temperature=True,
412+
supports_action=True,
413+
visual_min_temperature=10.0,
414+
visual_max_temperature=30.0,
415+
supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.AUTO],
416+
)
417+
]
418+
states = [
419+
ClimateState(
420+
key=1,
421+
mode=ClimateMode.HEAT,
422+
action=ClimateAction.HEATING,
423+
current_temperature=18,
424+
target_temperature=22,
425+
)
426+
]
427+
await mock_generic_device_entry(
428+
mock_client=mock_client,
429+
entity_info=entity_info,
430+
states=states,
431+
)
432+
state = hass.states.get("climate.test_my_climate")
433+
assert state is not None
434+
assert state.state == HVACMode.HEAT
435+
436+
await hass.services.async_call(
437+
CLIMATE_DOMAIN,
438+
SERVICE_SET_TEMPERATURE,
439+
{ATTR_ENTITY_ID: "climate.test_my_climate", ATTR_TEMPERATURE: 23},
440+
blocking=True,
441+
)
442+
mock_client.climate_command.assert_has_calls(
443+
[call(key=1, target_temperature_low=23, device_id=0)]
444+
)
445+
mock_client.climate_command.reset_mock()
446+
447+
448+
async def test_climate_entity_with_heat_cool(
449+
hass: HomeAssistant,
450+
mock_client: APIClient,
451+
mock_generic_device_entry: MockGenericDeviceEntryType,
452+
) -> None:
453+
"""Test a generic climate entity with heat."""
454+
entity_info = [
455+
ClimateInfo(
456+
object_id="myclimate",
457+
key=1,
458+
name="my climate",
459+
supports_current_temperature=True,
460+
supports_two_point_target_temperature=True,
461+
supports_action=True,
462+
visual_min_temperature=10.0,
463+
visual_max_temperature=30.0,
464+
supported_modes=[ClimateMode.COOL, ClimateMode.HEAT, ClimateMode.HEAT_COOL],
465+
)
466+
]
467+
states = [
468+
ClimateState(
469+
key=1,
470+
mode=ClimateMode.HEAT_COOL,
471+
action=ClimateAction.HEATING,
472+
current_temperature=18,
473+
target_temperature=22,
474+
)
475+
]
476+
await mock_generic_device_entry(
477+
mock_client=mock_client,
478+
entity_info=entity_info,
479+
states=states,
480+
)
481+
state = hass.states.get("climate.test_my_climate")
482+
assert state is not None
483+
assert state.state == HVACMode.HEAT_COOL
484+
485+
await hass.services.async_call(
486+
CLIMATE_DOMAIN,
487+
SERVICE_SET_TEMPERATURE,
488+
{
489+
ATTR_ENTITY_ID: "climate.test_my_climate",
490+
ATTR_TARGET_TEMP_HIGH: 23,
491+
ATTR_TARGET_TEMP_LOW: 20,
492+
},
493+
blocking=True,
494+
)
495+
mock_client.climate_command.assert_has_calls(
496+
[
497+
call(
498+
key=1,
499+
target_temperature_high=23,
500+
target_temperature_low=20,
501+
device_id=0,
502+
)
503+
]
504+
)
505+
mock_client.climate_command.reset_mock()
506+
507+
508+
async def test_climate_set_temperature_unsupported_mode(
509+
hass: HomeAssistant,
510+
mock_client: APIClient,
511+
mock_generic_device_entry: MockGenericDeviceEntryType,
512+
) -> None:
513+
"""Test setting temperature in unsupported mode with two-point temperature support."""
514+
entity_info = [
515+
ClimateInfo(
516+
object_id="myclimate",
517+
key=1,
518+
name="my climate",
519+
supports_two_point_target_temperature=True,
520+
supported_modes=[ClimateMode.HEAT, ClimateMode.COOL, ClimateMode.AUTO],
521+
visual_min_temperature=10.0,
522+
visual_max_temperature=30.0,
523+
)
524+
]
525+
states = [
526+
ClimateState(
527+
key=1,
528+
mode=ClimateMode.AUTO,
529+
target_temperature=20,
530+
)
531+
]
532+
await mock_generic_device_entry(
533+
mock_client=mock_client,
534+
entity_info=entity_info,
535+
states=states,
536+
)
537+
538+
with pytest.raises(
539+
ServiceValidationError,
540+
match="Setting target_temperature is only supported in heat or cool modes",
541+
):
542+
await hass.services.async_call(
543+
CLIMATE_DOMAIN,
544+
SERVICE_SET_TEMPERATURE,
545+
{
546+
ATTR_ENTITY_ID: "climate.test_my_climate",
547+
ATTR_TEMPERATURE: 25,
548+
},
549+
blocking=True,
550+
)
551+
552+
mock_client.climate_command.assert_not_called()
553+
554+
397555
async def test_climate_entity_with_inf_value(
398556
hass: HomeAssistant,
399557
mock_client: APIClient,
@@ -429,11 +587,9 @@ async def test_climate_entity_with_inf_value(
429587
target_humidity=25.7,
430588
)
431589
]
432-
user_service = []
433590
await mock_generic_device_entry(
434591
mock_client=mock_client,
435592
entity_info=entity_info,
436-
user_service=user_service,
437593
states=states,
438594
)
439595
state = hass.states.get("climate.test_my_climate")
@@ -444,7 +600,7 @@ async def test_climate_entity_with_inf_value(
444600
assert attributes[ATTR_HUMIDITY] == 26
445601
assert attributes[ATTR_MAX_HUMIDITY] == 30
446602
assert attributes[ATTR_MIN_HUMIDITY] == 10
447-
assert ATTR_TEMPERATURE not in attributes
603+
assert attributes[ATTR_TEMPERATURE] is None
448604
assert attributes[ATTR_CURRENT_TEMPERATURE] is None
449605

450606

@@ -490,11 +646,9 @@ async def test_climate_entity_attributes(
490646
swing_mode=ClimateSwingMode.BOTH,
491647
)
492648
]
493-
user_service = []
494649
await mock_generic_device_entry(
495650
mock_client=mock_client,
496651
entity_info=entity_info,
497-
user_service=user_service,
498652
states=states,
499653
)
500654
state = hass.states.get("climate.test_my_climate")
@@ -523,11 +677,9 @@ async def test_climate_entity_attribute_current_temperature_unsupported(
523677
current_temperature=30,
524678
)
525679
]
526-
user_service = []
527680
await mock_generic_device_entry(
528681
mock_client=mock_client,
529682
entity_info=entity_info,
530-
user_service=user_service,
531683
states=states,
532684
)
533685
state = hass.states.get("climate.test_my_climate")

0 commit comments

Comments
 (0)