From e8099fd3b2f19106fe8fd53da3c44a7062fdc14e Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Tue, 4 Mar 2025 19:26:20 +0100 Subject: [PATCH] Fix home connect available (#139760) * Fix home connect available * Extend and clarify test * Do not change connected state on stream interrupted --- .../components/home_connect/coordinator.py | 13 +- .../components/home_connect/entity.py | 16 ++- tests/components/home_connect/__init__.py | 18 +++ tests/components/home_connect/conftest.py | 21 +-- .../home_connect/test_coordinator.py | 132 +++++++++++++++++- 5 files changed, 177 insertions(+), 23 deletions(-) diff --git a/homeassistant/components/home_connect/coordinator.py b/homeassistant/components/home_connect/coordinator.py index 4d275854e30..7898fb7be12 100644 --- a/homeassistant/components/home_connect/coordinator.py +++ b/homeassistant/components/home_connect/coordinator.py @@ -98,6 +98,7 @@ class HomeConnectCoordinator( CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] ] = {} self.device_registry = dr.async_get(self.hass) + self.data = {} @cached_property def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: @@ -161,6 +162,14 @@ class HomeConnectCoordinator( async for event_message in self.client.stream_all_events(): retry_time = 10 event_message_ha_id = event_message.ha_id + if ( + event_message_ha_id in self.data + and not self.data[event_message_ha_id].info.connected + ): + self.data[event_message_ha_id].info.connected = True + self._call_all_event_listeners_for_appliance( + event_message_ha_id + ) match event_message.type: case EventType.STATUS: statuses = self.data[event_message_ha_id].status @@ -295,6 +304,8 @@ class HomeConnectCoordinator( translation_placeholders=get_dict_from_home_connect_error(error), ) from error except HomeConnectError as error: + for appliance_data in self.data.values(): + appliance_data.info.connected = False raise UpdateFailed( translation_domain=DOMAIN, translation_key="fetch_api_error", @@ -303,7 +314,7 @@ class HomeConnectCoordinator( return { appliance.ha_id: await self._get_appliance_data( - appliance, self.data.get(appliance.ha_id) if self.data else None + appliance, self.data.get(appliance.ha_id) ) for appliance in appliances.homeappliances } diff --git a/homeassistant/components/home_connect/entity.py b/homeassistant/components/home_connect/entity.py index 52eaaecace7..b55ff374f34 100644 --- a/homeassistant/components/home_connect/entity.py +++ b/homeassistant/components/home_connect/entity.py @@ -8,6 +8,7 @@ from typing import cast from aiohomeconnect.model import EventKey, OptionKey from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError +from homeassistant.const import STATE_UNAVAILABLE from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo @@ -51,8 +52,10 @@ 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() - _LOGGER.debug("Updated %s, new state: %s", self.entity_id, self.state) + state = STATE_UNAVAILABLE if not available else self.state + _LOGGER.debug("Updated %s, new state: %s", self.entity_id, state) @property def bsh_key(self) -> str: @@ -61,10 +64,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]): @property def available(self) -> bool: - """Return True if entity is available.""" - return ( - self.appliance.info.connected and self._attr_available and super().available - ) + """Return True if entity is available. + + Do not use self.last_update_success for available state + as event updates should take precedence over the coordinator + refresh. + """ + return self._attr_available class HomeConnectOptionEntity(HomeConnectEntity): diff --git a/tests/components/home_connect/__init__.py b/tests/components/home_connect/__init__.py index 2b61501c59a..47a438fd218 100644 --- a/tests/components/home_connect/__init__.py +++ b/tests/components/home_connect/__init__.py @@ -1 +1,19 @@ """Tests for the Home Connect integration.""" + +from typing import Any + +from aiohomeconnect.model import ArrayOfHomeAppliances, ArrayOfStatus + +from tests.common import load_json_object_fixture + +MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( + load_json_object_fixture("home_connect/appliances.json")["data"] # type: ignore[arg-type] +) +MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") +MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") +MOCK_STATUS = ArrayOfStatus.from_dict( + load_json_object_fixture("home_connect/status.json")["data"] # type: ignore[arg-type] +) +MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( + "home_connect/available_commands.json" +) diff --git a/tests/components/home_connect/conftest.py b/tests/components/home_connect/conftest.py index 49cbc89ba41..396fe8c5665 100644 --- a/tests/components/home_connect/conftest.py +++ b/tests/components/home_connect/conftest.py @@ -11,11 +11,9 @@ from aiohomeconnect.client import Client as HomeConnectClient from aiohomeconnect.model import ( ArrayOfCommands, ArrayOfEvents, - ArrayOfHomeAppliances, ArrayOfOptions, ArrayOfPrograms, ArrayOfSettings, - ArrayOfStatus, Event, EventKey, EventMessage, @@ -41,20 +39,15 @@ from homeassistant.const import Platform from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, load_json_object_fixture - -MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( - load_json_object_fixture("home_connect/appliances.json")["data"] -) -MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") -MOCK_SETTINGS: dict[str, Any] = load_json_object_fixture("home_connect/settings.json") -MOCK_STATUS = ArrayOfStatus.from_dict( - load_json_object_fixture("home_connect/status.json")["data"] -) -MOCK_AVAILABLE_COMMANDS: dict[str, Any] = load_json_object_fixture( - "home_connect/available_commands.json" +from . import ( + MOCK_APPLIANCES, + MOCK_AVAILABLE_COMMANDS, + MOCK_PROGRAMS, + MOCK_SETTINGS, + MOCK_STATUS, ) +from tests.common import MockConfigEntry CLIENT_ID = "1234" CLIENT_SECRET = "5678" diff --git a/tests/components/home_connect/test_coordinator.py b/tests/components/home_connect/test_coordinator.py index ac27b848a36..1a49d2bb2a0 100644 --- a/tests/components/home_connect/test_coordinator.py +++ b/tests/components/home_connect/test_coordinator.py @@ -1,6 +1,7 @@ """Test for Home Connect coordinator.""" from collections.abc import Awaitable, Callable +import copy from datetime import timedelta from typing import Any from unittest.mock import AsyncMock, MagicMock, patch @@ -20,6 +21,7 @@ from aiohomeconnect.model.error import ( HomeConnectError, HomeConnectRequestError, ) +from freezegun.api import FrozenDateTimeFactory import pytest from homeassistant.components.home_connect.const import ( @@ -36,8 +38,11 @@ from homeassistant.core import ( callback, ) from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util +from . import MOCK_APPLIANCES + from tests.common import MockConfigEntry, async_fire_time_changed @@ -74,6 +79,123 @@ async def test_coordinator_update_failing_get_appliances( assert config_entry.state == ConfigEntryState.SETUP_RETRY +@pytest.mark.usefixtures("setup_credentials") +@pytest.mark.parametrize("platforms", [("binary_sensor",)]) +@pytest.mark.parametrize("appliance_ha_id", ["Washer"], indirect=True) +async def test_coordinator_failure_refresh_and_stream( + hass: HomeAssistant, + config_entry: MockConfigEntry, + integration_setup: Callable[[MagicMock], Awaitable[bool]], + client: MagicMock, + freezer: FrozenDateTimeFactory, + appliance_ha_id: str, +) -> None: + """Test entity available state via coordinator refresh and event stream.""" + entity_id_1 = "binary_sensor.washer_remote_control" + entity_id_2 = "binary_sensor.washer_remote_start" + await async_setup_component(hass, "homeassistant", {}) + await integration_setup(client) + assert config_entry.state == ConfigEntryState.LOADED + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + client.get_home_appliances.side_effect = HomeConnectError() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Test that the entity becomes available again after a successful update. + + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # Move time forward to pass the debounce time. + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh. + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + # Test that the event stream makes the entity go available too. + + # First make the entity unavailable. + client.get_home_appliances.side_effect = HomeConnectError() + + # Move time forward to pass the debounce time + freezer.tick(timedelta(hours=1)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Force a coordinator refresh + await hass.services.async_call( + "homeassistant", "update_entity", {"entity_id": entity_id_1}, blocking=True + ) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state == "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state == "unavailable" + + # Now make the entity available again. + client.get_home_appliances.side_effect = None + client.get_home_appliances.return_value = copy.deepcopy(MOCK_APPLIANCES) + + # One event should make all entities for this appliance available again. + event_message = EventMessage( + appliance_ha_id, + EventType.STATUS, + ArrayOfEvents( + [ + Event( + key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE, + raw_key=EventKey.BSH_COMMON_STATUS_REMOTE_CONTROL_ACTIVE.value, + timestamp=0, + level="", + handling="", + value=False, + ) + ], + ), + ) + await client.add_events([event_message]) + await hass.async_block_till_done() + + state = hass.states.get(entity_id_1) + assert state + assert state.state != "unavailable" + state = hass.states.get(entity_id_2) + assert state + assert state.state != "unavailable" + + @pytest.mark.parametrize( "mock_method", [ @@ -330,11 +452,13 @@ async def test_event_listener_resilience( assert config_entry.state == ConfigEntryState.LOADED assert len(config_entry._background_tasks) == 1 - assert hass.states.is_state(entity_id, initial_state) + state = hass.states.get(entity_id) + assert state + assert state.state == initial_state - await hass.async_block_till_done() future.set_exception(exception) await hass.async_block_till_done() + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) await hass.async_block_till_done() @@ -362,4 +486,6 @@ async def test_event_listener_resilience( ) await hass.async_block_till_done() - assert hass.states.is_state(entity_id, after_event_expected_state) + state = hass.states.get(entity_id) + assert state + assert state.state == after_event_expected_state