1
0
mirror of https://github.com/home-assistant/core.git synced 2025-12-25 05:26:47 +00:00

Ensure that Home Connect program update value event is a string when updating options (#156416)

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Diego Rodríguez Royo
2025-11-14 10:51:52 +01:00
committed by GitHub
parent 09a105d9ad
commit 34c1d45ee0
5 changed files with 433 additions and 13 deletions

View File

@@ -7,7 +7,7 @@ from collections import defaultdict
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any, cast
from typing import Any
from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import (
@@ -247,14 +247,15 @@ class HomeConnectCoordinator(
value=event.value,
)
else:
event_value = event.value
if event_key in (
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
):
) and isinstance(event_value, str):
await self.update_options(
event_message_ha_id,
event_key,
ProgramKey(cast(str, event.value)),
ProgramKey(event_value),
)
events[event_key] = event
self._call_event_listener(event_message)

View File

@@ -14,7 +14,6 @@ from aiohomeconnect.model.error import (
TooManyRequestsError,
)
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -62,10 +61,8 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self.update_native_value()
available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state()
state = STATE_UNAVAILABLE if not available else self.state
_LOGGER.debug("Updated %s, new state: %s", self.entity_id, state)
_LOGGER.debug("Updated %s", self)
@property
def bsh_key(self) -> str:
@@ -80,7 +77,7 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
as event updates should take precedence over the coordinator
refresh.
"""
return self._attr_available
return self.appliance.info.connected and self._attr_available
class HomeConnectOptionEntity(HomeConnectEntity):

View File

@@ -190,7 +190,7 @@ async def test_connected_devices(
)
@pytest.mark.parametrize("appliance", ["FridgeFreezer"], indirect=True)
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
async def test_number_entity_availability(
hass: HomeAssistant,
client: MagicMock,
@@ -200,8 +200,19 @@ async def test_number_entity_availability(
) -> None:
"""Test if number entities availability are based on the appliance connection state."""
entity_ids = [
f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature",
f"{NUMBER_DOMAIN.lower()}.oven_alarm_clock",
f"{NUMBER_DOMAIN.lower()}.oven_setpoint_temperature",
]
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Boolean"
)
],
)
)
client.get_setting.side_effect = None
# Setting constrains are not needed for this test
@@ -616,3 +627,133 @@ async def test_options_functionality(
"value": 80,
}
assert hass.states.is_state(entity_id, "80.0")
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
async def test_options_unavailable_when_option_is_missing(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that option entities become unavailable when the option is missing."""
entity_id = "number.oven_setpoint_temperature"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Double"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.COOKING_OVEN_HEATING_MODE_INTENSIVE_HEAT,
options=[],
)
)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
0,
level="info",
handling="auto",
value=ProgramKey.COOKING_OVEN_HEATING_MODE_INTENSIVE_HEAT,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("appliance", ["Oven"], indirect=True)
@pytest.mark.parametrize(
"event_key",
[
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
],
)
async def test_options_available_when_program_is_null(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
event_key: EventKey,
) -> None:
"""Test that option entities still available when the active program becomes null.
This can happen when the appliance starts or finish the program; the appliance first
updates the non-null program, and then the null program value.
This test ensures that the options defined by the non-null program are not removed
from the coordinator and therefore, the entities remain available.
"""
entity_id = "number.oven_setpoint_temperature"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE, "Double"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
event_key,
event_key.value,
0,
level="info",
handling="auto",
value=None,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE

View File

@@ -215,9 +215,17 @@ async def test_select_entity_availability(
appliance: HomeAppliance,
) -> None:
"""Test if select entities availability are based on the appliance connection state."""
entity_ids = [
"select.washer_active_program",
]
entity_ids = ["select.washer_active_program", "select.washer_temperature"]
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Boolean"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
@@ -967,3 +975,133 @@ async def test_options_functionality(
assert hass.states.is_state(
entity_id, "laundry_care_washer_enum_type_temperature_ul_warm"
)
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
async def test_options_unavailable_when_option_is_missing(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that option entities become unavailable when the option is missing."""
entity_id = "select.washer_temperature"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Boolean"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.LAUNDRY_CARE_WASHER_AUTO_30,
options=[],
)
)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
0,
level="info",
handling="auto",
value=ProgramKey.LAUNDRY_CARE_WASHER_AUTO_30,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("appliance", ["Washer"], indirect=True)
@pytest.mark.parametrize(
"event_key",
[
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
],
)
async def test_options_available_when_program_is_null(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
event_key: EventKey,
) -> None:
"""Test that option entities still available when the active program becomes null.
This can happen when the appliance starts or finish the program; the appliance first
updates the non-null program, and then the null program value.
This test ensures that the options defined by the non-null program are not removed
from the coordinator and therefore, the entities remain available.
"""
entity_id = "select.washer_temperature"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.LAUNDRY_CARE_WASHER_TEMPERATURE, "Enumeration"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
event_key,
event_key.value,
0,
level="info",
handling="auto",
value=None,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE

View File

@@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, MagicMock
from aiohomeconnect.model import (
ArrayOfEvents,
ArrayOfSettings,
Event,
EventMessage,
EventType,
GetSetting,
@@ -31,6 +32,7 @@ from homeassistant.components.home_connect.const import (
BSH_POWER_ON,
BSH_POWER_STANDBY,
DOMAIN,
EventKey,
)
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
from homeassistant.config_entries import ConfigEntryState
@@ -191,7 +193,18 @@ async def test_switch_entity_availability(
entity_ids = [
"switch.dishwasher_power",
"switch.dishwasher_child_lock",
"switch.dishwasher_half_load",
]
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, "Boolean"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
@@ -735,3 +748,133 @@ async def test_options_functionality(
"value": True,
}
assert hass.states.is_state(entity_id, STATE_ON)
@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True)
async def test_options_unavailable_when_option_is_missing(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
) -> None:
"""Test that option entities become unavailable when the option is missing."""
entity_id = "switch.dishwasher_half_load"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, "Boolean"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.DISHCARE_DISHWASHER_AUTO_1,
options=[],
)
)
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM.value,
0,
level="info",
handling="auto",
value=ProgramKey.DISHCARE_DISHWASHER_AUTO_1,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state == STATE_UNAVAILABLE
@pytest.mark.parametrize("appliance", ["Dishwasher"], indirect=True)
@pytest.mark.parametrize(
"event_key",
[
EventKey.BSH_COMMON_ROOT_ACTIVE_PROGRAM,
EventKey.BSH_COMMON_ROOT_SELECTED_PROGRAM,
],
)
async def test_options_available_when_program_is_null(
hass: HomeAssistant,
client: MagicMock,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
appliance: HomeAppliance,
event_key: EventKey,
) -> None:
"""Test that option entities still available when the active program becomes null.
This can happen when the appliance starts or finish the program; the appliance first
updates the non-null program, and then the null program value.
This test ensures that the options defined by the non-null program are not removed
from the coordinator and therefore, the entities remain available.
"""
entity_id = "switch.dishwasher_half_load"
client.get_available_program = AsyncMock(
return_value=ProgramDefinition(
ProgramKey.UNKNOWN,
options=[
ProgramDefinitionOption(
OptionKey.DISHCARE_DISHWASHER_HALF_LOAD, "Boolean"
)
],
)
)
assert await integration_setup(client)
assert config_entry.state is ConfigEntryState.LOADED
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE
await client.add_events(
[
EventMessage(
appliance.ha_id,
EventType.NOTIFY,
data=ArrayOfEvents(
[
Event(
event_key,
event_key.value,
0,
level="info",
handling="auto",
value=None,
)
]
),
)
]
)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state
assert state.state != STATE_UNAVAILABLE