Migrate from homeconnect dependency to aiohomeconnect (#136116)

* Migrate from homeconnect dependency to aiohomeconnect

* Reload the integration if there is an API error on event stream

* fix typos at coordinator tests

* Setup config entry at coordinator tests

* fix ruff

* Bump aiohomeconnect to version 0.11.4

* Fix set program options

* Use context based updates at coordinator

* Improved how `context_callbacks` cache is invalidated

* fix

* fixes and improvements at coordinator

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove stale Entity inheritance

* Small improvement for light subscriptions

* Remove non-needed function

It had its purpose before some refactoring before the firs commit, no is no needed as is only used at HomeConnectEntity constructor

* Static methods and variables at conftest

* Refresh the data after an event stream interruption

* Cleaned debug logs

* Fetch programs at coordinator

* Improvements

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Simplify obtaining power settings from coordinator data

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

* Remove unnecessary statement

* use `is UNDEFINED` instead of `isinstance`

* Request power setting only when it is strictly necessary

* Bump aiohomeconnect to 0.12.1

* use raw keys for diagnostics

* Use keyword arguments where needed

* Remove unnecessary statements

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>

---------

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
J. Diego Rodríguez Royo
2025-01-30 02:42:41 +01:00
committed by GitHub
parent 4e3e1e91b7
commit b637129208
33 changed files with 3117 additions and 2641 deletions

View File

@@ -2,27 +2,18 @@
from collections.abc import Awaitable, Callable
from typing import Any
from unittest.mock import MagicMock, Mock, patch
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from aiohomeconnect.const import OAUTH2_TOKEN
from aiohomeconnect.model import SettingKey, StatusKey
from aiohomeconnect.model.error import HomeConnectError
import pytest
from requests import HTTPError
import requests_mock
import respx
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
from homeassistant.components.home_connect import (
SCAN_INTERVAL,
bsh_key_to_translation_key,
)
from homeassistant.components.home_connect.const import (
BSH_CHILD_LOCK_STATE,
BSH_OPERATION_STATE,
BSH_POWER_STATE,
BSH_REMOTE_START_ALLOWANCE_STATE,
COOKING_LIGHTING,
DOMAIN,
OAUTH2_TOKEN,
)
from homeassistant.components.home_connect.const import DOMAIN
from homeassistant.components.home_connect.utils import bsh_key_to_translation_key
from homeassistant.components.light import DOMAIN as LIGHT_DOMAIN
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
@@ -39,7 +30,6 @@ from .conftest import (
FAKE_ACCESS_TOKEN,
FAKE_REFRESH_TOKEN,
SERVER_ACCESS_TOKEN,
get_all_appliances,
)
from tests.common import MockConfigEntry
@@ -126,28 +116,26 @@ SERVICE_PROGRAM_CALL_PARAMS = [
]
SERVICE_APPLIANCE_METHOD_MAPPING = {
"set_option_active": "set_options_active_program",
"set_option_selected": "set_options_selected_program",
"set_option_active": "set_active_program_option",
"set_option_selected": "set_selected_program_option",
"change_setting": "set_setting",
"pause_program": "execute_command",
"resume_program": "execute_command",
"select_program": "select_program",
"pause_program": "put_command",
"resume_program": "put_command",
"select_program": "set_selected_program",
"start_program": "start_program",
}
@pytest.mark.usefixtures("bypass_throttle")
async def test_api_setup(
async def test_entry_setup(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client: MagicMock,
) -> None:
"""Test setup and unload."""
get_appliances.side_effect = get_all_appliances
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert await hass.config_entries.async_unload(config_entry.entry_id)
@@ -156,72 +144,60 @@ async def test_api_setup(
assert config_entry.state == ConfigEntryState.NOT_LOADED
async def test_update_throttle(
appliance: Mock,
freezer: FrozenDateTimeFactory,
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
) -> None:
"""Test to check Throttle functionality."""
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
get_appliances_call_count = get_appliances.call_count
# First re-load after 1 minute is not blocked.
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED
freezer.tick(SCAN_INTERVAL.seconds + 0.1)
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert get_appliances.call_count == get_appliances_call_count + 1
# Second re-load is blocked by Throttle.
assert await hass.config_entries.async_unload(config_entry.entry_id)
assert config_entry.state == ConfigEntryState.NOT_LOADED
freezer.tick(SCAN_INTERVAL.seconds - 0.1)
assert await hass.config_entries.async_setup(config_entry.entry_id)
assert get_appliances.call_count == get_appliances_call_count + 1
@pytest.mark.usefixtures("bypass_throttle")
async def test_exception_handling(
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry,
setup_credentials: None,
get_appliances: MagicMock,
problematic_appliance: Mock,
client_with_exception: MagicMock,
) -> None:
"""Test exception handling."""
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED
@pytest.mark.parametrize("token_expiration_time", [12345])
@pytest.mark.usefixtures("bypass_throttle")
@respx.mock
async def test_token_refresh_success(
integration_setup: Callable[[], Awaitable[bool]],
hass: HomeAssistant,
platforms: list[Platform],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
config_entry: MockConfigEntry,
aioclient_mock: AiohttpClientMocker,
requests_mock: requests_mock.Mocker,
setup_credentials: None,
client: MagicMock,
) -> None:
"""Test where token is expired and the refresh attempt succeeds."""
assert config_entry.data["token"]["access_token"] == FAKE_ACCESS_TOKEN
requests_mock.post(OAUTH2_TOKEN, json=SERVER_ACCESS_TOKEN)
requests_mock.get("/api/homeappliances", json={"data": {"homeappliances": []}})
aioclient_mock.post(
OAUTH2_TOKEN,
json=SERVER_ACCESS_TOKEN,
)
assert await integration_setup()
appliances = client.get_home_appliances.return_value
async def mock_get_home_appliances():
await client._auth.async_get_access_token()
return appliances
client.get_home_appliances.return_value = None
client.get_home_appliances.side_effect = mock_get_home_appliances
def init_side_effect(auth) -> MagicMock:
client._auth = auth
return client
assert config_entry.state == ConfigEntryState.NOT_LOADED
with (
patch("homeassistant.components.home_connect.PLATFORMS", platforms),
patch("homeassistant.components.home_connect.HomeConnectClient") as client_mock,
):
client_mock.side_effect = MagicMock(side_effect=init_side_effect)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state == ConfigEntryState.LOADED
# Verify token request
@@ -240,45 +216,43 @@ async def test_token_refresh_success(
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_http_error(
async def test_client_error(
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
client_with_exception: MagicMock,
) -> None:
"""Test HTTP errors during setup integration."""
get_appliances.side_effect = HTTPError(response=MagicMock())
"""Test client errors during setup integration."""
client_with_exception.get_home_appliances.return_value = None
client_with_exception.get_home_appliances.side_effect = HomeConnectError()
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert config_entry.state == ConfigEntryState.LOADED
assert get_appliances.call_count == 1
assert not await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.SETUP_RETRY
assert client_with_exception.get_home_appliances.call_count == 1
@pytest.mark.parametrize(
"service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_services(
service_call: list[dict[str, Any]],
service_call: dict[str, Any],
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
appliance: Mock,
client: MagicMock,
appliance_ha_id: str,
) -> None:
"""Create and test services."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, appliance.haId)},
identifiers={(DOMAIN, appliance_ha_id)},
)
service_name = service_call["service"]
@@ -286,8 +260,7 @@ async def test_services(
await hass.services.async_call(**service_call)
await hass.async_block_till_done()
assert (
getattr(appliance, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count
== 1
getattr(client, SERVICE_APPLIANCE_METHOD_MAPPING[service_name]).call_count == 1
)
@@ -295,26 +268,24 @@ async def test_services(
"service_call",
SERVICE_KV_CALL_PARAMS + SERVICE_COMMAND_CALL_PARAMS + SERVICE_PROGRAM_CALL_PARAMS,
)
@pytest.mark.usefixtures("bypass_throttle")
async def test_services_exception(
service_call: list[dict[str, Any]],
service_call: dict[str, Any],
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
problematic_appliance: Mock,
client_with_exception: MagicMock,
appliance_ha_id: str,
device_registry: dr.DeviceRegistry,
) -> None:
"""Raise a HomeAssistantError when there is an API error."""
get_appliances.return_value = [problematic_appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client_with_exception)
assert config_entry.state == ConfigEntryState.LOADED
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, problematic_appliance.haId)},
identifiers={(DOMAIN, appliance_ha_id)},
)
service_call["service_data"]["device_id"] = device_entry.id
@@ -323,25 +294,47 @@ async def test_services_exception(
await hass.services.async_call(**service_call)
@pytest.mark.usefixtures("bypass_throttle")
async def test_services_appliance_not_found(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[], Awaitable[bool]],
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
get_appliances: MagicMock,
appliance: Mock,
client: MagicMock,
device_registry: dr.DeviceRegistry,
) -> None:
"""Raise a ServiceValidationError when device id does not match."""
get_appliances.return_value = [appliance]
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup()
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
service_call = SERVICE_KV_CALL_PARAMS[0]
service_call["service_data"]["device_id"] = "DOES_NOT_EXISTS"
with pytest.raises(ServiceValidationError, match=r"Device entry.*not found"):
await hass.services.async_call(**service_call)
unrelated_config_entry = MockConfigEntry(
domain="TEST",
)
unrelated_config_entry.add_to_hass(hass)
device_entry = device_registry.async_get_or_create(
config_entry_id=unrelated_config_entry.entry_id,
identifiers={("RANDOM", "ABCD")},
)
service_call["service_data"]["device_id"] = device_entry.id
with pytest.raises(
ServiceValidationError, match=r"Home Connect config entry.*not found"
):
await hass.services.async_call(**service_call)
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={("RANDOM", "ABCD")},
)
service_call["service_data"]["device_id"] = device_entry.id
with pytest.raises(ServiceValidationError, match=r"Appliance.*not found"):
await hass.services.async_call(**service_call)
@@ -351,7 +344,7 @@ async def test_entity_migration(
device_registry: dr.DeviceRegistry,
entity_registry: er.EntityRegistry,
config_entry_v1_1: MockConfigEntry,
appliance: Mock,
appliance_ha_id: str,
platforms: list[Platform],
) -> None:
"""Test entity migration."""
@@ -360,34 +353,39 @@ async def test_entity_migration(
device_entry = device_registry.async_get_or_create(
config_entry_id=config_entry_v1_1.entry_id,
identifiers={(DOMAIN, appliance.haId)},
identifiers={(DOMAIN, appliance_ha_id)},
)
test_entities = [
(
SENSOR_DOMAIN,
"Operation State",
BSH_OPERATION_STATE,
StatusKey.BSH_COMMON_OPERATION_STATE,
),
(
SWITCH_DOMAIN,
"ChildLock",
BSH_CHILD_LOCK_STATE,
SettingKey.BSH_COMMON_CHILD_LOCK,
),
(
SWITCH_DOMAIN,
"Power",
BSH_POWER_STATE,
SettingKey.BSH_COMMON_POWER_STATE,
),
(
BINARY_SENSOR_DOMAIN,
"Remote Start",
BSH_REMOTE_START_ALLOWANCE_STATE,
StatusKey.BSH_COMMON_REMOTE_CONTROL_START_ALLOWED,
),
(
LIGHT_DOMAIN,
"Light",
COOKING_LIGHTING,
SettingKey.COOKING_COMMON_LIGHTING,
),
( # An already migrated entity
SWITCH_DOMAIN,
SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
SettingKey.REFRIGERATION_COMMON_VACATION_MODE,
),
]
@@ -395,7 +393,7 @@ async def test_entity_migration(
entity_registry.async_get_or_create(
domain,
DOMAIN,
f"{appliance.haId}-{old_unique_id_suffix}",
f"{appliance_ha_id}-{old_unique_id_suffix}",
device_id=device_entry.id,
config_entry=config_entry_v1_1,
)
@@ -406,7 +404,7 @@ async def test_entity_migration(
for domain, _, expected_unique_id_suffix in test_entities:
assert entity_registry.async_get_entity_id(
domain, DOMAIN, f"{appliance.haId}-{expected_unique_id_suffix}"
domain, DOMAIN, f"{appliance_ha_id}-{expected_unique_id_suffix}"
)
assert config_entry_v1_1.minor_version == 2