Fix home connect available (#139760)

* Fix home connect available

* Extend and clarify test

* Do not change connected state on stream interrupted
This commit is contained in:
Martin Hjelmare 2025-03-04 19:26:20 +01:00 committed by GitHub
parent 344cfedd6f
commit e8099fd3b2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 177 additions and 23 deletions

View File

@ -98,6 +98,7 @@ class HomeConnectCoordinator(
CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]] CALLBACK_TYPE, tuple[CALLBACK_TYPE, tuple[EventKey, ...]]
] = {} ] = {}
self.device_registry = dr.async_get(self.hass) self.device_registry = dr.async_get(self.hass)
self.data = {}
@cached_property @cached_property
def context_listeners(self) -> dict[tuple[str, EventKey], list[CALLBACK_TYPE]]: 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(): async for event_message in self.client.stream_all_events():
retry_time = 10 retry_time = 10
event_message_ha_id = event_message.ha_id 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: match event_message.type:
case EventType.STATUS: case EventType.STATUS:
statuses = self.data[event_message_ha_id].status statuses = self.data[event_message_ha_id].status
@ -295,6 +304,8 @@ class HomeConnectCoordinator(
translation_placeholders=get_dict_from_home_connect_error(error), translation_placeholders=get_dict_from_home_connect_error(error),
) from error ) from error
except HomeConnectError as error: except HomeConnectError as error:
for appliance_data in self.data.values():
appliance_data.info.connected = False
raise UpdateFailed( raise UpdateFailed(
translation_domain=DOMAIN, translation_domain=DOMAIN,
translation_key="fetch_api_error", translation_key="fetch_api_error",
@ -303,7 +314,7 @@ class HomeConnectCoordinator(
return { return {
appliance.ha_id: await self._get_appliance_data( 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 for appliance in appliances.homeappliances
} }

View File

@ -8,6 +8,7 @@ from typing import cast
from aiohomeconnect.model import EventKey, OptionKey from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.device_registry import DeviceInfo
@ -51,8 +52,10 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator.""" """Handle updated data from the coordinator."""
self.update_native_value() self.update_native_value()
available = self._attr_available = self.appliance.info.connected
self.async_write_ha_state() 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 @property
def bsh_key(self) -> str: def bsh_key(self) -> str:
@ -61,10 +64,13 @@ class HomeConnectEntity(CoordinatorEntity[HomeConnectCoordinator]):
@property @property
def available(self) -> bool: def available(self) -> bool:
"""Return True if entity is available.""" """Return True if entity is available.
return (
self.appliance.info.connected and self._attr_available and super().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): class HomeConnectOptionEntity(HomeConnectEntity):

View File

@ -1 +1,19 @@
"""Tests for the Home Connect integration.""" """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"
)

View File

@ -11,11 +11,9 @@ from aiohomeconnect.client import Client as HomeConnectClient
from aiohomeconnect.model import ( from aiohomeconnect.model import (
ArrayOfCommands, ArrayOfCommands,
ArrayOfEvents, ArrayOfEvents,
ArrayOfHomeAppliances,
ArrayOfOptions, ArrayOfOptions,
ArrayOfPrograms, ArrayOfPrograms,
ArrayOfSettings, ArrayOfSettings,
ArrayOfStatus,
Event, Event,
EventKey, EventKey,
EventMessage, EventMessage,
@ -41,20 +39,15 @@ from homeassistant.const import Platform
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_json_object_fixture from . import (
MOCK_APPLIANCES,
MOCK_APPLIANCES = ArrayOfHomeAppliances.from_dict( MOCK_AVAILABLE_COMMANDS,
load_json_object_fixture("home_connect/appliances.json")["data"] MOCK_PROGRAMS,
) MOCK_SETTINGS,
MOCK_PROGRAMS: dict[str, Any] = load_json_object_fixture("home_connect/programs.json") MOCK_STATUS,
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 tests.common import MockConfigEntry
CLIENT_ID = "1234" CLIENT_ID = "1234"
CLIENT_SECRET = "5678" CLIENT_SECRET = "5678"

View File

@ -1,6 +1,7 @@
"""Test for Home Connect coordinator.""" """Test for Home Connect coordinator."""
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
import copy
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
@ -20,6 +21,7 @@ from aiohomeconnect.model.error import (
HomeConnectError, HomeConnectError,
HomeConnectRequestError, HomeConnectRequestError,
) )
from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from homeassistant.components.home_connect.const import ( from homeassistant.components.home_connect.const import (
@ -36,8 +38,11 @@ from homeassistant.core import (
callback, callback,
) )
from homeassistant.helpers import entity_registry as er from homeassistant.helpers import entity_registry as er
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
from . import MOCK_APPLIANCES
from tests.common import MockConfigEntry, async_fire_time_changed 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 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( @pytest.mark.parametrize(
"mock_method", "mock_method",
[ [
@ -330,11 +452,13 @@ async def test_event_listener_resilience(
assert config_entry.state == ConfigEntryState.LOADED assert config_entry.state == ConfigEntryState.LOADED
assert len(config_entry._background_tasks) == 1 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) future.set_exception(exception)
await hass.async_block_till_done() await hass.async_block_till_done()
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30)) async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
await hass.async_block_till_done() await hass.async_block_till_done()
@ -362,4 +486,6 @@ async def test_event_listener_resilience(
) )
await hass.async_block_till_done() 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