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, ...]]
] = {}
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
}

View File

@ -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):

View File

@ -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"
)

View File

@ -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"

View File

@ -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