mirror of
https://github.com/home-assistant/core.git
synced 2026-11-06 05:32:45 -04:00
Rewrite iZone runtime for python-izone 1.3.6 discovery API (#176751)
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,9 +1,5 @@
|
||||
"""IZone tests."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pizone
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
@@ -12,21 +8,7 @@ from tests.common import MockConfigEntry
|
||||
async def setup_integration(
|
||||
hass: HomeAssistant, mock_config_entry: MockConfigEntry
|
||||
) -> None:
|
||||
"""Mock integration setup."""
|
||||
"""Add and set up a config entry (discovery/controller must already be mocked)."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
async def setup_controller(
|
||||
hass: HomeAssistant, mock_discovery: AsyncMock, mock_controller: AsyncMock
|
||||
) -> None:
|
||||
"""Mock integration setup."""
|
||||
get_discovery_service(mock_discovery).controller_discovered(mock_controller)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
def get_discovery_service(mock_discovery: AsyncMock) -> pizone.Listener:
|
||||
"""Get the DiscoveryService instance from the mock discovery."""
|
||||
return mock_discovery.mock_calls[0][1][0]
|
||||
|
||||
@@ -4,12 +4,11 @@ from collections.abc import AsyncGenerator, Generator, Iterable
|
||||
from contextlib import contextmanager
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from pizone import Controller, Zone
|
||||
from pizone import Controller, ControllerEndpoint, DiscoveryService, Zone
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.izone import discovery as izone_discovery
|
||||
from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN
|
||||
from homeassistant.const import CONF_EXCLUDE
|
||||
from homeassistant.components.izone.const import DOMAIN
|
||||
from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
@@ -18,27 +17,17 @@ from tests.common import MockConfigEntry
|
||||
|
||||
@pytest.fixture
|
||||
def mock_config_entry() -> MockConfigEntry:
|
||||
"""Create a mock config entry for iZone."""
|
||||
"""Create a mock config entry for iZone with a stored host."""
|
||||
return MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
title="iZone",
|
||||
data={},
|
||||
title="iZone 000000001",
|
||||
data={CONF_HOST: "192.0.2.1"},
|
||||
entry_id="test_entry_id",
|
||||
unique_id="000000001",
|
||||
version=2,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pizone_discovery_service() -> Mock:
|
||||
"""Create a mock pizone discovery service."""
|
||||
disco = Mock()
|
||||
disco.fetch_controllers = AsyncMock(return_value={})
|
||||
disco.start_discovery = AsyncMock()
|
||||
disco.close = AsyncMock()
|
||||
return disco
|
||||
|
||||
|
||||
def create_mock_controller(
|
||||
device_uid: str = "000000001",
|
||||
device_ip: str = "192.0.2.1",
|
||||
@@ -47,6 +36,8 @@ def create_mock_controller(
|
||||
zone_ctrl: int = 1,
|
||||
ras_mode: str = "master",
|
||||
free_air_enabled: bool = False,
|
||||
free_air: bool = False,
|
||||
is_on: bool = True,
|
||||
) -> Mock:
|
||||
"""Create a mock Controller with configurable parameters."""
|
||||
controller = Mock(spec=Controller)
|
||||
@@ -57,8 +48,9 @@ def create_mock_controller(
|
||||
controller.zone_ctrl = zone_ctrl
|
||||
controller.ras_mode = ras_mode
|
||||
controller.free_air_enabled = free_air_enabled
|
||||
controller.free_air = False
|
||||
controller.is_on = True
|
||||
controller.free_air = free_air
|
||||
controller.is_on = is_on
|
||||
controller.connected = True
|
||||
controller.mode = Controller.Mode.COOL
|
||||
controller.temp_setpoint = 24.0
|
||||
controller.temp_return = 22.0
|
||||
@@ -72,9 +64,30 @@ def create_mock_controller(
|
||||
Controller.Fan.HIGH,
|
||||
Controller.Fan.AUTO,
|
||||
]
|
||||
controller.zones = []
|
||||
controller.refresh_all = AsyncMock()
|
||||
controller.close = AsyncMock()
|
||||
controller.set_temp_setpoint = AsyncMock()
|
||||
controller.set_fan = AsyncMock()
|
||||
controller.set_on = AsyncMock()
|
||||
controller.set_mode = AsyncMock()
|
||||
controller.set_free_air = AsyncMock()
|
||||
return controller
|
||||
|
||||
|
||||
def create_mock_endpoint(
|
||||
uid: str = "000000001",
|
||||
host: str = "192.0.2.1",
|
||||
) -> ControllerEndpoint:
|
||||
"""Create a ControllerEndpoint for config-flow discovery patches."""
|
||||
return ControllerEndpoint(uid=uid, host=host)
|
||||
|
||||
|
||||
def endpoint_from_controller(controller: Mock) -> ControllerEndpoint:
|
||||
"""Map a mock controller's uid/ip into a ControllerEndpoint."""
|
||||
return ControllerEndpoint(uid=controller.device_uid, host=controller.device_ip)
|
||||
|
||||
|
||||
def create_mock_zone(
|
||||
index: int = 0,
|
||||
name: str = "Zone",
|
||||
@@ -92,123 +105,145 @@ def create_mock_zone(
|
||||
zone.airflow_min = 0
|
||||
zone.airflow_max = 100
|
||||
zone.is_on = True
|
||||
zone.set_airflow_min = AsyncMock()
|
||||
zone.set_airflow_max = AsyncMock()
|
||||
zone.set_temp_setpoint = AsyncMock()
|
||||
zone.set_mode = AsyncMock()
|
||||
return zone
|
||||
|
||||
|
||||
def create_mock_discovery_service(*controllers: Mock) -> Mock:
|
||||
"""Create a mock discovery service with the given controllers."""
|
||||
service = Mock()
|
||||
service.pi_disco = Mock()
|
||||
service.pi_disco.controllers = {c.device_uid: c for c in controllers}
|
||||
service.pi_disco.fetch_controller = AsyncMock(
|
||||
side_effect=lambda uid, timeout=None: service.pi_disco.controllers.get(uid)
|
||||
)
|
||||
service.pi_disco.fetch_controllers = AsyncMock(
|
||||
side_effect=lambda timeout=None: dict(service.pi_disco.controllers)
|
||||
)
|
||||
service.async_schedule_idle_stop = Mock()
|
||||
return service
|
||||
|
||||
|
||||
@contextmanager
|
||||
def patch_discovered_controllers(
|
||||
controllers: Mock | dict[str, Mock] | Iterable[Mock],
|
||||
) -> Generator[Mock]:
|
||||
"""Patch discovery startup so async_discover_controllers uses these controllers."""
|
||||
) -> Generator[tuple[AsyncMock, AsyncMock]]:
|
||||
"""Patch discovery helpers using mock controllers' uid/ip."""
|
||||
if isinstance(controllers, dict):
|
||||
ctrl_list = list(controllers.values())
|
||||
elif isinstance(controllers, Mock):
|
||||
ctrl_list = [controllers]
|
||||
else:
|
||||
ctrl_list = list(controllers)
|
||||
service = create_mock_discovery_service(*ctrl_list)
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.async_start_discovery_service",
|
||||
new_callable=AsyncMock,
|
||||
return_value=service,
|
||||
endpoints = {
|
||||
controller.device_uid: endpoint_from_controller(controller)
|
||||
for controller in ctrl_list
|
||||
}
|
||||
|
||||
async def _discover_all(
|
||||
hass: HomeAssistant,
|
||||
) -> dict[str, ControllerEndpoint]:
|
||||
return dict(endpoints)
|
||||
|
||||
async def _discover_one(hass: HomeAssistant, uid: str) -> ControllerEndpoint | None:
|
||||
return endpoints.get(uid)
|
||||
|
||||
mock_discover_all = AsyncMock(side_effect=_discover_all)
|
||||
mock_discover_one = AsyncMock(side_effect=_discover_one)
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_discover_all_endpoints",
|
||||
new=mock_discover_all,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_discover_endpoint",
|
||||
new=mock_discover_one,
|
||||
),
|
||||
):
|
||||
yield service
|
||||
yield mock_discover_all, mock_discover_one
|
||||
|
||||
|
||||
async def async_load_yaml_exclude(hass: HomeAssistant, *uids: str) -> None:
|
||||
"""Load deprecated YAML exclude config through the integration setup path."""
|
||||
with (
|
||||
patch.object(hass, "async_create_task"),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.discovery",
|
||||
return_value=Mock(start_discovery=AsyncMock(), close=AsyncMock()),
|
||||
"homeassistant.components.izone.async_setup_entry",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_ensure_discovery",
|
||||
new=AsyncMock(return_value=Mock(spec=DiscoveryService)),
|
||||
),
|
||||
):
|
||||
assert await async_setup_component(
|
||||
hass, DOMAIN, {DOMAIN: {CONF_EXCLUDE: list(uids)}}
|
||||
)
|
||||
|
||||
|
||||
async def async_install_discovery_service(
|
||||
hass: HomeAssistant, *controllers: Mock
|
||||
) -> Mock:
|
||||
"""Start the discovery service with mocked pizone and optional controllers."""
|
||||
mock_pi_disco = create_mock_discovery_service(*controllers).pi_disco
|
||||
mock_pi_disco.start_discovery = AsyncMock()
|
||||
mock_pi_disco.close = AsyncMock()
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.discovery",
|
||||
return_value=mock_pi_disco,
|
||||
),
|
||||
):
|
||||
service = await izone_discovery.async_start_discovery_service(hass)
|
||||
assert DATA_DISCOVERY_SERVICE in hass.data
|
||||
return service
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_entry_setup() -> Generator[None]:
|
||||
"""Patch climate platform setup for entry-creating config flow tests."""
|
||||
"""Skip full entry setup for config-flow create-entry tests."""
|
||||
with patch(
|
||||
"homeassistant.components.izone.climate.async_setup_entry",
|
||||
"homeassistant.components.izone.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_discovery(
|
||||
mock_controller: AsyncMock, mock_zones: list[AsyncMock]
|
||||
) -> AsyncGenerator[AsyncMock]:
|
||||
"""Create a mock discovery service with one controller and zones."""
|
||||
mock_controller.zones = mock_zones
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.pizone.discovery", autospec=True
|
||||
) as mock_disco:
|
||||
mock_disco.return_value.start_discovery = AsyncMock()
|
||||
mock_disco.return_value.close = AsyncMock()
|
||||
mock_disco.return_value.fetch_controller = AsyncMock(
|
||||
return_value=mock_controller
|
||||
)
|
||||
mock_disco.return_value.fetch_controllers = AsyncMock(
|
||||
return_value={mock_controller.device_uid: mock_controller}
|
||||
)
|
||||
yield mock_disco
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_zones() -> list[AsyncMock]:
|
||||
def mock_zones() -> list[Mock]:
|
||||
"""Create a list of mock zones."""
|
||||
return [create_mock_zone(index=0, name="Living Room")]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def mock_controller(mock_zones: list[AsyncMock]) -> AsyncMock:
|
||||
"""Create a mock controller."""
|
||||
return create_mock_controller(
|
||||
def mock_controller(mock_zones: list[Mock]) -> Mock:
|
||||
"""Create a mock controller with zones attached."""
|
||||
controller = create_mock_controller(
|
||||
device_uid="000000001",
|
||||
device_ip="192.0.2.1",
|
||||
ras_mode="master",
|
||||
zone_ctrl=1,
|
||||
zones_total=1,
|
||||
)
|
||||
controller.zones = mock_zones
|
||||
return controller
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_discovery_service(mock_controller: Mock) -> Mock:
|
||||
"""Return a mock pizone DiscoveryService wired for entry setup."""
|
||||
service = Mock(spec=DiscoveryService)
|
||||
service.scan = AsyncMock()
|
||||
service.close = AsyncMock()
|
||||
service.discover_all = AsyncMock(
|
||||
return_value=[endpoint_from_controller(mock_controller)]
|
||||
)
|
||||
service.discover_by_uid = AsyncMock(
|
||||
return_value=endpoint_from_controller(mock_controller)
|
||||
)
|
||||
service.create_controller = AsyncMock(return_value=mock_controller)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_create_discovery(
|
||||
mock_discovery_service: Mock,
|
||||
) -> Generator[AsyncMock]:
|
||||
"""Patch pizone.create_discovery to return the mock discovery service."""
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(return_value=mock_discovery_service),
|
||||
) as mock_create:
|
||||
yield mock_create
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def platforms() -> list[Platform]:
|
||||
"""Platforms to load in integration tests."""
|
||||
return [Platform.CLIMATE]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
platforms: list[Platform],
|
||||
) -> AsyncGenerator[MockConfigEntry]:
|
||||
"""Set up the iZone integration with a mocked discovery/controller stack."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch("homeassistant.components.izone.PLATFORMS", platforms):
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
yield mock_config_entry
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_basic_controller_properties[climate.izone_controller_000000001-entry]
|
||||
# name: test_climate_entities[climate.izone_controller_000000001-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
@@ -54,7 +54,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_basic_controller_properties[climate.izone_controller_000000001-state]
|
||||
# name: test_climate_entities[climate.izone_controller_000000001-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'control_zone': 1,
|
||||
@@ -92,7 +92,7 @@
|
||||
'state': 'cool',
|
||||
})
|
||||
# ---
|
||||
# name: test_basic_controller_properties[climate.living_room-entry]
|
||||
# name: test_climate_entities[climate.living_room-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': list([
|
||||
None,
|
||||
@@ -138,7 +138,7 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_basic_controller_properties[climate.living_room-state]
|
||||
# name: test_climate_entities[climate.living_room-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'airflow_max': 100,
|
||||
|
||||
@@ -1,95 +1,136 @@
|
||||
"""Tests for iZone climate platform."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import logging
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
from pizone import Controller, ControllerCommandError, Zone
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.climate import ClimateEntityFeature
|
||||
from homeassistant.components.climate import (
|
||||
ATTR_FAN_MODE,
|
||||
ATTR_HVAC_MODE,
|
||||
ATTR_TEMPERATURE,
|
||||
DOMAIN as CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
ClimateEntityFeature,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.components.izone.const import DOMAIN
|
||||
from homeassistant.components.izone.coordinator import UPDATE_INTERVAL
|
||||
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
import homeassistant.helpers.device_registry as dr
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.entity_registry as er
|
||||
|
||||
from . import setup_controller, setup_integration
|
||||
from . import setup_integration
|
||||
from .conftest import create_mock_controller, create_mock_zone
|
||||
|
||||
from tests.common import MockConfigEntry, snapshot_platform
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
|
||||
|
||||
CONTROLLER_ENTITY = "climate.izone_controller_000000001"
|
||||
ZONE_ENTITY = "climate.living_room"
|
||||
|
||||
|
||||
async def test_basic_controller_properties(
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_climate_entities(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
snapshot: SnapshotAssertion,
|
||||
) -> None:
|
||||
"""Test basic properties of ControllerDevice."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
|
||||
"""Controller and zone climate entities are created from the coordinator."""
|
||||
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
|
||||
assert entity is not None
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
& ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
) != ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
# Snapshot covers registry + state payloads; also assert the stable entity_ids
|
||||
# used by the rest of this module still resolve after setup.
|
||||
assert hass.states.get(CONTROLLER_ENTITY) is not None
|
||||
assert hass.states.get(ZONE_ENTITY) is not None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[
|
||||
create_mock_controller(
|
||||
ras_mode="RAS",
|
||||
)
|
||||
],
|
||||
[create_mock_controller(ras_mode="RAS", zones_total=1)],
|
||||
)
|
||||
async def test_set_controller_temperature_ras(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""RAS-mode controller accepts target temperature commands."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_TEMPERATURE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_TEMPERATURE: 23.5},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_controller.set_temp_setpoint.assert_awaited_once_with(23.5)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_set_controller_hvac_and_fan(
|
||||
hass: HomeAssistant,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""HVAC and fan mode services call through to the controller."""
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.HEAT},
|
||||
blocking=True,
|
||||
)
|
||||
mock_controller.set_mode.assert_awaited_once_with(Controller.Mode.HEAT)
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "high"},
|
||||
blocking=True,
|
||||
)
|
||||
mock_controller.set_fan.assert_awaited_once_with(Controller.Fan.HIGH)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_set_zone_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""Zone HVAC mode changes call the zone library API."""
|
||||
zone = mock_zones[0]
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: ZONE_ENTITY, ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
blocking=True,
|
||||
)
|
||||
zone.set_mode.assert_awaited_once_with(Zone.Mode.CLOSE)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[create_mock_controller(ras_mode="RAS")],
|
||||
)
|
||||
async def test_target_temperature_feature_ras_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""Test TARGET_TEMPERATURE feature enabled in RAS mode."""
|
||||
"""TARGET_TEMPERATURE is enabled in RAS mode."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
|
||||
assert entity is not None
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
& ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
) == ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[
|
||||
create_mock_controller(
|
||||
zone_ctrl=13, # Greater than zones_total (4)
|
||||
zones_total=4,
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_target_temperature_feature_master_mode_invalid_zone(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
) -> None:
|
||||
"""Test TARGET_TEMPERATURE feature enabled when control zone is invalid."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
|
||||
entity = hass.states.get(CONTROLLER_ENTITY)
|
||||
assert entity is not None
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
@@ -101,32 +142,26 @@ async def test_target_temperature_feature_master_mode_invalid_zone(
|
||||
("mock_controller", "mock_zones"),
|
||||
[
|
||||
(
|
||||
create_mock_controller(
|
||||
zone_ctrl=1, # Valid zone
|
||||
zones_total=2,
|
||||
),
|
||||
create_mock_controller(zone_ctrl=1, zones_total=2),
|
||||
[
|
||||
create_mock_zone(index=0, name="Living Room", temp_current=22.5),
|
||||
create_mock_zone(
|
||||
index=1, name="Bedroom", temp_current=None
|
||||
), # No sensor
|
||||
create_mock_zone(index=1, name="Bedroom", temp_current=None),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_target_temperature_feature_zone_without_sensor(
|
||||
async def test_target_temperature_when_zone_missing_sensor(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""Test TARGET_TEMPERATURE enabled when zone lacks temp sensor."""
|
||||
"""TARGET_TEMPERATURE is enabled when any zone lacks a temperature sensor."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
|
||||
entity = hass.states.get(CONTROLLER_ENTITY)
|
||||
assert entity is not None
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
@@ -134,216 +169,375 @@ async def test_target_temperature_feature_zone_without_sensor(
|
||||
) == ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mock_controller", "mock_zones"),
|
||||
[
|
||||
(
|
||||
create_mock_controller(
|
||||
zones_total=3,
|
||||
),
|
||||
[
|
||||
create_mock_zone(index=0, name="Zone 1", temp_current=22.5),
|
||||
create_mock_zone(index=1, name="Zone 2", temp_current=23.0),
|
||||
create_mock_zone(index=2, name="Zone 3", temp_current=21.5),
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_target_temperature_feature_all_zones_with_sensors(
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_refresh_failure_makes_entities_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test TARGET_TEMPERATURE feature NOT enabled when all zones have sensors."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
"""Coordinator refresh failure marks controller and zone unavailable."""
|
||||
mock_controller.refresh_all = AsyncMock(side_effect=ConnectionError("offline"))
|
||||
freezer.tick(UPDATE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
assert hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE
|
||||
|
||||
assert entity is not None
|
||||
# Should NOT have TARGET_TEMPERATURE feature
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
& ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
) != ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_soft_fault_marks_entities_unavailable_until_reconnect(
|
||||
hass: HomeAssistant,
|
||||
mock_controller: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Successful refresh with connected=False marks entities unavailable."""
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL
|
||||
assert hass.states.get(ZONE_ENTITY).state == HVACMode.HEAT_COOL
|
||||
|
||||
mock_controller.connected = False
|
||||
freezer.tick(UPDATE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
assert hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE
|
||||
|
||||
mock_controller.connected = True
|
||||
freezer.tick(UPDATE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL
|
||||
assert hass.states.get(ZONE_ENTITY).state == HVACMode.HEAT_COOL
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mock_controller", "mock_zones"),
|
||||
("command_error", "expect_unavailable", "translation_key"),
|
||||
[
|
||||
(
|
||||
create_mock_controller(
|
||||
zones_total=3,
|
||||
),
|
||||
[
|
||||
create_mock_zone(index=0, name="Zone 1", temp_current=22.5),
|
||||
create_mock_zone(index=1, name="Zone 2", temp_current=None),
|
||||
create_mock_zone(index=2, name="Zone 3", temp_current=21.5),
|
||||
],
|
||||
)
|
||||
pytest.param(
|
||||
ControllerCommandError("rejected"),
|
||||
False,
|
||||
"command_rejected",
|
||||
id="command_error_stays_available",
|
||||
),
|
||||
pytest.param(
|
||||
ConnectionError("disconnected"),
|
||||
True,
|
||||
"unable_to_connect",
|
||||
id="connection_error_marks_unavailable",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_target_temperature_feature_multiple_zones_one_without_sensor(
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_controller_command_error_handling(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
command_error: Exception,
|
||||
expect_unavailable: bool,
|
||||
translation_key: str,
|
||||
) -> None:
|
||||
"""Test TARGET_TEMPERATURE enabled with multiple zones, one no sensor."""
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
"""Rejected and transport failures raise HomeAssistantError; only transport clears availability."""
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
mock_controller.set_fan = AsyncMock(side_effect=command_error)
|
||||
with pytest.raises(HomeAssistantError) as err:
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert entity is not None
|
||||
assert err.value.translation_domain == DOMAIN
|
||||
assert err.value.translation_key == translation_key
|
||||
is_unavailable = hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
assert is_unavailable is expect_unavailable
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_controller_rejected_command_does_not_log_warning(
|
||||
hass: HomeAssistant,
|
||||
mock_controller: Mock,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Rejected commands raise HomeAssistantError without a duplicate warning log."""
|
||||
mock_controller.set_fan = AsyncMock(side_effect=ControllerCommandError("rejected"))
|
||||
with (
|
||||
caplog.at_level(logging.WARNING),
|
||||
pytest.raises(HomeAssistantError) as err,
|
||||
):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_key == "command_rejected"
|
||||
assert not any(
|
||||
record.name.startswith("homeassistant.components.izone")
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("command_error", "expect_unavailable", "translation_key"),
|
||||
[
|
||||
pytest.param(
|
||||
ControllerCommandError("rejected"),
|
||||
False,
|
||||
"command_rejected",
|
||||
id="command_error_stays_available",
|
||||
),
|
||||
pytest.param(
|
||||
ConnectionError("disconnected"),
|
||||
True,
|
||||
"unable_to_connect",
|
||||
id="connection_error_marks_unavailable",
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_zone_command_error_handling(
|
||||
hass: HomeAssistant,
|
||||
mock_zones: list[Mock],
|
||||
command_error: Exception,
|
||||
expect_unavailable: bool,
|
||||
translation_key: str,
|
||||
) -> None:
|
||||
"""Zone command errors raise HomeAssistantError; only transport clears availability."""
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL
|
||||
assert hass.states.get(ZONE_ENTITY) is not None
|
||||
|
||||
mock_zones[0].set_mode = AsyncMock(side_effect=command_error)
|
||||
with pytest.raises(HomeAssistantError) as err:
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: ZONE_ENTITY, ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert err.value.translation_domain == DOMAIN
|
||||
assert err.value.translation_key == translation_key
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
& ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
) == ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
) is expect_unavailable
|
||||
assert (
|
||||
hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE
|
||||
) is expect_unavailable
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[
|
||||
create_mock_controller(
|
||||
ras_mode="slave",
|
||||
free_air_enabled=True,
|
||||
free_air=True,
|
||||
zone_ctrl=1,
|
||||
zones_total=1,
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_target_temperature_feature_slave_mode(
|
||||
async def test_set_hvac_mode_free_air_noop_when_on(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""Test TARGET_TEMPERATURE feature NOT enabled in slave mode."""
|
||||
"""Free-air fan_only does not send commands when the system is already on."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
mock_controller.set_mode = AsyncMock()
|
||||
mock_controller.set_on = AsyncMock()
|
||||
|
||||
assert entity is not None
|
||||
# Should NOT have TARGET_TEMPERATURE feature
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
& ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
) != ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.FAN_ONLY},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_controller.set_mode.assert_not_called()
|
||||
mock_controller.set_on.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[
|
||||
create_mock_controller(
|
||||
zone_ctrl=13,
|
||||
zones_total=4,
|
||||
free_air_enabled=True,
|
||||
free_air=True,
|
||||
zone_ctrl=1,
|
||||
zones_total=1,
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_target_temperature_feature_master_mode_zone_13(
|
||||
async def test_set_hvac_mode_free_air_noop_when_unavailable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""Test TARGET_TEMPERATURE feature enabled when control zone is 13 (master unit)."""
|
||||
"""Free-air fan_only while unavailable must not restore availability."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
entity = hass.states.get(entity_id)
|
||||
mock_controller.set_fan = AsyncMock(side_effect=ConnectionError("disconnected"))
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"},
|
||||
blocking=True,
|
||||
)
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
|
||||
mock_controller.set_mode = AsyncMock()
|
||||
mock_controller.set_on = AsyncMock()
|
||||
|
||||
# Service calls are rejected for unavailable entities; call the entity method
|
||||
# directly so the free-air early-return path is still covered.
|
||||
entity = hass.data[CLIMATE_DOMAIN].get_entity(CONTROLLER_ENTITY)
|
||||
assert entity is not None
|
||||
assert (
|
||||
entity.attributes["supported_features"]
|
||||
& ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
) == ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
await entity.async_set_hvac_mode(HVACMode.FAN_ONLY)
|
||||
|
||||
mock_controller.set_mode.assert_not_called()
|
||||
mock_controller.set_on.assert_not_called()
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
async def test_setup_entry_only_adds_entities_for_matching_config_entry(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
) -> None:
|
||||
"""Test a config entry only adds entities for its matching controller."""
|
||||
matching_controller = create_mock_controller(
|
||||
device_uid="000000001", device_ip="192.0.2.1", zones_total=1
|
||||
)
|
||||
matching_controller.zones = [create_mock_zone(index=0, name="Living Room")]
|
||||
|
||||
other_controller = create_mock_controller(
|
||||
device_uid="000000002", device_ip="192.0.2.2", zones_total=1
|
||||
)
|
||||
other_controller.zones = [create_mock_zone(index=0, name="Bedroom")]
|
||||
|
||||
entry = MockConfigEntry(
|
||||
domain="izone",
|
||||
title="iZone",
|
||||
data={},
|
||||
unique_id="000000001",
|
||||
entry_id="test_entry_id",
|
||||
version=2,
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.pizone.discovery", autospec=True
|
||||
) as mock_disco:
|
||||
mock_disco.return_value.start_discovery = AsyncMock()
|
||||
mock_disco.return_value.close = AsyncMock()
|
||||
mock_disco.return_value.fetch_controller = AsyncMock(
|
||||
return_value=matching_controller
|
||||
)
|
||||
mock_disco.return_value.fetch_controllers = AsyncMock(
|
||||
return_value={
|
||||
matching_controller.device_uid: matching_controller,
|
||||
other_controller.device_uid: other_controller,
|
||||
}
|
||||
)
|
||||
|
||||
await setup_integration(hass, entry)
|
||||
|
||||
entry_entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
|
||||
unique_ids = {entity.unique_id for entity in entry_entities}
|
||||
|
||||
assert unique_ids == {"000000001", "000000001_z1"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("mock_zones", [[]])
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[
|
||||
create_mock_controller(
|
||||
ras_mode="zones",
|
||||
sys_type="0",
|
||||
zones_total=0,
|
||||
free_air_enabled=False,
|
||||
free_air_enabled=True,
|
||||
free_air=True,
|
||||
is_on=False,
|
||||
zone_ctrl=1,
|
||||
zones_total=1,
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_controller_device_init_fault_bootstrap(
|
||||
async def test_set_hvac_mode_free_air_turns_on_when_off(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_discovery: AsyncMock,
|
||||
mock_controller: AsyncMock,
|
||||
entity_registry: er.EntityRegistry,
|
||||
device_registry: dr.DeviceRegistry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""Controller climate entity is created with fault-shaped controller defaults."""
|
||||
"""Free-air fan_only turns the system on before skipping the mode change."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
await setup_controller(hass, mock_discovery, mock_controller)
|
||||
|
||||
entity_id = "climate.izone_controller_000000001"
|
||||
assert hass.states.get(entity_id) is not None
|
||||
mock_controller.set_on = AsyncMock()
|
||||
mock_controller.set_mode = AsyncMock()
|
||||
|
||||
entry_entities = er.async_entries_for_config_entry(
|
||||
entity_registry, mock_config_entry.entry_id
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.FAN_ONLY},
|
||||
blocking=True,
|
||||
)
|
||||
assert {entity.unique_id for entity in entry_entities} == {"000000001"}
|
||||
|
||||
entity_entry = entity_registry.async_get(entity_id)
|
||||
assert entity_entry is not None
|
||||
device_entry = device_registry.async_get(entity_entry.device_id)
|
||||
assert device_entry is not None
|
||||
assert device_entry.model == "0"
|
||||
mock_controller.set_on.assert_awaited_once_with(True)
|
||||
mock_controller.set_mode.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[
|
||||
create_mock_controller(
|
||||
free_air_enabled=True,
|
||||
free_air=True,
|
||||
zone_ctrl=1,
|
||||
zones_total=1,
|
||||
)
|
||||
],
|
||||
)
|
||||
async def test_set_hvac_mode_off_while_free_air(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""Off is sent before the free-air early return."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_controller.set_on = AsyncMock()
|
||||
mock_controller.set_mode = AsyncMock()
|
||||
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.OFF},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_controller.set_on.assert_awaited_once_with(False)
|
||||
mock_controller.set_mode.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"mock_controller",
|
||||
[create_mock_controller(is_on=False, zone_ctrl=1, zones_total=1)],
|
||||
)
|
||||
async def test_set_hvac_mode_connection_error_on_turn_on_skips_set_mode(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
mock_zones: list[Mock],
|
||||
) -> None:
|
||||
"""A transport failure turning on does not continue to set_mode."""
|
||||
mock_controller.zones = mock_zones
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
mock_controller.set_on = AsyncMock(side_effect=ConnectionError("disconnected"))
|
||||
mock_controller.set_mode = AsyncMock()
|
||||
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_HVAC_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_HVAC_MODE: HVACMode.COOL},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_controller.set_on.assert_awaited_once_with(True)
|
||||
mock_controller.set_mode.assert_not_called()
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_command_connection_error_recovers_on_coordinator_refresh(
|
||||
hass: HomeAssistant,
|
||||
mock_controller: Mock,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Entities recover when a later coordinator refresh succeeds after a transport failure."""
|
||||
mock_controller.set_fan = AsyncMock(side_effect=ConnectionError("disconnected"))
|
||||
with pytest.raises(HomeAssistantError):
|
||||
await hass.services.async_call(
|
||||
CLIMATE_DOMAIN,
|
||||
SERVICE_SET_FAN_MODE,
|
||||
{ATTR_ENTITY_ID: CONTROLLER_ENTITY, ATTR_FAN_MODE: "low"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == STATE_UNAVAILABLE
|
||||
assert hass.states.get(ZONE_ENTITY).state == STATE_UNAVAILABLE
|
||||
|
||||
mock_controller.refresh_all = AsyncMock()
|
||||
freezer.tick(UPDATE_INTERVAL)
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert hass.states.get(CONTROLLER_ENTITY).state == HVACMode.COOL
|
||||
assert hass.states.get(ZONE_ENTITY).state == HVACMode.HEAT_COOL
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,335 +1,534 @@
|
||||
"""Tests for iZone discovery service."""
|
||||
"""Tests for iZone discovery service lifecycle."""
|
||||
|
||||
from unittest.mock import ANY, AsyncMock, Mock, patch
|
||||
import asyncio
|
||||
from collections.abc import Generator
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from pizone import ControllerEndpoint, DiscoveryService
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.izone import discovery as izone_discovery
|
||||
from homeassistant.components.izone.const import DATA_DISCOVERY_SERVICE, DOMAIN
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.components.izone.const import DISCOVERY_SCAN_INTERVAL, DOMAIN
|
||||
from homeassistant.components.izone.discovery import DATA_DISCOVERY_SERVICE
|
||||
from homeassistant.const import CONF_HOST, EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from .conftest import (
|
||||
async_load_yaml_exclude,
|
||||
create_mock_controller,
|
||||
create_mock_discovery_service,
|
||||
)
|
||||
from .conftest import async_load_yaml_exclude, create_mock_endpoint
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.common import MockConfigEntry, async_fire_time_changed
|
||||
|
||||
|
||||
async def test_async_start_discovery_service_stops_on_home_assistant_stop(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_discovery_service: Mock,
|
||||
) -> None:
|
||||
"""Test discovery service is stopped on Home Assistant shutdown."""
|
||||
def _mock_pizone_service() -> Mock:
|
||||
"""Return a mock pizone DiscoveryService."""
|
||||
service = Mock(spec=DiscoveryService)
|
||||
service.scan = AsyncMock()
|
||||
service.close = AsyncMock()
|
||||
service.discover_all = AsyncMock(return_value=[])
|
||||
service.discover_by_uid = AsyncMock(return_value=None)
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pizone_create_discovery() -> Generator[tuple[AsyncMock, Mock]]:
|
||||
"""Patch create_discovery and yield (mock_create, mock_service)."""
|
||||
mock_service = _mock_pizone_service()
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.discovery",
|
||||
return_value=mock_pizone_discovery_service,
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(return_value=mock_service),
|
||||
) as mock_create,
|
||||
):
|
||||
yield mock_create, mock_service
|
||||
|
||||
|
||||
async def test_ensure_discovery_starts_and_stops_on_homeassistant_stop(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""Ensure starts discovery with an initial scan and closes on HA stop."""
|
||||
mock_create, mock_service = mock_pizone_create_discovery
|
||||
|
||||
service = await izone_discovery.async_ensure_discovery(hass)
|
||||
|
||||
assert service is mock_service
|
||||
mock_create.assert_awaited_once()
|
||||
mock_service.scan.assert_awaited_once()
|
||||
|
||||
# Second ensure reuses the same service.
|
||||
assert await izone_discovery.async_ensure_discovery(hass) is mock_service
|
||||
mock_create.assert_awaited_once()
|
||||
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_service.close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_ensure_discovery_recreates_after_stop(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""After a clean stop, ensure starts a fresh create_discovery."""
|
||||
first_service = _mock_pizone_service()
|
||||
second_service = _mock_pizone_service()
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=[first_service, second_service]),
|
||||
) as mock_create,
|
||||
):
|
||||
assert await izone_discovery.async_ensure_discovery(hass) is first_service
|
||||
await izone_discovery.async_stop_discovery(hass)
|
||||
first_service.close.assert_awaited_once()
|
||||
assert DATA_DISCOVERY_SERVICE not in hass.data
|
||||
|
||||
assert await izone_discovery.async_ensure_discovery(hass) is second_service
|
||||
|
||||
assert mock_create.await_count == 2
|
||||
assert isinstance(
|
||||
hass.data[DATA_DISCOVERY_SERVICE], izone_discovery.DiscoveryServiceState
|
||||
)
|
||||
assert hass.data[DATA_DISCOVERY_SERVICE].runtime is not None
|
||||
assert hass.data[DATA_DISCOVERY_SERVICE].runtime.service is second_service
|
||||
|
||||
|
||||
async def test_ensure_discovery_serializes_concurrent_reopen(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Concurrent ensure after stop still shares a single reopen create."""
|
||||
first_service = _mock_pizone_service()
|
||||
second_service = _mock_pizone_service()
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
create_calls = 0
|
||||
|
||||
async def _create(**_kwargs: object) -> Mock:
|
||||
nonlocal create_calls
|
||||
create_calls += 1
|
||||
if create_calls == 1:
|
||||
return first_service
|
||||
started.set()
|
||||
await release.wait()
|
||||
return second_service
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=_create),
|
||||
) as mock_create,
|
||||
):
|
||||
assert await izone_discovery.async_ensure_discovery(hass) is first_service
|
||||
await izone_discovery.async_stop_discovery(hass)
|
||||
|
||||
task_one = hass.async_create_task(
|
||||
izone_discovery.async_ensure_discovery(hass), eager_start=True
|
||||
)
|
||||
await started.wait()
|
||||
task_two = hass.async_create_task(
|
||||
izone_discovery.async_ensure_discovery(hass), eager_start=True
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert create_calls == 2
|
||||
state = hass.data[DATA_DISCOVERY_SERVICE]
|
||||
assert isinstance(state, izone_discovery.DiscoveryServiceState)
|
||||
assert state.starting is not None
|
||||
assert not state.starting.done()
|
||||
|
||||
release.set()
|
||||
service_one, service_two = await asyncio.gather(task_one, task_two)
|
||||
|
||||
assert service_one is second_service
|
||||
assert service_two is second_service
|
||||
assert mock_create.await_count == 2
|
||||
|
||||
|
||||
async def test_ensure_discovery_serializes_concurrent_create(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Concurrent ensure callers share one create_discovery and the same service."""
|
||||
mock_service = _mock_pizone_service()
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
create_calls = 0
|
||||
|
||||
async def _slow_create(**_kwargs: object) -> Mock:
|
||||
nonlocal create_calls
|
||||
create_calls += 1
|
||||
started.set()
|
||||
await release.wait()
|
||||
return mock_service
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=_slow_create),
|
||||
) as mock_create,
|
||||
):
|
||||
task_one = hass.async_create_task(
|
||||
izone_discovery.async_ensure_discovery(hass), eager_start=True
|
||||
)
|
||||
await started.wait()
|
||||
task_two = hass.async_create_task(
|
||||
izone_discovery.async_ensure_discovery(hass), eager_start=True
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
assert create_calls == 1
|
||||
state = hass.data[DATA_DISCOVERY_SERVICE]
|
||||
assert isinstance(state, izone_discovery.DiscoveryServiceState)
|
||||
assert state.starting is not None
|
||||
assert not state.starting.done()
|
||||
|
||||
release.set()
|
||||
service_one, service_two = await asyncio.gather(task_one, task_two)
|
||||
|
||||
assert service_one is mock_service
|
||||
assert service_two is mock_service
|
||||
mock_create.assert_awaited_once()
|
||||
assert isinstance(
|
||||
hass.data[DATA_DISCOVERY_SERVICE], izone_discovery.DiscoveryServiceState
|
||||
)
|
||||
assert hass.data[DATA_DISCOVERY_SERVICE].runtime is not None
|
||||
|
||||
|
||||
async def test_ensure_discovery_failure_fails_concurrent_waiters(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Create failure clears the slot and fails every concurrent waiter."""
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _failing_create(**_kwargs: object) -> Mock:
|
||||
started.set()
|
||||
await release.wait()
|
||||
raise OSError("bind failed")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=_failing_create),
|
||||
),
|
||||
):
|
||||
await izone_discovery.async_start_discovery_service(hass)
|
||||
task_one = hass.async_create_task(
|
||||
izone_discovery.async_ensure_discovery(hass), eager_start=True
|
||||
)
|
||||
await started.wait()
|
||||
task_two = hass.async_create_task(
|
||||
izone_discovery.async_ensure_discovery(hass), eager_start=True
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
release.set()
|
||||
results = await asyncio.gather(task_one, task_two, return_exceptions=True)
|
||||
|
||||
assert DATA_DISCOVERY_SERVICE in hass.data
|
||||
|
||||
hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_pizone_discovery_service.start_discovery.assert_awaited_once()
|
||||
mock_pizone_discovery_service.close.assert_awaited_once()
|
||||
assert all(isinstance(result, OSError) for result in results)
|
||||
assert DATA_DISCOVERY_SERVICE not in hass.data
|
||||
|
||||
|
||||
async def test_async_maybe_stop_keeps_running_when_actionable_flow_exists(
|
||||
async def test_stop_discovery_aborts_in_flight_create(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Discovery should stay running while an actionable iZone flow is in progress."""
|
||||
service = create_mock_discovery_service()
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
"""Stopping while create is pending fails the Future and clears the slot."""
|
||||
started = asyncio.Event()
|
||||
release = asyncio.Event()
|
||||
|
||||
async def _slow_create(**_kwargs: object) -> Mock:
|
||||
started.set()
|
||||
await release.wait()
|
||||
return _mock_pizone_service()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
hass.config_entries.flow,
|
||||
"async_progress_by_handler",
|
||||
return_value=[{"context": {"source": config_entries.SOURCE_USER}}],
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_stop_discovery_service",
|
||||
new=AsyncMock(),
|
||||
) as mock_stop,
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=_slow_create),
|
||||
),
|
||||
):
|
||||
await izone_discovery.async_maybe_stop_discovery_service(hass)
|
||||
|
||||
mock_stop.assert_not_awaited()
|
||||
service.async_schedule_idle_stop.assert_called_once()
|
||||
|
||||
|
||||
async def test_async_maybe_stop_keeps_running_when_actionable_entry_exists(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Discovery should stay running while an enabled, non-ignored entry exists."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
source=config_entries.SOURCE_USER,
|
||||
data={},
|
||||
).add_to_hass(hass)
|
||||
|
||||
service = create_mock_discovery_service()
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.async_stop_discovery_service",
|
||||
new=AsyncMock(),
|
||||
) as mock_stop:
|
||||
await izone_discovery.async_maybe_stop_discovery_service(hass)
|
||||
|
||||
mock_stop.assert_not_awaited()
|
||||
service.async_schedule_idle_stop.assert_called_once()
|
||||
|
||||
|
||||
async def test_async_maybe_stop_stops_when_only_disabled_entry_matches_controller(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Discovery should stop when only disabled/ignored controllers remain."""
|
||||
MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
source=config_entries.SOURCE_USER,
|
||||
disabled_by=config_entries.ConfigEntryDisabler.USER,
|
||||
data={},
|
||||
).add_to_hass(hass)
|
||||
|
||||
service = create_mock_discovery_service(create_mock_controller("000000001"))
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.async_stop_discovery_service",
|
||||
new=AsyncMock(),
|
||||
) as mock_stop:
|
||||
await izone_discovery.async_maybe_stop_discovery_service(hass)
|
||||
|
||||
mock_stop.assert_awaited_once_with(hass)
|
||||
service.async_schedule_idle_stop.assert_not_called()
|
||||
|
||||
|
||||
async def test_async_discover_controllers_starts_shared_service_when_missing(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Starting discovery without refresh does not trigger extra wait/rescan work."""
|
||||
controller = create_mock_controller(device_ip="192.0.2.3")
|
||||
service = create_mock_discovery_service(controller)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.async_start_discovery_service",
|
||||
return_value=service,
|
||||
) as mock_start:
|
||||
controllers = await izone_discovery.async_discover_controllers(hass)
|
||||
|
||||
assert list(controllers) == ["000000001"]
|
||||
mock_start.assert_awaited_once()
|
||||
service.pi_disco.fetch_controller.assert_not_awaited()
|
||||
service.pi_disco.fetch_controllers.assert_awaited_once_with()
|
||||
|
||||
|
||||
async def test_async_discover_controllers_refresh_after_start_calls_fetch_controllers(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Refresh after starting discovery delegates to fetch_controllers with timeout."""
|
||||
controller = create_mock_controller(device_ip="192.0.2.3")
|
||||
service = create_mock_discovery_service(controller)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.discovery.async_start_discovery_service",
|
||||
return_value=service,
|
||||
) as mock_start:
|
||||
controllers = await izone_discovery.async_discover_controllers(
|
||||
hass, refresh=True
|
||||
ensure_task = hass.async_create_task(
|
||||
izone_discovery.async_ensure_discovery(hass), eager_start=True
|
||||
)
|
||||
await started.wait()
|
||||
await izone_discovery.async_stop_discovery(hass)
|
||||
release.set()
|
||||
with pytest.raises(RuntimeError, match="stopped before start completed"):
|
||||
await ensure_task
|
||||
|
||||
assert list(controllers) == ["000000001"]
|
||||
mock_start.assert_awaited_once()
|
||||
service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY)
|
||||
assert DATA_DISCOVERY_SERVICE not in hass.data
|
||||
|
||||
|
||||
async def test_async_discover_controllers_refresh_calls_fetch_controllers(
|
||||
async def test_slow_scan_fires_while_entry_loaded(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""Refresh without UID delegates to fetch_controllers with timeout."""
|
||||
service = create_mock_discovery_service()
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
|
||||
controllers = await izone_discovery.async_discover_controllers(hass, refresh=True)
|
||||
|
||||
assert controllers == {}
|
||||
service.pi_disco.fetch_controllers.assert_awaited_once_with(timeout=ANY)
|
||||
|
||||
|
||||
async def test_async_discover_controllers_waits_for_requested_uid(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Refresh with wait_for_uid calls fetch_controller and returns all controllers."""
|
||||
service = create_mock_discovery_service()
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
requested = create_mock_controller("000000777", "192.0.2.77")
|
||||
|
||||
async def _fetch_and_add(uid: str, timeout: float | None = None) -> None:
|
||||
service.pi_disco.controllers[uid] = requested
|
||||
|
||||
service.pi_disco.fetch_controller.side_effect = _fetch_and_add
|
||||
|
||||
controllers = await izone_discovery.async_discover_controllers(
|
||||
hass,
|
||||
refresh=True,
|
||||
wait_for_uid="000000777",
|
||||
"""Shared timer keeps hunting for new devices while an entry is loaded."""
|
||||
_, mock_service = mock_pizone_create_discovery
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
source=config_entries.SOURCE_USER,
|
||||
data={CONF_HOST: "192.0.2.1"},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
entry.mock_state(hass, config_entries.ConfigEntryState.LOADED)
|
||||
|
||||
assert controllers == {requested.device_uid: requested}
|
||||
service.pi_disco.fetch_controller.assert_awaited_once_with("000000777", timeout=ANY)
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
mock_service.scan.reset_mock()
|
||||
|
||||
|
||||
async def test_async_discover_controllers_returns_empty_when_start_fails(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Startup errors while creating discovery are propagated to the caller."""
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_start_discovery_service",
|
||||
side_effect=OSError,
|
||||
),
|
||||
pytest.raises(OSError),
|
||||
):
|
||||
await izone_discovery.async_discover_controllers(hass, refresh=True)
|
||||
|
||||
|
||||
def test_controller_discovered_dispatches_signal_and_reschedules_idle_stop(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Discovered controller should dispatch signal and cancel prior idle-stop handle."""
|
||||
service = izone_discovery.DiscoveryService(hass)
|
||||
previous_handle = Mock()
|
||||
new_handle = Mock()
|
||||
service._idle_stop_handle = previous_handle
|
||||
controller = create_mock_controller("000000001", "192.0.2.1")
|
||||
|
||||
with (
|
||||
patch.object(hass.loop, "call_later", return_value=new_handle),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_dispatcher_send"
|
||||
) as mock_send,
|
||||
):
|
||||
service.controller_discovered(controller)
|
||||
|
||||
previous_handle.cancel.assert_called_once()
|
||||
assert service._idle_stop_handle is new_handle
|
||||
mock_send.assert_called_once_with(
|
||||
hass,
|
||||
izone_discovery.DISPATCH_CONTROLLER_DISCOVERED,
|
||||
controller,
|
||||
async_fire_time_changed(
|
||||
hass, utcnow() + DISCOVERY_SCAN_INTERVAL + timedelta(seconds=1)
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
mock_service.scan.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_is_ignored_or_excluded_uid_returns_true_for_yaml_exclude(
|
||||
async def test_endpoint_discovered_starts_config_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""UIDs listed in YAML exclude are treated as ignored/excluded."""
|
||||
"""Discovered endpoints surface as integration discovery flows."""
|
||||
mock_create, _mock_service = mock_pizone_create_discovery
|
||||
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
on_endpoint_discovered = mock_create.await_args.kwargs["on_endpoint_discovered"]
|
||||
on_endpoint_discovered(ControllerEndpoint(uid="000000001", host="192.0.2.1"))
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
progress = [
|
||||
flow
|
||||
for flow in hass.config_entries.flow.async_progress_by_handler(DOMAIN)
|
||||
if flow["context"]["source"] == config_entries.SOURCE_INTEGRATION_DISCOVERY
|
||||
]
|
||||
assert len(progress) == 1
|
||||
assert progress[0]["context"]["unique_id"] == "000000001"
|
||||
assert progress[0]["step_id"] == "confirm"
|
||||
|
||||
|
||||
async def test_endpoint_discovered_skips_yaml_excluded(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""YAML exclude suppresses integration discovery for that UID."""
|
||||
await async_load_yaml_exclude(hass, "000000009")
|
||||
mock_create, _mock_service = mock_pizone_create_discovery
|
||||
|
||||
assert izone_discovery._async_is_ignored_or_excluded_uid(hass, "000000009") is True
|
||||
with patch("homeassistant.helpers.discovery_flow.async_create_flow") as mock_flow:
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
mock_create.await_args.kwargs["on_endpoint_discovered"](
|
||||
ControllerEndpoint(uid="000000009", host="192.0.2.9")
|
||||
)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
mock_flow.assert_not_called()
|
||||
|
||||
|
||||
async def test_async_maybe_stop_returns_when_service_not_started(
|
||||
async def test_maybe_stop_keeps_discovery_for_loaded_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""No-op when maybe-stop is called without a discovery service instance."""
|
||||
await izone_discovery.async_maybe_stop_discovery_service(hass)
|
||||
"""Idle stop does not tear down discovery while a loaded entry remains."""
|
||||
_, mock_service = mock_pizone_create_discovery
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
source=config_entries.SOURCE_USER,
|
||||
data={CONF_HOST: "192.0.2.1"},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
entry.mock_state(hass, config_entries.ConfigEntryState.LOADED)
|
||||
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
mock_service.close.reset_mock()
|
||||
await izone_discovery.async_maybe_stop_discovery(hass)
|
||||
|
||||
mock_service.close.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_async_start_discovery_service_returns_existing_instance(
|
||||
async def test_maybe_stop_keeps_discovery_for_actionable_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""Starting discovery returns existing service when already running."""
|
||||
existing = Mock()
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = existing
|
||||
"""Idle stop keeps discovery while a user config flow is in progress."""
|
||||
_, mock_service = mock_pizone_create_discovery
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
mock_service.close.reset_mock()
|
||||
|
||||
disco = await izone_discovery.async_start_discovery_service(hass)
|
||||
with patch.object(
|
||||
hass.config_entries.flow,
|
||||
"async_progress_by_handler",
|
||||
return_value=[{"context": {"source": config_entries.SOURCE_USER}}],
|
||||
):
|
||||
await izone_discovery.async_maybe_stop_discovery(hass)
|
||||
|
||||
assert disco is existing
|
||||
mock_service.close.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_async_maybe_stop_stops_when_no_controllers_remain(
|
||||
@pytest.mark.parametrize(
|
||||
("entry_state", "expect_close"),
|
||||
[
|
||||
pytest.param(
|
||||
config_entries.ConfigEntryState.SETUP_RETRY,
|
||||
False,
|
||||
id="setup_retry",
|
||||
),
|
||||
pytest.param(
|
||||
config_entries.ConfigEntryState.SETUP_IN_PROGRESS,
|
||||
False,
|
||||
id="setup_in_progress",
|
||||
),
|
||||
pytest.param(
|
||||
config_entries.ConfigEntryState.SETUP_ERROR,
|
||||
True,
|
||||
id="setup_error",
|
||||
),
|
||||
pytest.param(
|
||||
config_entries.ConfigEntryState.NOT_LOADED,
|
||||
True,
|
||||
id="not_loaded",
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_maybe_stop_respects_entry_setup_state(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
entry_state: config_entries.ConfigEntryState,
|
||||
expect_close: bool,
|
||||
) -> None:
|
||||
"""Discovery stops when no controllers are tracked and nothing is actionable."""
|
||||
service = create_mock_discovery_service()
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
"""Keep discovery for in-flight/retry setup; stop for error or unloaded."""
|
||||
_, mock_service = mock_pizone_create_discovery
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
source=config_entries.SOURCE_USER,
|
||||
data={CONF_HOST: "192.0.2.1"},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
entry.mock_state(hass, entry_state)
|
||||
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
mock_service.close.reset_mock()
|
||||
await izone_discovery.async_maybe_stop_discovery(hass)
|
||||
|
||||
assert mock_service.close.await_count == int(expect_close)
|
||||
|
||||
|
||||
async def test_maybe_stop_closes_when_idle(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""Idle stop closes discovery when nothing actionable remains."""
|
||||
_, mock_service = mock_pizone_create_discovery
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
mock_service.close.reset_mock()
|
||||
|
||||
await izone_discovery.async_maybe_stop_discovery(hass)
|
||||
|
||||
mock_service.close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_stop_discovery_is_noop_when_not_started(hass: HomeAssistant) -> None:
|
||||
"""Stop is a no-op if discovery was never started."""
|
||||
await izone_discovery.async_stop_discovery(hass)
|
||||
|
||||
|
||||
async def test_ensure_discovery_propagates_oserror(hass: HomeAssistant) -> None:
|
||||
"""Bind failures from create_discovery propagate to the caller."""
|
||||
with (
|
||||
patch.object(
|
||||
hass.config_entries.flow,
|
||||
"async_progress_by_handler",
|
||||
return_value=[],
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_stop_discovery_service",
|
||||
new=AsyncMock(),
|
||||
) as mock_stop,
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=OSError("bind failed")),
|
||||
),
|
||||
pytest.raises(OSError, match="bind failed"),
|
||||
):
|
||||
await izone_discovery.async_maybe_stop_discovery_service(hass)
|
||||
await izone_discovery.async_ensure_discovery(hass)
|
||||
|
||||
mock_stop.assert_awaited_once_with(hass)
|
||||
assert DATA_DISCOVERY_SERVICE not in hass.data
|
||||
|
||||
|
||||
async def test_async_maybe_stop_keeps_running_when_controller_not_ignored(
|
||||
async def test_setup_runtime_error_from_discovery_retries(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Discovery remains active if at least one discovered controller is still actionable."""
|
||||
service = create_mock_discovery_service(create_mock_controller("000000001"))
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
"""Process-global discovery conflicts surface as SETUP_RETRY."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
data={CONF_HOST: "192.0.2.1"},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
hass.config_entries.flow,
|
||||
"async_progress_by_handler",
|
||||
return_value=[],
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.async_stop_discovery_service",
|
||||
new=AsyncMock(),
|
||||
) as mock_stop,
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=RuntimeError("already running")),
|
||||
),
|
||||
):
|
||||
await izone_discovery.async_maybe_stop_discovery_service(hass)
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
mock_stop.assert_not_awaited()
|
||||
service.async_schedule_idle_stop.assert_called_once()
|
||||
assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY
|
||||
assert DATA_DISCOVERY_SERVICE not in hass.data
|
||||
|
||||
|
||||
async def test_async_stop_discovery_service_returns_when_not_started(
|
||||
async def test_discover_all_endpoints(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""Stop is a no-op if discovery service was never started."""
|
||||
await izone_discovery.async_stop_discovery_service(hass)
|
||||
"""User/HomeKit scan returns endpoints from discover_all."""
|
||||
_, mock_service = mock_pizone_create_discovery
|
||||
endpoint = create_mock_endpoint("000000001", "192.0.2.1")
|
||||
mock_service.discover_all = AsyncMock(return_value=[endpoint])
|
||||
|
||||
result = await izone_discovery.async_discover_all_endpoints(hass)
|
||||
|
||||
assert result == {endpoint.uid: endpoint}
|
||||
mock_service.discover_all.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_async_stop_discovery_service_clears_stop_listener(
|
||||
async def test_discover_endpoint_by_uid(
|
||||
hass: HomeAssistant,
|
||||
mock_pizone_create_discovery: tuple[AsyncMock, Mock],
|
||||
) -> None:
|
||||
"""Stop should remove the stop listener when it exists."""
|
||||
service = Mock()
|
||||
stop_listener = Mock()
|
||||
service.remove_stop_listener = stop_listener
|
||||
service.remove_config_flow_listener = None
|
||||
service.async_cancel_idle_stop = Mock()
|
||||
service.pi_disco.close = AsyncMock()
|
||||
hass.data[DATA_DISCOVERY_SERVICE] = service
|
||||
"""Targeted lookup returns a single endpoint from discover_by_uid."""
|
||||
_, mock_service = mock_pizone_create_discovery
|
||||
endpoint = create_mock_endpoint("000000001", "192.0.2.1")
|
||||
mock_service.discover_by_uid = AsyncMock(return_value=endpoint)
|
||||
|
||||
await izone_discovery.async_stop_discovery_service(hass)
|
||||
result = await izone_discovery.async_discover_endpoint(hass, "000000001")
|
||||
|
||||
stop_listener.assert_called_once()
|
||||
assert service.remove_stop_listener is None
|
||||
assert result == endpoint
|
||||
mock_service.discover_by_uid.assert_awaited_once_with("000000001")
|
||||
|
||||
@@ -0,0 +1,551 @@
|
||||
"""Tests for iZone config entry setup and unload."""
|
||||
|
||||
from asyncio import CancelledError
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from pizone import ControllerCommandError, ControllerEndpoint, UnpairedBridgeError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.izone.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .conftest import async_load_yaml_exclude, create_mock_controller
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_setup_and_unload(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""Entry loads climate entities and unloads cleanly."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_discovery_service.create_controller.assert_awaited_once()
|
||||
mock_controller.refresh_all.assert_awaited()
|
||||
assert hass.states.get("climate.izone_controller_000000001") is not None
|
||||
|
||||
assert 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
|
||||
mock_controller.close.assert_awaited()
|
||||
mock_discovery_service.close.assert_awaited()
|
||||
|
||||
|
||||
async def test_setup_heals_legacy_domain_unique_id(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""Legacy unique_id=DOMAIN binds the sole discovered endpoint and loads."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DOMAIN,
|
||||
title="iZone Aircon",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_all = AsyncMock(
|
||||
return_value=[ControllerEndpoint(uid="000000001", host="192.0.2.1")]
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.unique_id == "000000001"
|
||||
assert entry.data[CONF_HOST] == "192.0.2.1"
|
||||
assert entry.title == "iZone 000000001"
|
||||
mock_discovery_service.create_controller.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_setup_heals_legacy_domain_unique_id_keeps_custom_title(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""Legacy rebind keeps a user-customised title instead of rewriting it."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DOMAIN,
|
||||
title="Living Room AC",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_all = AsyncMock(
|
||||
return_value=[ControllerEndpoint(uid="000000001", host="192.0.2.1")]
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.unique_id == "000000001"
|
||||
assert entry.title == "Living Room AC"
|
||||
|
||||
|
||||
async def test_setup_legacy_domain_unique_id_filters_yaml_excluded(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""YAML exclude narrows multiple discoveries to one eligible controller."""
|
||||
await async_load_yaml_exclude(hass, "000000002")
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DOMAIN,
|
||||
title="iZone Aircon",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_all = AsyncMock(
|
||||
return_value=[
|
||||
ControllerEndpoint(uid="000000001", host="192.0.2.1"),
|
||||
ControllerEndpoint(uid="000000002", host="192.0.2.2"),
|
||||
]
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.unique_id == "000000001"
|
||||
assert entry.data[CONF_HOST] == "192.0.2.1"
|
||||
|
||||
|
||||
async def test_setup_legacy_domain_unique_id_filters_already_configured(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""Already-configured UIDs are skipped when binding a legacy DOMAIN entry."""
|
||||
configured = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000002",
|
||||
data={CONF_HOST: "192.0.2.2"},
|
||||
version=2,
|
||||
)
|
||||
configured.add_to_hass(hass)
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DOMAIN,
|
||||
title="iZone Aircon",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_all = AsyncMock(
|
||||
return_value=[
|
||||
ControllerEndpoint(uid="000000001", host="192.0.2.1"),
|
||||
ControllerEndpoint(uid="000000002", host="192.0.2.2"),
|
||||
]
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.unique_id == "000000001"
|
||||
assert entry.data[CONF_HOST] == "192.0.2.1"
|
||||
|
||||
|
||||
async def test_migrate_then_heals_legacy_domain_unique_id(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""v1 migrate clears data, then setup heal rebinds UID and CONF_HOST."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=1,
|
||||
unique_id=DOMAIN,
|
||||
title="iZone Aircon",
|
||||
data={"host": "203.0.113.1"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_all = AsyncMock(
|
||||
return_value=[ControllerEndpoint(uid="000000001", host="192.0.2.1")]
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.version == 2
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.unique_id == "000000001"
|
||||
assert entry.data == {CONF_HOST: "192.0.2.1"}
|
||||
assert entry.title == "iZone 000000001"
|
||||
|
||||
|
||||
async def test_setup_legacy_domain_unique_id_no_eligible_retries(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
) -> None:
|
||||
"""Legacy unique_id=DOMAIN with no eligible endpoint leaves SETUP_RETRY."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DOMAIN,
|
||||
title="iZone Aircon",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_all = AsyncMock(return_value=[])
|
||||
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_legacy_domain_unique_id_multiple_eligible_fails(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
) -> None:
|
||||
"""Legacy unique_id=DOMAIN with multiple eligible endpoints is SETUP_ERROR."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DOMAIN,
|
||||
title="iZone Aircon",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_all = AsyncMock(
|
||||
return_value=[
|
||||
ControllerEndpoint(uid="000000001", host="192.0.2.1"),
|
||||
ControllerEndpoint(uid="000000002", host="192.0.2.2"),
|
||||
]
|
||||
)
|
||||
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
async def test_setup_heals_missing_host_via_discover_endpoint(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""Real UID with empty data recovers CONF_HOST via discover_by_uid."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_by_uid = AsyncMock(
|
||||
return_value=ControllerEndpoint(uid="000000001", host="192.0.2.1")
|
||||
)
|
||||
|
||||
assert await hass.config_entries.async_setup(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert entry.state is ConfigEntryState.LOADED
|
||||
assert entry.data[CONF_HOST] == "192.0.2.1"
|
||||
mock_discovery_service.create_controller.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_setup_missing_host_not_found_retries(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
) -> None:
|
||||
"""Real UID with empty data and no discovered endpoint leaves SETUP_RETRY."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
mock_discovery_service.discover_by_uid = AsyncMock(return_value=None)
|
||||
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
mock_discovery_service.create_controller.assert_not_awaited()
|
||||
|
||||
|
||||
async def test_setup_legacy_domain_unique_id_discovery_oserror_retries(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
) -> None:
|
||||
"""OSError while discovering for legacy DOMAIN unique_id leaves SETUP_RETRY."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id=DOMAIN,
|
||||
title="iZone Aircon",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.async_discover_all_endpoints",
|
||||
new=AsyncMock(side_effect=OSError("bind failed")),
|
||||
):
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_missing_host_discover_oserror_retries(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
) -> None:
|
||||
"""OSError while resolving host for a real UID leaves SETUP_RETRY."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
data={},
|
||||
version=2,
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.async_discover_endpoint",
|
||||
new=AsyncMock(side_effect=OSError("bind failed")),
|
||||
):
|
||||
assert not await hass.config_entries.async_setup(entry.entry_id)
|
||||
|
||||
assert entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("side_effect", "expected_state"),
|
||||
[
|
||||
(ConnectionError("offline"), ConfigEntryState.SETUP_RETRY),
|
||||
(UnpairedBridgeError("unpaired"), ConfigEntryState.SETUP_ERROR),
|
||||
(ControllerCommandError("rejected"), ConfigEntryState.SETUP_ERROR),
|
||||
],
|
||||
)
|
||||
async def test_setup_create_controller_errors(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
side_effect: Exception,
|
||||
expected_state: ConfigEntryState,
|
||||
) -> None:
|
||||
"""create_controller failures map to retry vs error entry states."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
mock_discovery_service.create_controller = AsyncMock(side_effect=side_effect)
|
||||
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
assert mock_config_entry.state is expected_state
|
||||
|
||||
|
||||
async def test_setup_discovery_bind_failure_retries(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""UDP bind failure during discovery start leaves the entry in SETUP_RETRY."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.aiohttp_client.async_get_clientsession",
|
||||
return_value=Mock(),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.izone.discovery.pizone.create_discovery",
|
||||
new=AsyncMock(side_effect=OSError("bind failed")),
|
||||
),
|
||||
):
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_setup_first_refresh_failure_closes_controller(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""A failed first refresh closes the controller and retries setup."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
mock_controller.refresh_all = AsyncMock(side_effect=ConnectionError("gone"))
|
||||
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
mock_controller.close.assert_awaited()
|
||||
|
||||
|
||||
async def test_setup_platform_failure_closes_controller(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""A failure during platform forward still releases the controller claim."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.config_entries.ConfigEntries.async_forward_entry_setups",
|
||||
side_effect=RuntimeError("platform boom"),
|
||||
):
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
mock_controller.close.assert_awaited()
|
||||
|
||||
|
||||
async def test_setup_cancelled_closes_controller(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""Cancellation after create_controller still closes it to release the UID claim."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.izone.IZoneCoordinator.async_config_entry_first_refresh",
|
||||
side_effect=CancelledError,
|
||||
):
|
||||
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
|
||||
mock_controller.close.assert_awaited()
|
||||
|
||||
|
||||
async def test_address_changed_updates_config_entry_host(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
) -> None:
|
||||
"""Library address-change callback persists the new host on the entry."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
on_address_changed = mock_discovery_service.create_controller.await_args.kwargs[
|
||||
"on_address_changed"
|
||||
]
|
||||
on_address_changed(ControllerEndpoint(uid="000000001", host="192.0.2.99"))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.data[CONF_HOST] == "192.0.2.99"
|
||||
|
||||
|
||||
async def test_address_changed_same_host_is_ignored(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
) -> None:
|
||||
"""Address callback with an unchanged host does not rewrite entry data."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
original = dict(mock_config_entry.data)
|
||||
on_address_changed = mock_discovery_service.create_controller.await_args.kwargs[
|
||||
"on_address_changed"
|
||||
]
|
||||
on_address_changed(ControllerEndpoint(uid="000000001", host="192.0.2.1"))
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert dict(mock_config_entry.data) == original
|
||||
|
||||
|
||||
async def test_last_unload_stops_shared_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
) -> None:
|
||||
"""Unloading the last loaded entry closes the shared discovery service."""
|
||||
first = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000001",
|
||||
data={CONF_HOST: "192.0.2.1"},
|
||||
entry_id="entry_1",
|
||||
version=2,
|
||||
)
|
||||
second = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
unique_id="000000002",
|
||||
data={CONF_HOST: "192.0.2.2"},
|
||||
entry_id="entry_2",
|
||||
version=2,
|
||||
)
|
||||
first_controller = create_mock_controller("000000001", "192.0.2.1")
|
||||
second_controller = create_mock_controller("000000002", "192.0.2.2")
|
||||
|
||||
async def _create_controller(uid: str, host: str, **kwargs: object) -> Mock:
|
||||
if uid == "000000001":
|
||||
return first_controller
|
||||
return second_controller
|
||||
|
||||
mock_discovery_service.create_controller = AsyncMock(side_effect=_create_controller)
|
||||
|
||||
first.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(first.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert first.state is ConfigEntryState.LOADED
|
||||
|
||||
second.add_to_hass(hass)
|
||||
assert await hass.config_entries.async_setup(second.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
assert second.state is ConfigEntryState.LOADED
|
||||
|
||||
mock_discovery_service.close.reset_mock()
|
||||
assert await hass.config_entries.async_unload(first.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_discovery_service.close.assert_not_awaited()
|
||||
|
||||
assert await hass.config_entries.async_unload(second.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_discovery_service.close.assert_awaited_once()
|
||||
|
||||
|
||||
async def test_setup_after_last_unload_recreates_discovery(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_create_discovery: AsyncMock,
|
||||
mock_discovery_service: Mock,
|
||||
mock_controller: Mock,
|
||||
) -> None:
|
||||
"""After the last unload stops discovery, a later setup starts it again."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_create_discovery.assert_awaited_once()
|
||||
|
||||
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
mock_discovery_service.close.assert_awaited_once()
|
||||
|
||||
mock_create_discovery.reset_mock()
|
||||
mock_discovery_service.close.reset_mock()
|
||||
mock_controller.close.reset_mock()
|
||||
|
||||
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
mock_create_discovery.assert_awaited_once()
|
||||
mock_discovery_service.create_controller.assert_awaited()
|
||||
Reference in New Issue
Block a user