Skip to content

Commit 34c1d45

Browse files
Ensure that Home Connect program update value event is a string when updating options (home-assistant#156416)
Co-authored-by: Martin Hjelmare <[email protected]>
1 parent 09a105d commit 34c1d45

File tree

5 files changed

+433
-13
lines changed

5 files changed

+433
-13
lines changed

homeassistant/components/home_connect/coordinator.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
from collections.abc import Callable
88
from dataclasses import dataclass
99
import logging
10-
from typing import Any, cast
10+
from typing import Any
1111

1212
from aiohomeconnect.client import Client as HomeConnectClient
1313
from aiohomeconnect.model import (
@@ -247,14 +247,15 @@ async def _event_listener(self) -> None: # noqa: C901
247247
value=event.value,
248248
)
249249
else:
250+
event_value = event.value
250251
if event_key in (
251252
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
252253
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
253-
):
254+
) and isinstance(event_value, str):
254255
await self.update_options(
255256
event_message_ha_id,
256257
event_key,
257-
ProgramKey(cast(str, event.value)),
258+
ProgramKey(event_value),
258259
)
259260
events[event_key] = event
260261
self._call_event_listener(event_message)

homeassistant/components/home_connect/entity.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
TooManyRequestsError,
1515
)
1616

17-
from homeassistant.const import STATE_UNAVAILABLE
1817
from homeassistant.core import callback
1918
from homeassistant.exceptions import HomeAssistantError
2019
from homeassistant.helpers.device_registry import DeviceInfo
@@ -62,10 +61,8 @@ def update_native_value(self) -> None:
6261
def _handle_coordinator_update(self) -> None:
6362
"""Handle updated data from the coordinator."""
6463
self.update_native_value()
65-
available = self._attr_available = self.appliance.info.connected
6664
self.async_write_ha_state()
67-
state = STATE_UNAVAILABLE if not available else self.state
68-
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
65+
_LOGGER.debug("Updated %s", self)
6966

7067
@property
7168
def bsh_key(self) -> str:
@@ -80,7 +77,7 @@ def available(self) -> bool:
8077
as event updates should take precedence over the coordinator
8178
refresh.
8279
"""
83-
return self._attr_available
80+
return self.appliance.info.connected and self._attr_available
8481

8582

8683
class HomeConnectOptionEntity(HomeConnectEntity):

tests/components/home_connect/test_number.py

Lines changed: 143 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,7 @@ def get_settings_side_effect(ha_id: str):
190190
)
191191

192192

193-
@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True)
193+
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
194194
async def test_number_entity_availability(
195195
hass: HomeAssistant,
196196
client: MagicMock,
@@ -200,8 +200,19 @@ async def test_number_entity_availability(
200200
) -> None:
201201
"""Test if number entities availability are based on the appliance connection state."""
202202
entity_ids = [
203-
f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
203+
f"{NUMBER_DOMAIN.lower()}.oven_alarm_clock",
204+
f"{NUMBER_DOMAIN.lower()}.oven_setpoint_temperature",
204205
]
206+
client.get_available_program = AsyncMock(
207+
return_value=ProgramDefinition(
208+
ProgramKey.UNKNOWN,
209+
options=[
210+
ProgramDefinitionOption(
211+
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Boolean"
212+
)
213+
],
214+
)
215+
)
205216

206217
client.get_setting.side_effect = None
207218
# Setting constrains are not needed for this test
@@ -616,3 +627,133 @@ async def set_program_option_side_effect(ha_id: str, *_, **kwargs) -> None:
616627
"value": 80,
617628
}
618629
assert hass.states.is_state(entity_id, "80.0")
630+
631+
632+
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
633+
async def test_options_unavailable_when_option_is_missing(
634+
hass: HomeAssistant,
635+
client: MagicMock,
636+
config_entry: MockConfigEntry,
637+
integration_setup: Callable[[MagicMock], Awaitable[bool]],
638+
appliance: HomeAppliance,
639+
) -> None:
640+
"""Test that option entities become unavailable when the option is missing."""
641+
entity_id = "number.oven_setpoint_temperature"
642+
client.get_available_program = AsyncMock(
643+
return_value=ProgramDefinition(
644+
ProgramKey.UNKNOWN,
645+
options=[
646+
ProgramDefinitionOption(
647+
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Double"
648+
)
649+
],
650+
)
651+
)
652+
653+
assert await integration_setup(client)
654+
assert config_entry.state is ConfigEntryState.LOADED
655+
656+
state = hass.states.get(entity_id)
657+
assert state
658+
assert state.state != STATE_UNAVAILABLE
659+
660+
client.get_available_program = AsyncMock(
661+
return_value=ProgramDefinition(
662+
ProgramKey.COOKING_OVEN_HEATING_MODE_INTENSIVE_HEAT,
663+
options=[],
664+
)
665+
)
666+
await client.add_events(
667+
[
668+
EventMessage(
669+
appliance.ha_id,
670+
EventType.NOTIFY,
671+
data=ArrayOfEvents(
672+
[
673+
Event(
674+
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
675+
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
676+
0,
677+
level="info",
678+
handling="auto",
679+
value=ProgramKey.COOKING_OVEN_HEATING_MODE_INTENSIVE_HEAT,
680+
)
681+
]
682+
),
683+
)
684+
]
685+
)
686+
await hass.async_block_till_done()
687+
688+
state = hass.states.get(entity_id)
689+
assert state
690+
assert state.state == STATE_UNAVAILABLE
691+
692+
693+
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
694+
@pytest.mark.parametrize(
695+
"event_key",
696+
[
697+
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
698+
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
699+
],
700+
)
701+
async def test_options_available_when_program_is_null(
702+
hass: HomeAssistant,
703+
client: MagicMock,
704+
config_entry: MockConfigEntry,
705+
integration_setup: Callable[[MagicMock], Awaitable[bool]],
706+
appliance: HomeAppliance,
707+
event_key: EventKey,
708+
) -> None:
709+
"""Test that option entities still available when the active program becomes null.
710+
711+
This can happen when the appliance starts or finish the program; the appliance first
712+
updates the non-null program, and then the null program value.
713+
This test ensures that the options defined by the non-null program are not removed
714+
from the coordinator and therefore, the entities remain available.
715+
"""
716+
entity_id = "number.oven_setpoint_temperature"
717+
client.get_available_program = AsyncMock(
718+
return_value=ProgramDefinition(
719+
ProgramKey.UNKNOWN,
720+
options=[
721+
ProgramDefinitionOption(
722+
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Double"
723+
)
724+
],
725+
)
726+
)
727+
728+
assert await integration_setup(client)
729+
assert config_entry.state is ConfigEntryState.LOADED
730+
731+
state = hass.states.get(entity_id)
732+
assert state
733+
assert state.state != STATE_UNAVAILABLE
734+
735+
await client.add_events(
736+
[
737+
EventMessage(
738+
appliance.ha_id,
739+
EventType.NOTIFY,
740+
data=ArrayOfEvents(
741+
[
742+
Event(
743+
event_key,
744+
event_key.value,
745+
0,
746+
level="info",
747+
handling="auto",
748+
value=None,
749+
)
750+
]
751+
),
752+
)
753+
]
754+
)
755+
await hass.async_block_till_done()
756+
757+
state = hass.states.get(entity_id)
758+
assert state
759+
assert state.state != STATE_UNAVAILABLE

tests/components/home_connect/test_select.py

Lines changed: 141 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -215,9 +215,17 @@ async def test_select_entity_availability(
215215
appliance: HomeAppliance,
216216
) -> None:
217217
"""Test if select entities availability are based on the appliance connection state."""
218-
entity_ids = [
219-
"select.washer_active_program",
220-
]
218+
entity_ids = ["select.washer_active_program", "select.washer_temperature"]
219+
client.get_available_program = AsyncMock(
220+
return_value=ProgramDefinition(
221+
ProgramKey.UNKNOWN,
222+
options=[
223+
ProgramDefinitionOption(
224+
OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Boolean"
225+
)
226+
],
227+
)
228+
)
221229
assert await integration_setup(client)
222230
assert config_entry.state is ConfigEntryState.LOADED
223231

@@ -967,3 +975,133 @@ async def test_options_functionality(
967975
assert hass.states.is_state(
968976
entity_id, "laundry_care_washer_enum_type_temperature_ul_warm"
969977
)
978+
979+
980+
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
981+
async def test_options_unavailable_when_option_is_missing(
982+
hass: HomeAssistant,
983+
client: MagicMock,
984+
config_entry: MockConfigEntry,
985+
integration_setup: Callable[[MagicMock], Awaitable[bool]],
986+
appliance: HomeAppliance,
987+
) -> None:
988+
"""Test that option entities become unavailable when the option is missing."""
989+
entity_id = "select.washer_temperature"
990+
client.get_available_program = AsyncMock(
991+
return_value=ProgramDefinition(
992+
ProgramKey.UNKNOWN,
993+
options=[
994+
ProgramDefinitionOption(
995+
OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Boolean"
996+
)
997+
],
998+
)
999+
)
1000+
1001+
assert await integration_setup(client)
1002+
assert config_entry.state is ConfigEntryState.LOADED
1003+
1004+
state = hass.states.get(entity_id)
1005+
assert state
1006+
assert state.state != STATE_UNAVAILABLE
1007+
1008+
client.get_available_program = AsyncMock(
1009+
return_value=ProgramDefinition(
1010+
ProgramKey.LAUNDRY_CARE_WASHER_AUTO_30,
1011+
options=[],
1012+
)
1013+
)
1014+
await client.add_events(
1015+
[
1016+
EventMessage(
1017+
appliance.ha_id,
1018+
EventType.NOTIFY,
1019+
data=ArrayOfEvents(
1020+
[
1021+
Event(
1022+
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
1023+
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
1024+
0,
1025+
level="info",
1026+
handling="auto",
1027+
value=ProgramKey.LAUNDRY_CARE_WASHER_AUTO_30,
1028+
)
1029+
]
1030+
),
1031+
)
1032+
]
1033+
)
1034+
await hass.async_block_till_done()
1035+
1036+
state = hass.states.get(entity_id)
1037+
assert state
1038+
assert state.state == STATE_UNAVAILABLE
1039+
1040+
1041+
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
1042+
@pytest.mark.parametrize(
1043+
"event_key",
1044+
[
1045+
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
1046+
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
1047+
],
1048+
)
1049+
async def test_options_available_when_program_is_null(
1050+
hass: HomeAssistant,
1051+
client: MagicMock,
1052+
config_entry: MockConfigEntry,
1053+
integration_setup: Callable[[MagicMock], Awaitable[bool]],
1054+
appliance: HomeAppliance,
1055+
event_key: EventKey,
1056+
) -> None:
1057+
"""Test that option entities still available when the active program becomes null.
1058+
1059+
This can happen when the appliance starts or finish the program; the appliance first
1060+
updates the non-null program, and then the null program value.
1061+
This test ensures that the options defined by the non-null program are not removed
1062+
from the coordinator and therefore, the entities remain available.
1063+
"""
1064+
entity_id = "select.washer_temperature"
1065+
client.get_available_program = AsyncMock(
1066+
return_value=ProgramDefinition(
1067+
ProgramKey.UNKNOWN,
1068+
options=[
1069+
ProgramDefinitionOption(
1070+
OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Enumeration"
1071+
)
1072+
],
1073+
)
1074+
)
1075+
1076+
assert await integration_setup(client)
1077+
assert config_entry.state is ConfigEntryState.LOADED
1078+
1079+
state = hass.states.get(entity_id)
1080+
assert state
1081+
assert state.state != STATE_UNAVAILABLE
1082+
1083+
await client.add_events(
1084+
[
1085+
EventMessage(
1086+
appliance.ha_id,
1087+
EventType.NOTIFY,
1088+
data=ArrayOfEvents(
1089+
[
1090+
Event(
1091+
event_key,
1092+
event_key.value,
1093+
0,
1094+
level="info",
1095+
handling="auto",
1096+
value=None,
1097+
)
1098+
]
1099+
),
1100+
)
1101+
]
1102+
)
1103+
await hass.async_block_till_done()
1104+
1105+
state = hass.states.get(entity_id)
1106+
assert state
1107+
assert state.state != STATE_UNAVAILABLE

0 commit comments

Comments
 (0)