From 63ab13681af808584a11b60ccfdd04ab22191977 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=2E=20Diego=20Rodr=C3=ADguez=20Royo?= Date: Sat, 1 Feb 2025 12:46:49 +0100 Subject: [PATCH] Home Connect entities availability based on the connected state of the appliance (#136951) * Base the entity availability on the connected state of the appliance * cache `ha_id` Co-authored-by: Martin Hjelmare * Inlcude coordinator `available` property at entity Co-authored-by: Martin Hjelmare --------- Co-authored-by: Martin Hjelmare --- .../components/home_connect/coordinator.py | 30 ++++++-- .../components/home_connect/entity.py | 7 ++ .../home_connect/test_binary_sensor.py | 61 +++++++++++++++- tests/components/home_connect/test_light.py | 54 +++++++++++++++ tests/components/home_connect/test_number.py | 69 ++++++++++++++++++- tests/components/home_connect/test_select.py | 53 ++++++++++++++ tests/components/home_connect/test_sensor.py | 56 ++++++++++++++- tests/components/home_connect/test_switch.py | 56 +++++++++++++++ tests/components/home_connect/test_time.py | 58 +++++++++++++++- 9 files changed, 433 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 29bd961220e..9e49b6e678e 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -121,9 +121,10 @@ class HomeConnectCoordinator( while True: try: async for event_message in self.client.stream_all_events(): + event_message_ha_id = event_message.ha_id match event_message.type: case EventType.STATUS: - statuses = self.data[event_message.ha_id].status + statuses = self.data[event_message_ha_id].status for event in event_message.data.items: status_key = StatusKey(event.key) if status_key in statuses: @@ -134,10 +135,11 @@ class HomeConnectCoordinator( raw_key=status_key.value, value=event.value, ) + self._call_event_listener(event_message) case EventType.NOTIFY: - settings = self.data[event_message.ha_id].settings - events = self.data[event_message.ha_id].events + settings = self.data[event_message_ha_id].settings + events = self.data[event_message_ha_id].events for event in event_message.data.items: if event.key in SettingKey: setting_key = SettingKey(event.key) @@ -151,13 +153,25 @@ class HomeConnectCoordinator( ) else: events[event.key] = event + self._call_event_listener(event_message) case EventType.EVENT: - events = self.data[event_message.ha_id].events + events = self.data[event_message_ha_id].events for event in event_message.data.items: events[event.key] = event + self._call_event_listener(event_message) - self._call_event_listener(event_message) + case EventType.CONNECTED: + self.data[event_message_ha_id].info.connected = True + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) + + case EventType.DISCONNECTED: + self.data[event_message_ha_id].info.connected = False + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) except (EventStreamInterruptedError, HomeConnectRequestError) as error: _LOGGER.debug( @@ -186,6 +200,12 @@ class HomeConnectCoordinator( ): listener() + @callback + def _call_all_event_listeners_for_appliance(self, ha_id: str): + for listener, context in self._listeners.values(): + if isinstance(context, tuple) and context[0] == ha_id: + listener() + async def _async_update_data(self) -> dict[str, HomeConnectApplianceData]: """Fetch data from Home Connect.""" try: diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index ba8500fe8b6..c665ca7f947 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -56,3 +56,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): def bsh_key(self) -> str: """Return the BSH key.""" return self.entity_description.key + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return ( + self.appliance.info.connected and self._attr_available and super().available + ) diff --git a/tests/components/home_connect/test_binary_sensor.py b/tests/components/home_connect/test_binary_sensor.py index 182051ad64a..25e0e8084e2 100644 --- a/tests/components/home_connect/test_binary_sensor.py +++ b/tests/components/home_connect/test_binary_sensor.py @@ -18,7 +18,13 @@ from homeassistant.components.home_connect.const import ( ) from homeassistant.components.script import scripts_with_entity from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNKNOWN, Platform +from homeassistant.const import ( + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) from homeassistant.core import HomeAssistant import homeassistant.helpers.issue_registry as ir from homeassistant.setup import async_setup_component @@ -44,6 +50,59 @@ async def test_binary_sensors( assert config_entry.state == ConfigEntryState.LOADED +async def test_binary_sensors_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if binary sensor entities availability are based on the appliance connection state.""" + entity_ids = [ + "binary_sensor.washer_door", + "binary_sensor.washer_remote_control", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + @pytest.mark.parametrize( ("value", "expected"), [ diff --git a/tests/components/home_connect/test_light.py b/tests/components/home_connect/test_light.py index 4f8cb60d881..f0998523c8c 100644 --- a/tests/components/home_connect/test_light.py +++ b/tests/components/home_connect/test_light.py @@ -27,6 +27,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -55,6 +56,59 @@ async def test_light( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["Hood"], indirect=True) +async def test_light_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if light entities availability are based on the appliance connection state.""" + entity_ids = [ + "light.hood_functional_light", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + @pytest.mark.parametrize( ( "entity_id", diff --git a/tests/components/home_connect/test_number.py b/tests/components/home_connect/test_number.py index 371aed928dd..7df21e45da9 100644 --- a/tests/components/home_connect/test_number.py +++ b/tests/components/home_connect/test_number.py @@ -4,7 +4,14 @@ from collections.abc import Awaitable, Callable import random from unittest.mock import AsyncMock, MagicMock -from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model import ( + ArrayOfEvents, + ArrayOfSettings, + EventMessage, + EventType, + GetSetting, + SettingKey, +) from aiohomeconnect.model.error import HomeConnectError from aiohomeconnect.model.setting import SettingConstraints import pytest @@ -17,7 +24,7 @@ from homeassistant.components.number import ( SERVICE_SET_VALUE, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -42,6 +49,64 @@ async def test_number( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) +async def test_number_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if number entities availability are based on the appliance connection state.""" + entity_ids = [ + f"{NUMBER_DOMAIN.lower()}.fridgefreezer_refrigerator_temperature", + ] + + client.get_setting.side_effect = None + # Setting constrains are not needed for this test + # so we rise an error to easily test the availability + client.get_setting = AsyncMock(side_effect=HomeConnectError()) + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + @pytest.mark.parametrize("appliance_ha_id", ["FridgeFreezer"], indirect=True) @pytest.mark.parametrize( ( diff --git a/tests/components/home_connect/test_select.py b/tests/components/home_connect/test_select.py index fb75e4fbc22..6dad99c90cb 100644 --- a/tests/components/home_connect/test_select.py +++ b/tests/components/home_connect/test_select.py @@ -29,6 +29,7 @@ from homeassistant.config_entries import ConfigEntryState from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_SELECT_OPTION, + STATE_UNAVAILABLE, STATE_UNKNOWN, Platform, ) @@ -57,6 +58,58 @@ async def test_select( assert config_entry.state is ConfigEntryState.LOADED +async def test_select_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if select entities availability are based on the appliance connection state.""" + entity_ids = [ + "select.washer_active_program", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + async def test_filter_programs( config_entry: MockConfigEntry, integration_setup: Callable[[MagicMock], Awaitable[bool]], diff --git a/tests/components/home_connect/test_sensor.py b/tests/components/home_connect/test_sensor.py index ce06a841bbb..398210d586a 100644 --- a/tests/components/home_connect/test_sensor.py +++ b/tests/components/home_connect/test_sensor.py @@ -24,7 +24,7 @@ from homeassistant.components.home_connect.const import ( BSH_EVENT_PRESENT_STATE_PRESENT, ) from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry @@ -94,6 +94,60 @@ async def test_sensors( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", [TEST_HC_APP], indirect=True) +async def test_sensor_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if sensor entities availability are based on the appliance connection state.""" + entity_ids = [ + "sensor.dishwasher_operation_state", + "sensor.dishwasher_salt_nearly_empty", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + # Appliance_ha_id program sequence with a delayed start. PROGRAM_SEQUENCE_EVENTS = ( EVENT_PROG_DELAYED_START, diff --git a/tests/components/home_connect/test_switch.py b/tests/components/home_connect/test_switch.py index 4d6b59eddd9..eb58f832c9d 100644 --- a/tests/components/home_connect/test_switch.py +++ b/tests/components/home_connect/test_switch.py @@ -36,6 +36,7 @@ from homeassistant.const import ( SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNAVAILABLE, Platform, ) from homeassistant.core import HomeAssistant @@ -65,6 +66,61 @@ async def test_switches( assert config_entry.state == ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["Dishwasher"], indirect=True) +async def test_switch_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if switch entities availability are based on the appliance connection state.""" + entity_ids = [ + "switch.dishwasher_power", + "switch.dishwasher_child_lock", + "switch.dishwasher_program_eco50", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + @pytest.mark.parametrize( ( "entity_id", diff --git a/tests/components/home_connect/test_time.py b/tests/components/home_connect/test_time.py index 95f9ddeba80..58ffd17c41a 100644 --- a/tests/components/home_connect/test_time.py +++ b/tests/components/home_connect/test_time.py @@ -4,13 +4,14 @@ from collections.abc import Awaitable, Callable from datetime import time from unittest.mock import MagicMock -from aiohomeconnect.model import ArrayOfSettings, GetSetting, SettingKey +from aiohomeconnect.model import ArrayOfSettings, EventMessage, GetSetting, SettingKey from aiohomeconnect.model.error import HomeConnectError +from aiohomeconnect.model.event import ArrayOfEvents, EventType import pytest from homeassistant.components.time import DOMAIN as TIME_DOMAIN, SERVICE_SET_VALUE from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, Platform +from homeassistant.const import ATTR_ENTITY_ID, ATTR_TIME, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError @@ -35,6 +36,59 @@ async def test_time( assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) +async def test_time_entity_availabilty( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + setup_credentials: None, + client: MagicMock, + appliance_ha_id: str, +) -> None: + """Test if time entities availability are based on the appliance connection state.""" + entity_ids = [ + "time.oven_alarm_clock", + ] + assert config_entry.state == ConfigEntryState.NOT_LOADED + assert await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.DISCONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + assert hass.states.is_state(entity_id, STATE_UNAVAILABLE) + + await client.add_events( + [ + EventMessage( + appliance_ha_id, + EventType.CONNECTED, + ArrayOfEvents([]), + ) + ] + ) + await hass.async_block_till_done() + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state + assert state.state != STATE_UNAVAILABLE + + @pytest.mark.parametrize("appliance_ha_id", ["Oven"], indirect=True) @pytest.mark.parametrize( ("entity_id", "setting_key"),