Rewrite iZone runtime for python-izone 1.3.6 discovery API (#176751)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Penny Wood
2026-07-23 12:43:45 +02:00
committed by GitHub
co-authored by Cursor
parent 998bab5d96
commit 22693e4deb
15 changed files with 2376 additions and 1943 deletions
+2 -20
View File
@@ -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]
+128 -93
View File
@@ -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,
+420 -226
View File
@@ -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
+448 -249
View File
@@ -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")
+551
View File
@@ -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()