mirror of
https://github.com/home-assistant/core.git
synced 2026-05-08 17:49:37 +01:00
Implement dynamic devices for Liebherr integration (#163951)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -3,6 +3,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
from pyliebherrhomeapi import LiebherrClient
|
||||
from pyliebherrhomeapi.exceptions import (
|
||||
@@ -14,8 +16,13 @@ from homeassistant.const import CONF_API_KEY, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .const import DEVICE_SCAN_INTERVAL, DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS: list[Platform] = [
|
||||
Platform.NUMBER,
|
||||
@@ -42,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
|
||||
|
||||
# Create a coordinator for each device (may be empty if no devices)
|
||||
coordinators: dict[str, LiebherrCoordinator] = {}
|
||||
data = LiebherrData(client=client)
|
||||
for device in devices:
|
||||
coordinator = LiebherrCoordinator(
|
||||
hass=hass,
|
||||
@@ -50,20 +57,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
|
||||
client=client,
|
||||
device_id=device.device_id,
|
||||
)
|
||||
coordinators[device.device_id] = coordinator
|
||||
data.coordinators[device.device_id] = coordinator
|
||||
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.async_config_entry_first_refresh()
|
||||
for coordinator in coordinators.values()
|
||||
for coordinator in data.coordinators.values()
|
||||
)
|
||||
)
|
||||
|
||||
# Store coordinators in runtime data
|
||||
entry.runtime_data = coordinators
|
||||
# Store runtime data
|
||||
entry.runtime_data = data
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Schedule periodic scan for new devices
|
||||
async def _async_scan_for_new_devices(_now: datetime) -> None:
|
||||
"""Scan for new devices added to the account."""
|
||||
try:
|
||||
devices = await client.get_devices()
|
||||
except LiebherrAuthenticationError, LiebherrConnectionError:
|
||||
_LOGGER.debug("Failed to scan for new devices")
|
||||
return
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected error scanning for new devices")
|
||||
return
|
||||
|
||||
new_coordinators: list[LiebherrCoordinator] = []
|
||||
for device in devices:
|
||||
if device.device_id not in data.coordinators:
|
||||
coordinator = LiebherrCoordinator(
|
||||
hass=hass,
|
||||
config_entry=entry,
|
||||
client=client,
|
||||
device_id=device.device_id,
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
if not coordinator.last_update_success:
|
||||
_LOGGER.debug("Failed to set up new device %s", device.device_id)
|
||||
continue
|
||||
data.coordinators[device.device_id] = coordinator
|
||||
new_coordinators.append(coordinator)
|
||||
|
||||
if new_coordinators:
|
||||
async_dispatcher_send(
|
||||
hass,
|
||||
f"{DOMAIN}_new_device_{entry.entry_id}",
|
||||
new_coordinators,
|
||||
)
|
||||
|
||||
entry.async_on_unload(
|
||||
async_track_time_interval(
|
||||
hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL
|
||||
)
|
||||
)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
|
||||
@@ -6,4 +6,6 @@ from typing import Final
|
||||
DOMAIN: Final = "liebherr"
|
||||
MANUFACTURER: Final = "Liebherr"
|
||||
|
||||
SCAN_INTERVAL: Final = timedelta(seconds=60)
|
||||
DEVICE_SCAN_INTERVAL: Final = timedelta(minutes=5)
|
||||
REFRESH_DELAY: Final = timedelta(seconds=5)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
from pyliebherrhomeapi import (
|
||||
@@ -18,13 +18,20 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
type LiebherrConfigEntry = ConfigEntry[dict[str, LiebherrCoordinator]]
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(seconds=60)
|
||||
|
||||
@dataclass
|
||||
class LiebherrData:
|
||||
"""Runtime data for the Liebherr integration."""
|
||||
|
||||
client: LiebherrClient
|
||||
coordinators: dict[str, LiebherrCoordinator] = field(default_factory=dict)
|
||||
|
||||
|
||||
type LiebherrConfigEntry = ConfigEntry[LiebherrData]
|
||||
|
||||
|
||||
class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):
|
||||
|
||||
@@ -29,6 +29,6 @@ async def async_get_config_entry_diagnostics(
|
||||
},
|
||||
"data": asdict(coordinator.data),
|
||||
}
|
||||
for device_id, coordinator in entry.runtime_data.items()
|
||||
for device_id, coordinator in entry.runtime_data.coordinators.items()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -16,9 +16,11 @@ from homeassistant.components.number import (
|
||||
NumberEntityDescription,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import LiebherrZoneEntity
|
||||
|
||||
@@ -53,22 +55,41 @@ NUMBER_TYPES: tuple[LiebherrNumberEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _create_number_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrNumber]:
|
||||
"""Create number entities for the given coordinators."""
|
||||
return [
|
||||
LiebherrNumber(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in NUMBER_TYPES
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr number entities."""
|
||||
coordinators = entry.runtime_data
|
||||
async_add_entities(
|
||||
LiebherrNumber(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
_create_number_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add number entities for new devices."""
|
||||
async_add_entities(_create_number_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
for coordinator in coordinators.values()
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in NUMBER_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -53,7 +53,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default:
|
||||
|
||||
@@ -18,9 +18,11 @@ from pyliebherrhomeapi import (
|
||||
)
|
||||
|
||||
from homeassistant.components.select import SelectEntity, SelectEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import ZONE_POSITION_MAP, LiebherrEntity
|
||||
|
||||
@@ -109,15 +111,13 @@ SELECT_TYPES: list[LiebherrSelectEntityDescription] = [
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr select entities."""
|
||||
def _create_select_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrSelectEntity]:
|
||||
"""Create select entities for the given coordinators."""
|
||||
entities: list[LiebherrSelectEntity] = []
|
||||
|
||||
for coordinator in entry.runtime_data.values():
|
||||
for coordinator in coordinators:
|
||||
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
|
||||
|
||||
for control in coordinator.data.controls:
|
||||
@@ -137,7 +137,29 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr select entities."""
|
||||
async_add_entities(
|
||||
_create_select_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add select entities for new devices."""
|
||||
async_add_entities(_create_select_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class LiebherrSelectEntity(LiebherrEntity, SelectEntity):
|
||||
|
||||
@@ -14,10 +14,12 @@ from homeassistant.components.sensor import (
|
||||
SensorStateClass,
|
||||
)
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import LiebherrZoneEntity
|
||||
|
||||
@@ -48,22 +50,41 @@ SENSOR_TYPES: tuple[LiebherrSensorEntityDescription, ...] = (
|
||||
)
|
||||
|
||||
|
||||
def _create_sensor_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrSensor]:
|
||||
"""Create sensor entities for the given coordinators."""
|
||||
return [
|
||||
LiebherrSensor(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
)
|
||||
for coordinator in coordinators
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in SENSOR_TYPES
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr sensor entities."""
|
||||
coordinators = entry.runtime_data
|
||||
async_add_entities(
|
||||
LiebherrSensor(
|
||||
coordinator=coordinator,
|
||||
zone_id=temp_control.zone_id,
|
||||
description=description,
|
||||
_create_sensor_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add sensor entities for new devices."""
|
||||
async_add_entities(_create_sensor_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
for coordinator in coordinators.values()
|
||||
for temp_control in coordinator.data.get_temperature_controls().values()
|
||||
for description in SENSOR_TYPES
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -15,9 +15,11 @@ from pyliebherrhomeapi.const import (
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
|
||||
from .entity import ZONE_POSITION_MAP, LiebherrEntity
|
||||
|
||||
@@ -90,15 +92,13 @@ DEVICE_SWITCH_TYPES: dict[str, LiebherrDeviceSwitchEntityDescription] = {
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr switch entities."""
|
||||
def _create_switch_entities(
|
||||
coordinators: list[LiebherrCoordinator],
|
||||
) -> list[LiebherrDeviceSwitch | LiebherrZoneSwitch]:
|
||||
"""Create switch entities for the given coordinators."""
|
||||
entities: list[LiebherrDeviceSwitch | LiebherrZoneSwitch] = []
|
||||
|
||||
for coordinator in entry.runtime_data.values():
|
||||
for coordinator in coordinators:
|
||||
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
|
||||
|
||||
for control in coordinator.data.controls:
|
||||
@@ -127,7 +127,29 @@ async def async_setup_entry(
|
||||
)
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: LiebherrConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Liebherr switch entities."""
|
||||
async_add_entities(
|
||||
_create_switch_entities(list(entry.runtime_data.coordinators.values()))
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
|
||||
"""Add switch entities for new devices."""
|
||||
async_add_entities(_create_switch_entities(coordinators))
|
||||
|
||||
entry.async_on_unload(
|
||||
async_dispatcher_connect(
|
||||
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):
|
||||
|
||||
@@ -1,20 +1,36 @@
|
||||
"""Test the liebherr integration init."""
|
||||
|
||||
import copy
|
||||
from datetime import timedelta
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pyliebherrhomeapi import (
|
||||
Device,
|
||||
DeviceState,
|
||||
DeviceType,
|
||||
IceMakerControl,
|
||||
IceMakerMode,
|
||||
TemperatureControl,
|
||||
TemperatureUnit,
|
||||
ToggleControl,
|
||||
ZonePosition,
|
||||
)
|
||||
from pyliebherrhomeapi.exceptions import (
|
||||
LiebherrAuthenticationError,
|
||||
LiebherrConnectionError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.liebherr.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import MOCK_DEVICE
|
||||
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
# Test errors during initial get_devices() call in async_setup_entry
|
||||
@@ -85,3 +101,173 @@ async def test_unload_entry(
|
||||
await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
|
||||
|
||||
|
||||
NEW_DEVICE = Device(
|
||||
device_id="new_device_id",
|
||||
nickname="New Fridge",
|
||||
device_type=DeviceType.FRIDGE,
|
||||
device_name="K2601",
|
||||
)
|
||||
|
||||
NEW_DEVICE_STATE = DeviceState(
|
||||
device=NEW_DEVICE,
|
||||
controls=[
|
||||
TemperatureControl(
|
||||
zone_id=1,
|
||||
zone_position=ZonePosition.TOP,
|
||||
name="Fridge",
|
||||
type="fridge",
|
||||
value=4,
|
||||
target=5,
|
||||
min=2,
|
||||
max=8,
|
||||
unit=TemperatureUnit.CELSIUS,
|
||||
),
|
||||
ToggleControl(
|
||||
name="supercool",
|
||||
type="ToggleControl",
|
||||
zone_id=1,
|
||||
zone_position=ZonePosition.TOP,
|
||||
value=False,
|
||||
),
|
||||
IceMakerControl(
|
||||
name="icemaker",
|
||||
type="IceMakerControl",
|
||||
zone_id=1,
|
||||
zone_position=ZonePosition.TOP,
|
||||
ice_maker_mode=IceMakerMode.OFF,
|
||||
has_max_ice=False,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_dynamic_device_discovery_no_new_devices(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test device scan with no new devices does not create entities."""
|
||||
# Same devices returned
|
||||
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE]
|
||||
|
||||
initial_states = len(hass.states.async_all())
|
||||
|
||||
freezer.tick(timedelta(minutes=5, seconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# No new entities should be created
|
||||
assert len(hass.states.async_all()) == initial_states
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
@pytest.mark.parametrize(
|
||||
"exception",
|
||||
[
|
||||
LiebherrConnectionError("Connection failed"),
|
||||
LiebherrAuthenticationError("Auth failed"),
|
||||
],
|
||||
ids=["connection_error", "auth_error"],
|
||||
)
|
||||
async def test_dynamic_device_discovery_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
exception: Exception,
|
||||
) -> None:
|
||||
"""Test device scan gracefully handles API errors."""
|
||||
mock_liebherr_client.get_devices.side_effect = exception
|
||||
|
||||
initial_states = len(hass.states.async_all())
|
||||
|
||||
freezer.tick(timedelta(minutes=5, seconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# No crash, no new entities
|
||||
assert len(hass.states.async_all()) == initial_states
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_dynamic_device_discovery_coordinator_setup_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test device scan skips devices that fail coordinator setup."""
|
||||
# New device appears but its state fetch fails
|
||||
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE]
|
||||
|
||||
original_state = copy.deepcopy(MOCK_DEVICE_STATE)
|
||||
mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: (
|
||||
copy.deepcopy(original_state)
|
||||
if device_id == "test_device_id"
|
||||
else (_ for _ in ()).throw(LiebherrConnectionError("Device offline"))
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(minutes=5, seconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# New device should NOT be added
|
||||
assert "new_device_id" not in mock_config_entry.runtime_data.coordinators
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
async def test_dynamic_device_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_liebherr_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test new devices are automatically discovered on all platforms."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
all_platforms = [
|
||||
Platform.SENSOR,
|
||||
Platform.NUMBER,
|
||||
Platform.SWITCH,
|
||||
Platform.SELECT,
|
||||
]
|
||||
with patch(f"homeassistant.components.{DOMAIN}.PLATFORMS", all_platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Initially only the original device exists
|
||||
assert hass.states.get("sensor.test_fridge_top_zone") is not None
|
||||
assert hass.states.get("sensor.new_fridge") is None
|
||||
|
||||
# Simulate a new device appearing on the account
|
||||
mock_liebherr_client.get_devices.return_value = [MOCK_DEVICE, NEW_DEVICE]
|
||||
mock_liebherr_client.get_device_state.side_effect = lambda device_id, **kw: (
|
||||
copy.deepcopy(
|
||||
NEW_DEVICE_STATE if device_id == "new_device_id" else MOCK_DEVICE_STATE
|
||||
)
|
||||
)
|
||||
|
||||
# Advance time to trigger device scan (5 minute interval)
|
||||
freezer.tick(timedelta(minutes=5, seconds=1))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# New device should have entities on all platforms
|
||||
state = hass.states.get("sensor.new_fridge")
|
||||
assert state is not None
|
||||
assert state.state == "4"
|
||||
assert hass.states.get("number.new_fridge_setpoint") is not None
|
||||
assert hass.states.get("switch.new_fridge_supercool") is not None
|
||||
assert hass.states.get("select.new_fridge_icemaker") is not None
|
||||
|
||||
# Original device should still exist
|
||||
assert hass.states.get("sensor.test_fridge_top_zone") is not None
|
||||
|
||||
# Runtime data should have both coordinators
|
||||
assert "new_device_id" in mock_config_entry.runtime_data.coordinators
|
||||
assert "test_device_id" in mock_config_entry.runtime_data.coordinators
|
||||
|
||||
@@ -29,7 +29,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE
|
||||
from .conftest import MOCK_DEVICE
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
@@ -100,70 +100,6 @@ async def test_single_zone_number(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_multi_zone_with_none_position(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
platforms: list[Platform],
|
||||
) -> None:
|
||||
"""Test multi-zone device with None zone_position falls back to base translation key."""
|
||||
device = Device(
|
||||
device_id="multi_zone_none",
|
||||
nickname="Multi Zone Fridge",
|
||||
device_type=DeviceType.COMBI,
|
||||
device_name="CBNes9999",
|
||||
)
|
||||
mock_liebherr_client.get_devices.return_value = [device]
|
||||
multi_zone_state = DeviceState(
|
||||
device=device,
|
||||
controls=[
|
||||
TemperatureControl(
|
||||
zone_id=1,
|
||||
zone_position=None, # None triggers fallback
|
||||
name="Fridge",
|
||||
type="fridge",
|
||||
value=5,
|
||||
target=4,
|
||||
min=2,
|
||||
max=8,
|
||||
unit=TemperatureUnit.CELSIUS,
|
||||
),
|
||||
TemperatureControl(
|
||||
zone_id=2,
|
||||
zone_position=ZonePosition.BOTTOM,
|
||||
name="Freezer",
|
||||
type="freezer",
|
||||
value=-18,
|
||||
target=-18,
|
||||
min=-24,
|
||||
max=-16,
|
||||
unit=TemperatureUnit.CELSIUS,
|
||||
),
|
||||
],
|
||||
)
|
||||
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
|
||||
multi_zone_state
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Zone with None position should have base translation key
|
||||
zone1_entity = entity_registry.async_get("number.multi_zone_fridge_setpoint")
|
||||
assert zone1_entity is not None
|
||||
assert zone1_entity.translation_key == "setpoint_temperature"
|
||||
|
||||
# Zone with valid position should have zone-specific translation key
|
||||
zone2_entity = entity_registry.async_get(
|
||||
"number.multi_zone_fridge_bottom_zone_setpoint"
|
||||
)
|
||||
assert zone2_entity is not None
|
||||
assert zone2_entity.translation_key == "setpoint_temperature_bottom_zone"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_set_temperature(
|
||||
hass: HomeAssistant,
|
||||
@@ -216,50 +152,6 @@ async def test_set_temperature_failure(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_number_update_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test number becomes unavailable when coordinator update fails and recovers."""
|
||||
entity_id = "number.test_fridge_top_zone_setpoint"
|
||||
|
||||
# Initial state should be available with value
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "4"
|
||||
|
||||
# Simulate update error
|
||||
mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError(
|
||||
"Connection failed"
|
||||
)
|
||||
|
||||
# Advance time to trigger coordinator refresh (60 second interval)
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Number should now be unavailable
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
# Simulate recovery
|
||||
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
|
||||
MOCK_DEVICE_STATE
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Number should recover
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "4"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_number_when_control_missing(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -182,46 +182,6 @@ async def test_select_failure(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_select_update_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test select becomes unavailable when coordinator update fails and recovers."""
|
||||
entity_id = "select.test_fridge_bottom_zone_icemaker"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
|
||||
# Simulate update error
|
||||
mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError(
|
||||
"Connection failed"
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
# Simulate recovery
|
||||
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
|
||||
MOCK_DEVICE_STATE
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "off"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_select_when_control_missing(
|
||||
hass: HomeAssistant,
|
||||
@@ -307,70 +267,6 @@ async def test_single_zone_select(
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
|
||||
async def test_multi_zone_with_none_position(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
platforms: list[Platform],
|
||||
) -> None:
|
||||
"""Test multi-zone device where zone_position is None."""
|
||||
device = Device(
|
||||
device_id="multi_none_id",
|
||||
nickname="Multi None Fridge",
|
||||
device_type=DeviceType.COMBI,
|
||||
device_name="CBNes5678",
|
||||
)
|
||||
mock_liebherr_client.get_devices.return_value = [device]
|
||||
state = DeviceState(
|
||||
device=device,
|
||||
controls=[
|
||||
TemperatureControl(
|
||||
zone_id=1,
|
||||
zone_position=None,
|
||||
name="Fridge",
|
||||
type="fridge",
|
||||
value=4,
|
||||
target=4,
|
||||
min=2,
|
||||
max=8,
|
||||
unit=TemperatureUnit.CELSIUS,
|
||||
),
|
||||
TemperatureControl(
|
||||
zone_id=2,
|
||||
zone_position=None,
|
||||
name="Freezer",
|
||||
type="freezer",
|
||||
value=-18,
|
||||
target=-18,
|
||||
min=-24,
|
||||
max=-16,
|
||||
unit=TemperatureUnit.CELSIUS,
|
||||
),
|
||||
IceMakerControl(
|
||||
name="icemaker",
|
||||
type="IceMakerControl",
|
||||
zone_id=1,
|
||||
zone_position=None,
|
||||
ice_maker_mode=IceMakerMode.OFF,
|
||||
has_max_ice=True,
|
||||
),
|
||||
],
|
||||
)
|
||||
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
|
||||
state
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
with patch("homeassistant.components.liebherr.PLATFORMS", platforms):
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Without zone_position, should use the base translation key (no zone suffix)
|
||||
entity_state = hass.states.get("select.multi_none_fridge_icemaker")
|
||||
assert entity_state is not None
|
||||
assert entity_state.state == "off"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_select_current_option_none_mode(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -32,7 +32,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from .conftest import MOCK_DEVICE, MOCK_DEVICE_STATE
|
||||
from .conftest import MOCK_DEVICE
|
||||
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
@@ -150,46 +150,6 @@ async def test_switch_failure(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_switch_update_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_liebherr_client: MagicMock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test switch becomes unavailable when coordinator update fails and recovers."""
|
||||
entity_id = "switch.test_fridge_top_zone_supercool"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
# Simulate update error
|
||||
mock_liebherr_client.get_device_state.side_effect = LiebherrConnectionError(
|
||||
"Connection failed"
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
# Simulate recovery
|
||||
mock_liebherr_client.get_device_state.side_effect = lambda *a, **kw: copy.deepcopy(
|
||||
MOCK_DEVICE_STATE
|
||||
)
|
||||
|
||||
freezer.tick(timedelta(seconds=61))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_switch_when_control_missing(
|
||||
hass: HomeAssistant,
|
||||
|
||||
Reference in New Issue
Block a user