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
+94 -40
View File
@@ -1,17 +1,24 @@
"""Platform for the iZone AC."""
"""The iZone integration."""
import pizone
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_EXCLUDE, CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from . import discovery
from .const import DATA_CONFIG, DOMAIN
from .coordinator import IZoneConfigEntry, IZoneCoordinator
from .discovery import (
async_discover_all_endpoints,
async_discover_endpoint,
async_ensure_discovery,
async_stop_discovery,
yaml_excluded_uids,
)
PLATFORMS = [Platform.CLIMATE]
@@ -43,37 +50,24 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: IZoneConfigEntry) -> bool:
"""Set up from a config entry."""
try:
await discovery.async_start_discovery_service(hass)
except OSError as err:
discovery = await async_ensure_discovery(hass)
except (OSError, RuntimeError) as err:
raise ConfigEntryNotReady("iZone discovery service failed to start") from err
# Heal legacy / host-less entries here (not in migrate) so ConfigEntryNotReady
# can retry. Upstream pairs migrate→data={} with this setup-time rebind.
if entry.unique_id == DOMAIN:
# Legacy v1-migrated entry: resolve to a real controller UID at setup time.
#
# Doing this work here (rather than in async_migrate_entry) is intentional:
# ConfigEntryNotReady raised from async_migrate_entry becomes a permanent
# MIGRATION_ERROR — HA does not retry failed migrations. Raising it from
# async_setup_entry correctly schedules a retry on the next HA start.
#
# Raising ConfigEntryError (multiple eligible controllers) is permanent either
# way; those controllers are not lost — the discovery fan-out will surface them
# as individual flows once HA restarts. This is not a breaking change: a v1
# entry with multiple controllers was already broken before this PR.
# async_discover_controllers reuses the already-running service (idempotent
# start), so OSError here means fetch_controllers() itself failed — rare but
# kept as a defensive guard.
try:
controllers = await discovery.async_discover_controllers(hass)
endpoints = await async_discover_all_endpoints(hass)
except OSError as err:
raise ConfigEntryNotReady(
"iZone discovery failed while resolving legacy config entry"
) from err
conf: ConfigType | None = hass.data.get(DATA_CONFIG)
excluded_uids: set[str] = set(conf.get(CONF_EXCLUDE, [])) if conf else set()
excluded_uids = yaml_excluded_uids(hass)
configured_uids = {
config_entry.unique_id
for config_entry in hass.config_entries.async_entries(DOMAIN)
@@ -81,10 +75,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
and config_entry.unique_id not in (None, DOMAIN)
}
eligible = [
controller
for controller in controllers.values()
if controller.device_uid not in excluded_uids
and controller.device_uid not in configured_uids
endpoint
for endpoint in endpoints.values()
if endpoint.uid not in excluded_uids and endpoint.uid not in configured_uids
]
if not eligible:
@@ -98,28 +91,82 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"delete this entry and re-add each controller individually"
)
controller = eligible[0]
endpoint = eligible[0]
new_title = (
f"iZone {controller.device_uid}"
if entry.title == "iZone Aircon"
else entry.title
f"iZone {endpoint.uid}" if entry.title == "iZone Aircon" else entry.title
)
hass.config_entries.async_update_entry(
entry,
unique_id=controller.device_uid,
unique_id=endpoint.uid,
title=new_title,
data={CONF_HOST: controller.device_ip},
data={CONF_HOST: endpoint.host},
)
elif CONF_HOST not in entry.data:
uid = entry.unique_id
if not isinstance(uid, str):
raise ConfigEntryError("iZone config entry is missing unique_id")
try:
resolved = await async_discover_endpoint(hass, uid)
except OSError as err:
raise ConfigEntryNotReady(
"iZone discovery failed while resolving config entry host"
) from err
if resolved is None:
raise ConfigEntryNotReady(f"No iZone controller found for unique_id {uid}")
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_HOST: resolved.host},
)
uid = entry.unique_id
if not isinstance(uid, str):
raise ConfigEntryError("iZone config entry is missing unique_id")
if CONF_HOST not in entry.data:
raise ConfigEntryError("iZone config entry is missing host")
host: str = entry.data[CONF_HOST]
@callback
def _async_on_address_changed(endpoint: pizone.ControllerEndpoint) -> None:
if endpoint.host == entry.data[CONF_HOST]:
return
hass.config_entries.async_update_entry(
entry,
data={**entry.data, CONF_HOST: endpoint.host},
)
try:
controller = await discovery.create_controller(
uid,
host,
on_address_changed=_async_on_address_changed,
)
except pizone.UnpairedBridgeError as err:
raise ConfigEntryError(
"iZone bridge is not paired with an air conditioner"
) from err
except ConnectionError as err:
raise ConfigEntryNotReady(
f"Unable to connect to iZone controller at {host}"
) from err
except pizone.ControllerCommandError as err:
raise ConfigEntryError(f"iZone controller at {host} rejected setup") from err
entry.async_on_unload(controller.close)
coordinator = IZoneCoordinator(hass, entry, controller)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: IZoneConfigEntry) -> bool:
"""Migrate old config entry schema to the current version."""
if entry.version == 1:
# Clear legacy data only — UID and title binding is deferred to
# async_setup_entry where ConfigEntryNotReady retry semantics work correctly.
# Clear legacy data only.
# Raising ConfigEntryNotReady from async_migrate_entry would permanently land
# the entry in MIGRATION_ERROR with no retry path.
hass.config_entries.async_update_entry(entry, version=2, data={})
@@ -127,6 +174,13 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return False
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload the config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_unload_entry(hass: HomeAssistant, entry: IZoneConfigEntry) -> bool:
"""Unload the config entry and release the controller."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
coordinator = entry.runtime_data
await coordinator.async_shutdown()
await coordinator.controller.close()
if not hass.config_entries.async_loaded_entries(DOMAIN):
await async_stop_discovery(hass)
return unload_ok
+64 -247
View File
@@ -1,8 +1,7 @@
"""Support for the iZone HVAC."""
from collections.abc import Callable, Mapping
import logging
from typing import Any, Concatenate, override
from collections.abc import Mapping
from typing import Any, override
from pizone import Controller, Zone
import voluptuous as vol
@@ -19,35 +18,22 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_TEMPERATURE,
PRECISION_HALVES,
PRECISION_TENTHS,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_platform
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.temperature import display_temp as show_temp
from homeassistant.helpers.typing import VolDictType
from .const import (
DATA_DISCOVERY_SERVICE,
DISPATCH_CONTROLLER_DISCONNECTED,
DISPATCH_CONTROLLER_DISCOVERED,
DISPATCH_CONTROLLER_RECONNECTED,
DISPATCH_CONTROLLER_UPDATE,
DISPATCH_ZONE_UPDATE,
DOMAIN,
TIMEOUT_DISCOVERY,
)
type _FuncType[_T, **_P, _R] = Callable[Concatenate[_T, _P], _R]
_LOGGER = logging.getLogger(__name__)
from .const import DOMAIN
from .coordinator import IZoneConfigEntry, IZoneCoordinator
from .entity import IZoneCoordinatorEntity
_IZONE_FAN_TO_HA = {
Controller.Fan.LOW: FAN_LOW,
@@ -71,39 +57,13 @@ IZONE_SERVICE_AIRFLOW_SCHEMA: VolDictType = {
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigEntry,
entry: IZoneConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize an IZone Controller."""
disco = hass.data[DATA_DISCOVERY_SERVICE]
entry_unique_id = config.unique_id
initialized = False
@callback
def init_controller(ctrl: Controller):
"""Register the controller device and the containing zones."""
nonlocal initialized
if entry_unique_id and ctrl.device_uid != entry_unique_id:
return
if initialized:
return
initialized = True
device = ControllerDevice(ctrl)
async_add_entities([device])
async_add_entities(device.zones.values())
_LOGGER.debug("Controller UID=%s initialized", ctrl.device_uid)
# Fetch the controller for this entry, waiting for discovery if it hasn't been found yet
if ctrl := await disco.pi_disco.fetch_controller(
entry_unique_id, timeout=TIMEOUT_DISCOVERY
):
init_controller(ctrl)
# connect to register any further components
config.async_on_unload(
async_dispatcher_connect(hass, DISPATCH_CONTROLLER_DISCOVERED, init_controller)
)
"""Set up climate entities from the entry coordinator."""
coordinator = entry.runtime_data
controller_device = ControllerDevice(coordinator)
async_add_entities([controller_device, *controller_device.zones.values()])
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -118,36 +78,19 @@ async def async_setup_entry(
)
def _return_on_connection_error[_DeviceT: ControllerDevice | ZoneDevice, **_P, _R, _T](
ret: _T = None, # type: ignore[assignment]
) -> Callable[[_FuncType[_DeviceT, _P, _R]], _FuncType[_DeviceT, _P, _R | _T]]:
def wrap(func: _FuncType[_DeviceT, _P, _R]) -> _FuncType[_DeviceT, _P, _R | _T]:
def wrapped_f(self: _DeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R | _T:
if not self.available:
return ret
try:
return func(self, *args, **kwargs)
except ConnectionError:
return ret
return wrapped_f
return wrap
class ControllerDevice(ClimateEntity):
class ControllerDevice(IZoneCoordinatorEntity, ClimateEntity):
"""Representation of iZone Controller."""
_attr_precision = PRECISION_TENTHS
_attr_should_poll = False
_attr_temperature_unit = UnitOfTemperature.CELSIUS
_attr_has_entity_name = True
_attr_name = None
_attr_target_temperature_step = 0.5
def __init__(self, controller: Controller) -> None:
def __init__(self, coordinator: IZoneCoordinator) -> None:
"""Initialise ControllerDevice."""
self._controller = controller
super().__init__(coordinator)
controller = coordinator.controller
self._attr_supported_features = (
ClimateEntityFeature.FAN_MODE
@@ -200,77 +143,7 @@ class ControllerDevice(ClimateEntity):
# Create the zones
self.zones = {}
for zone in controller.zones:
self.zones[zone] = ZoneDevice(self, zone)
@override
async def async_added_to_hass(self) -> None:
"""Call on adding to hass."""
# Register for connect/disconnect/update events
@callback
def controller_disconnected(ctrl: Controller, ex: Exception) -> None:
"""Disconnected from controller."""
if ctrl is not self._controller:
return
self.set_available(False, ex)
self.async_on_remove(
async_dispatcher_connect(
self.hass, DISPATCH_CONTROLLER_DISCONNECTED, controller_disconnected
)
)
@callback
def controller_reconnected(ctrl: Controller) -> None:
"""Reconnected to controller."""
if ctrl is not self._controller:
return
self.set_available(True)
self.async_on_remove(
async_dispatcher_connect(
self.hass, DISPATCH_CONTROLLER_RECONNECTED, controller_reconnected
)
)
@callback
def controller_update(ctrl: Controller) -> None:
"""Handle controller data updates."""
if ctrl is not self._controller:
return
if not self.available:
return
self.async_write_ha_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass, DISPATCH_CONTROLLER_UPDATE, controller_update
)
)
@callback
def set_available(self, available: bool, ex: Exception | None = None) -> None:
"""Set availability for the controller.
Also sets zone availability as they follow the same availability.
"""
if self.available == available:
return
if available:
_LOGGER.warning("Reconnected controller %s ", self._controller.device_uid)
else:
_LOGGER.warning(
"Controller %s disconnected due to exception: %s",
self._controller.device_uid,
ex,
)
self._attr_available = available
self.async_write_ha_state()
for zone in self.zones.values():
if zone.hass is not None:
zone.async_schedule_update_ha_state()
self.zones[zone] = ZoneDevice(coordinator, self, zone)
@property
@override
@@ -285,11 +158,11 @@ class ControllerDevice(ClimateEntity):
),
"temp_setpoint": show_temp(
self.hass,
self._controller.temp_setpoint,
self.controller.temp_setpoint,
self.temperature_unit,
PRECISION_HALVES,
),
"control_zone": self._controller.zone_ctrl,
"control_zone": self.controller.zone_ctrl,
"control_zone_name": self.control_zone_name,
# Feature ClimateEntityFeature.TARGET_TEMPERATURE controls both displaying
# target temp & setting it as the feature is turned off for zone control,
@@ -306,9 +179,9 @@ class ControllerDevice(ClimateEntity):
@override
def hvac_mode(self) -> HVACMode:
"""Return current operation ie. heat, cool, idle."""
if not self._controller.is_on:
if not self.controller.is_on:
return HVACMode.OFF
if (mode := self._controller.mode) is Controller.Mode.FREE_AIR:
if (mode := self.controller.mode) is Controller.Mode.FREE_AIR:
return HVACMode.FAN_ONLY
for key, value in self._state_to_pizone.items():
if value is mode:
@@ -316,38 +189,34 @@ class ControllerDevice(ClimateEntity):
raise RuntimeError("Should be unreachable")
@property
@_return_on_connection_error([])
@override
def hvac_modes(self) -> list[HVACMode]:
"""Return the list of available operation modes."""
if self._controller.free_air:
if self.controller.free_air:
return [HVACMode.OFF, HVACMode.FAN_ONLY]
return [HVACMode.OFF, *self._state_to_pizone]
@property
@_return_on_connection_error(PRESET_NONE)
@override
def preset_mode(self) -> str:
"""Eco mode is external air."""
return PRESET_ECO if self._controller.free_air else PRESET_NONE
return PRESET_ECO if self.controller.free_air else PRESET_NONE
@property
@_return_on_connection_error([PRESET_NONE])
@override
def preset_modes(self) -> list[str]:
"""Available preset modes, normal or eco."""
if self._controller.free_air_enabled:
if self.controller.free_air_enabled:
return [PRESET_NONE, PRESET_ECO]
return [PRESET_NONE]
@property
@_return_on_connection_error()
@override
def current_temperature(self) -> float | None:
"""Return the current temperature."""
if self._controller.mode is Controller.Mode.FREE_AIR:
return self._controller.temp_supply
return self._controller.temp_return
if self.controller.mode is Controller.Mode.FREE_AIR:
return self.controller.temp_supply
return self.controller.temp_return
@property
def control_zone_name(self):
@@ -357,7 +226,7 @@ class ControllerDevice(ClimateEntity):
"""
if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE:
return None
zone_ctrl = self._controller.zone_ctrl
zone_ctrl = self.controller.zone_ctrl
zone = next((z for z in self.zones.values() if z.zone_index == zone_ctrl), None)
if zone is None:
return None
@@ -371,14 +240,13 @@ class ControllerDevice(ClimateEntity):
"""
if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE:
return None
zone_ctrl = self._controller.zone_ctrl
zone_ctrl = self.controller.zone_ctrl
zone = next((z for z in self.zones.values() if z.zone_index == zone_ctrl), None)
if zone is None:
return None
return zone.target_temperature
@property
@_return_on_connection_error()
@override
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach.
@@ -386,19 +254,19 @@ class ControllerDevice(ClimateEntity):
Either from control zone or master unit.
"""
if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE:
return self._controller.temp_setpoint
return self.controller.temp_setpoint
return self.control_zone_setpoint
@property
def supply_temperature(self) -> float | None:
"""Return the current supply, or in duct, temperature."""
return self._controller.temp_supply
return self.controller.temp_supply
@property
@override
def fan_mode(self) -> str | None:
"""Return the fan setting."""
return _IZONE_FAN_TO_HA[self._controller.fan]
return _IZONE_FAN_TO_HA[self.controller.fan]
@property
@override
@@ -407,27 +275,16 @@ class ControllerDevice(ClimateEntity):
return list(self._fan_to_pizone)
@property
@_return_on_connection_error(0.0)
@override
def min_temp(self) -> float:
"""Return the minimum temperature."""
return self._controller.temp_min
return self.controller.temp_min
@property
@_return_on_connection_error(50.0)
@override
def max_temp(self) -> float:
"""Return the maximum temperature."""
return self._controller.temp_max
async def wrap_and_catch(self, coro):
"""Catch any connection errors and set unavailable."""
try:
await coro
except ConnectionError as ex:
self.set_available(False, ex)
else:
self.set_available(True)
return self.controller.temp_max
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
@@ -436,45 +293,44 @@ class ControllerDevice(ClimateEntity):
self.async_schedule_update_ha_state(True)
return
if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
await self.wrap_and_catch(self._controller.set_temp_setpoint(temp))
await self._async_run_and_update(self.controller.set_temp_setpoint(temp))
@override
async def async_set_fan_mode(self, fan_mode: str) -> None:
"""Set new target fan mode."""
fan = self._fan_to_pizone[fan_mode]
await self.wrap_and_catch(self._controller.set_fan(fan))
await self._async_run_and_update(self.controller.set_fan(fan))
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
if hvac_mode == HVACMode.OFF:
await self.wrap_and_catch(self._controller.set_on(False))
await self._async_run_and_update(self.controller.set_on(False))
return
if not self._controller.is_on:
await self.wrap_and_catch(self._controller.set_on(True))
if self._controller.free_air:
if not self.controller.is_on:
await self._async_run_and_update(self.controller.set_on(True))
if self.controller.free_air:
return
mode = self._state_to_pizone[hvac_mode]
await self.wrap_and_catch(self._controller.set_mode(mode))
await self._async_run_and_update(self.controller.set_mode(mode))
@override
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode."""
await self.wrap_and_catch(
self._controller.set_free_air(preset_mode == PRESET_ECO)
await self._async_run_and_update(
self.controller.set_free_air(preset_mode == PRESET_ECO)
)
@override
async def async_turn_on(self) -> None:
"""Turn the entity on."""
await self.wrap_and_catch(self._controller.set_on(True))
await self._async_run_and_update(self.controller.set_on(True))
class ZoneDevice(ClimateEntity):
class ZoneDevice(IZoneCoordinatorEntity, ClimateEntity):
"""Representation of iZone Zone."""
_attr_precision = PRECISION_TENTHS
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
_attr_temperature_unit = UnitOfTemperature.CELSIUS
@@ -483,9 +339,14 @@ class ZoneDevice(ClimateEntity):
ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON
)
def __init__(self, controller: ControllerDevice, zone: Zone) -> None:
def __init__(
self,
coordinator: IZoneCoordinator,
controller_entity: ControllerDevice,
zone: Zone,
) -> None:
"""Initialise ZoneDevice."""
self._controller = controller
super().__init__(coordinator)
self._zone = zone
if zone.type is not Zone.Type.AUTO:
@@ -500,59 +361,20 @@ class ZoneDevice(ClimateEntity):
HVACMode.HEAT_COOL: Zone.Mode.AUTO,
}
self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE
self._attr_unique_id = f"{controller.unique_id}_z{zone.index + 1}"
assert controller.unique_id
self._attr_unique_id = f"{controller_entity.unique_id}_z{zone.index + 1}"
assert controller_entity.unique_id
self._attr_device_info = DeviceInfo(
identifiers={
(DOMAIN, controller.unique_id, zone.index) # type:ignore[arg-type]
(DOMAIN, controller_entity.unique_id, zone.index) # type:ignore[arg-type]
},
manufacturer="IZone",
model=zone.type.name.title(),
name=zone.name.title(),
via_device=(DOMAIN, controller.unique_id),
)
@override
async def async_added_to_hass(self) -> None:
"""Call on adding to hass."""
@callback
def controller_update(ctrl: Controller) -> None:
"""Handle controller data updates."""
if ctrl.device_uid != self._controller.unique_id:
return
if not self.available:
return
self.async_write_ha_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass, DISPATCH_CONTROLLER_UPDATE, controller_update
)
)
@callback
def zone_update(ctrl: Controller, zone: Zone) -> None:
"""Handle zone data updates."""
if zone is not self._zone:
return
if not self.available:
return
self.async_write_ha_state()
self.async_on_remove(
async_dispatcher_connect(self.hass, DISPATCH_ZONE_UPDATE, zone_update)
via_device=(DOMAIN, controller_entity.unique_id),
)
@property
@override
def available(self) -> bool:
"""Return True if entity is available."""
return self._controller.available
@property
@_return_on_connection_error(ClimateEntityFeature(0))
@override
def supported_features(self) -> ClimateEntityFeature:
"""Return the list of supported features."""
if self._zone.mode is Zone.Mode.AUTO:
@@ -593,13 +415,13 @@ class ZoneDevice(ClimateEntity):
@override
def min_temp(self) -> float:
"""Return the minimum temperature."""
return self._controller.min_temp
return self.controller.temp_min
@property
@override
def max_temp(self) -> float:
"""Return the maximum temperature."""
return self._controller.max_temp
return self.controller.temp_max
@property
def airflow_min(self):
@@ -613,17 +435,15 @@ class ZoneDevice(ClimateEntity):
async def async_set_airflow_min(self, **kwargs):
"""Set new airflow minimum."""
await self._controller.wrap_and_catch(
await self._async_run_and_update(
self._zone.set_airflow_min(int(kwargs[ATTR_AIRFLOW]))
)
self.async_write_ha_state()
async def async_set_airflow_max(self, **kwargs):
"""Set new airflow maximum."""
await self._controller.wrap_and_catch(
await self._async_run_and_update(
self._zone.set_airflow_max(int(kwargs[ATTR_AIRFLOW]))
)
self.async_write_ha_state()
@override
async def async_set_temperature(self, **kwargs: Any) -> None:
@@ -631,14 +451,13 @@ class ZoneDevice(ClimateEntity):
if self._zone.mode is not Zone.Mode.AUTO:
return
if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None:
await self._controller.wrap_and_catch(self._zone.set_temp_setpoint(temp))
await self._async_run_and_update(self._zone.set_temp_setpoint(temp))
@override
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:
"""Set new target operation mode."""
mode = self._state_to_pizone[hvac_mode]
await self._controller.wrap_and_catch(self._zone.set_mode(mode))
self.async_write_ha_state()
await self._async_run_and_update(self._zone.set_mode(mode))
@property
def is_on(self) -> bool:
@@ -649,16 +468,14 @@ class ZoneDevice(ClimateEntity):
async def async_turn_on(self) -> None:
"""Turn device on (open zone)."""
if self._zone.type is Zone.Type.AUTO:
await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.AUTO))
await self._async_run_and_update(self._zone.set_mode(Zone.Mode.AUTO))
else:
await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.OPEN))
self.async_write_ha_state()
await self._async_run_and_update(self._zone.set_mode(Zone.Mode.OPEN))
@override
async def async_turn_off(self) -> None:
"""Turn device off (close zone)."""
await self._controller.wrap_and_catch(self._zone.set_mode(Zone.Mode.CLOSE))
self.async_write_ha_state()
await self._async_run_and_update(self._zone.set_mode(Zone.Mode.CLOSE))
@property
def zone_index(self):
+72 -99
View File
@@ -42,7 +42,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 2
_user_discovered_controllers: list[pizone.Controller] | None = None
_user_discovered_endpoints: list[pizone.ControllerEndpoint] | None = None
_discovered_controller_ip: str | None = None
@override
@@ -73,7 +73,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_in_progress")
try:
await izone_discovery.async_start_discovery_service(self.hass)
await izone_discovery.async_ensure_discovery(self.hass)
except OSError:
_LOGGER.debug("Unable to start iZone discovery from import", exc_info=True)
return self.async_abort(reason="discovery_failed")
@@ -90,8 +90,7 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
"""User-started flow: offer configuration choices for discovered controllers.
Discovery is started if not yet running, then a fresh discovery cycle is triggered
and this step waits briefly for replies. The pizone library's built-in coalescing
avoids redundant broadcasts when discovery was just started.
and this step waits briefly for replies.
While this interactive flow is active, runtime integration discovery remains
blocked by ``_async_blocks_runtime_integration_discovery`` to avoid UI races.
@@ -101,55 +100,50 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="already_in_progress")
try:
controllers = await izone_discovery.async_discover_controllers(
self.hass, refresh=True
)
endpoints = await izone_discovery.async_discover_all_endpoints(self.hass)
except OSError:
_LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
return self.async_abort(reason="discovery_failed")
if not controllers:
if not endpoints:
_LOGGER.debug("No controllers found")
return self.async_abort(reason="no_devices_found")
self._user_discovered_controllers = self._async_get_unconfigured_controllers(
controllers
self._user_discovered_endpoints = self._async_get_unconfigured_endpoints(
endpoints
)
if not self._user_discovered_controllers:
if not self._user_discovered_endpoints:
return self.async_abort(reason="already_configured")
if len(self._user_discovered_controllers) > 1:
if len(self._user_discovered_endpoints) > 1:
return await self.async_step_select_controller()
sole = self._user_discovered_controllers[0]
await self.async_set_unique_id(sole.device_uid)
self._discovered_controller_ip = sole.device_ip
sole = self._user_discovered_endpoints[0]
await self.async_set_unique_id(sole.uid)
self._discovered_controller_ip = sole.host
return await self.async_step_confirm()
async def async_step_select_controller(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Choose one unconfigured controller after broadcast discovery."""
if not self._user_discovered_controllers:
if not self._user_discovered_endpoints:
return self.async_abort(reason="no_devices_found")
by_uid = {
controller.device_uid: controller
for controller in self._user_discovered_controllers
endpoint.uid: endpoint for endpoint in self._user_discovered_endpoints
}
selection_schema = vol.Schema(
{
vol.Required(
SELECTED_CONTROLLER_UID,
default=self._user_discovered_controllers[0].device_uid,
default=self._user_discovered_endpoints[0].uid,
): SelectSelector(
SelectSelectorConfig(
options=[
SelectOptionDict(
value=controller.device_uid,
label=(
f"{controller.device_uid} ({controller.device_ip})"
),
value=endpoint.uid,
label=f"{endpoint.uid} ({endpoint.host})",
)
for controller in self._user_discovered_controllers
for endpoint in self._user_discovered_endpoints
],
mode=SelectSelectorMode.DROPDOWN,
)
@@ -162,20 +156,20 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
if (primary := by_uid.get(selected_uid)) is None:
return self.async_abort(reason="no_devices_found")
for ctrl in self._user_discovered_controllers:
if ctrl.device_uid == primary.device_uid:
for endpoint in self._user_discovered_endpoints:
if endpoint.uid == primary.uid:
continue
# Using integration_discovery lets HA's deduplication guard prevent stacking
# flows for UIDs already in progress or already configured.
self._async_schedule_integration_discovery_flow(
ctrl.device_uid,
ctrl.device_ip,
endpoint.uid,
endpoint.host,
)
return await self._async_create_controller_entry(primary)
controllers_lines = "\n".join(
f"- {controller.device_uid} ({controller.device_ip})"
for controller in self._user_discovered_controllers
f"- {endpoint.uid} ({endpoint.host})"
for endpoint in self._user_discovered_endpoints
)
return self.async_show_form(
step_id="select_controller",
@@ -206,25 +200,26 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
# A HomeKit advertisement implies a specific UID is on the LAN. Wait for it.
try:
controllers = await izone_discovery.async_discover_controllers(
self.hass,
refresh=True,
wait_for_uid=device_uid,
)
endpoints = await izone_discovery.async_discover_all_endpoints(self.hass)
endpoint = endpoints.get(device_uid)
if endpoint is None:
endpoint = await izone_discovery.async_discover_endpoint(
self.hass, device_uid
)
if endpoint is None:
return self.async_abort(reason="no_devices_found")
endpoints = {**endpoints, endpoint.uid: endpoint}
except OSError:
_LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
return self.async_abort(reason="discovery_failed")
controller = controllers.get(device_uid)
if controller is None:
return self.async_abort(reason="no_devices_found")
self._discovered_controller_ip = controller.device_ip
self._discovered_controller_ip = endpoint.host
# Re-check after awaiting discovery to catch mid-flight configuration.
self._abort_if_unique_id_configured()
self._async_fan_out_discovered_controllers(
controllers.values(),
self._async_fan_out_discovered_endpoints(
endpoints.values(),
selected_uid=device_uid,
)
@@ -249,14 +244,14 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm adding a controller found via HomeKit or manual host."""
"""Confirm adding a controller found via HomeKit or discovery."""
controller_uid = self.unique_id
host = self._discovered_controller_ip
assert isinstance(controller_uid, str)
assert controller_uid
assert host is not None
if user_input is None:
controller_uid = self.unique_id
host = self._discovered_controller_ip
assert isinstance(controller_uid, str)
assert controller_uid
assert host is not None
host_str = str(host)
self.context["title_placeholders"] = {
"name": self._entry_title(controller_uid),
}
@@ -264,31 +259,12 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="confirm",
description_placeholders={
"controller_uid": controller_uid,
"host": host_str,
"host": str(host),
},
)
try:
controllers = await izone_discovery.async_discover_controllers(self.hass)
except OSError:
_LOGGER.debug("Unable to start iZone discovery service", exc_info=True)
return self.async_abort(reason="discovery_failed")
if not controllers:
_LOGGER.debug("No controllers found")
return self.async_abort(reason="no_devices_found")
uid = self.unique_id
assert isinstance(uid, str)
controller = controllers.get(uid)
if controller is None:
_LOGGER.debug(
"Discovered controller UID %s was not found during confirmation",
uid,
)
return self.async_abort(reason="no_devices_found")
return await self._async_create_controller_entry(
controller,
pizone.ControllerEndpoint(uid=controller_uid, host=str(host))
)
# -- Private helpers
@@ -317,52 +293,52 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
@staticmethod
def _filter_yaml_exclude(
hass: HomeAssistant, controllers: dict[str, pizone.Controller]
) -> dict[str, pizone.Controller]:
hass: HomeAssistant, endpoints: dict[str, pizone.ControllerEndpoint]
) -> dict[str, pizone.ControllerEndpoint]:
"""Remove UIDs listed in deprecated YAML ``exclude``."""
excluded = izone_discovery.yaml_excluded_uids(hass)
if not excluded:
return controllers
return endpoints
return {
uid: ctrl
for uid, ctrl in controllers.items()
if ctrl.device_uid not in excluded
uid: endpoint
for uid, endpoint in endpoints.items()
if endpoint.uid not in excluded
}
@callback
def _async_get_unconfigured_controllers(
self, controllers: dict[str, pizone.Controller]
) -> list[pizone.Controller]:
"""Return sorted unconfigured controllers for the interactive user flow."""
controllers = self._filter_yaml_exclude(self.hass, controllers)
def _async_get_unconfigured_endpoints(
self, endpoints: dict[str, pizone.ControllerEndpoint]
) -> list[pizone.ControllerEndpoint]:
"""Return sorted unconfigured endpoints for the interactive user flow."""
endpoints = self._filter_yaml_exclude(self.hass, endpoints)
# include_ignore=True ensures controllers whose entries have been explicitly
# ignored by the user (SOURCE_IGNORE) are not re-offered as configurable.
configured_uids = self._async_current_ids(include_ignore=True)
return sorted(
(
controller
for controller in controllers.values()
if controller.device_uid not in configured_uids
endpoint
for endpoint in endpoints.values()
if endpoint.uid not in configured_uids
),
key=lambda controller: (controller.device_uid, controller.device_ip),
key=lambda endpoint: (endpoint.uid, endpoint.host),
)
async def _async_create_controller_entry(
self,
controller: pizone.Controller,
endpoint: pizone.ControllerEndpoint,
) -> ConfigFlowResult:
"""Create the config entry for a chosen :class:`pizone.Controller` instance."""
await self.async_set_unique_id(controller.device_uid)
"""Create the config entry for a chosen discovered endpoint."""
await self.async_set_unique_id(endpoint.uid)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=self._entry_title(controller.device_uid),
data={CONF_HOST: controller.device_ip},
title=self._entry_title(endpoint.uid),
data={CONF_HOST: endpoint.host},
)
@callback
def _async_fan_out_discovered_controllers(
def _async_fan_out_discovered_endpoints(
self,
controllers: Iterable[pizone.Controller],
endpoints: Iterable[pizone.ControllerEndpoint],
*,
selected_uid: str,
) -> None:
@@ -372,15 +348,12 @@ class IZoneConfigFlow(ConfigFlow, domain=DOMAIN):
flow["context"].get("unique_id")
for flow in self._async_in_progress(include_uninitialized=True)
}
for candidate in controllers:
if candidate.device_uid == selected_uid:
for candidate in endpoints:
if candidate.uid == selected_uid:
continue
if (
candidate.device_uid in current_ids
or candidate.device_uid in in_progress_ids
):
if candidate.uid in current_ids or candidate.uid in in_progress_ids:
continue
self._async_schedule_integration_discovery_flow(
candidate.device_uid,
candidate.device_ip,
candidate.uid,
candidate.host,
)
+4 -7
View File
@@ -1,15 +1,12 @@
"""Constants used by the izone component."""
from datetime import timedelta
DOMAIN = "izone"
DATA_DISCOVERY_SERVICE = "izone_discovery"
DATA_CONFIG = "izone_config"
DISPATCH_CONTROLLER_DISCOVERED = "izone_controller_discovered"
DISPATCH_CONTROLLER_DISCONNECTED = "izone_controller_disconnected"
DISPATCH_CONTROLLER_RECONNECTED = "izone_controller_reconnected"
DISPATCH_CONTROLLER_UPDATE = "izone_controller_update"
DISPATCH_ZONE_UPDATE = "izone_zone_update"
TIMEOUT_DISCOVERY = 5
DISCOVERY_IDLE_SECONDS = 4 * TIMEOUT_DISCOVERY
# Match legacy pizone DISCOVERY_SLEEP (~5 min) for new-device hunt cadence.
DISCOVERY_SCAN_INTERVAL = timedelta(minutes=5)
@@ -0,0 +1,60 @@
"""DataUpdateCoordinator for a single iZone controller."""
from datetime import timedelta
import logging
from typing import override
import pizone
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
# Match legacy pizone Controller._poll_loop cadence.
UPDATE_INTERVAL = timedelta(seconds=25)
type IZoneConfigEntry = ConfigEntry[IZoneCoordinator]
class IZoneCoordinator(DataUpdateCoordinator[pizone.Controller]):
"""Refresh one controller via ``refresh_all`` only.
DHCP / moved-host recovery stays in pizone (HTTP-fail scan nudge). After
local commands, entities should call ``async_set_updated_data`` rather than
requesting a full refresh.
"""
config_entry: IZoneConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: IZoneConfigEntry,
controller: pizone.Controller,
) -> None:
"""Initialize the coordinator for *controller*."""
super().__init__(
hass,
_LOGGER,
config_entry=entry,
name=f"{DOMAIN} {controller.device_uid}",
update_interval=UPDATE_INTERVAL,
)
self.controller = controller
@override
async def _async_update_data(self) -> pizone.Controller:
"""Pull system/zone/(power) state; do not rediscover or rebind here."""
try:
await self.controller.refresh_all()
except ConnectionError as err:
raise UpdateFailed(
f"Error communicating with iZone controller: {err}"
) from err
except pizone.ControllerCommandError as err:
raise UpdateFailed(f"iZone controller rejected refresh: {err}") from err
return self.controller
+206 -201
View File
@@ -1,9 +1,19 @@
"""Internal discovery service for iZone AC."""
"""Shared pizone discovery lifecycle for iZone.
Lazy: ``create_discovery`` runs only when HomeKit discovery or user-initiated
setup needs it (then reused for config-entry ``create_controller``). Not started
from bare domain ``async_setup``.
The library 1.4 path does not run a scan loop; this module owns a slow shared
``discovery.scan()`` timer so new bridges still surface (parity with the old
~5 minute discovery broadcast).
"""
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import override
import pizone
@@ -11,56 +21,43 @@ from homeassistant import config_entries
from homeassistant.const import CONF_EXCLUDE, CONF_HOST, EVENT_HOMEASSISTANT_STOP
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.helpers import aiohttp_client, discovery_flow
from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.event import async_track_time_interval
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import (
DATA_CONFIG,
DATA_DISCOVERY_SERVICE,
DISCOVERY_IDLE_SECONDS,
DISPATCH_CONTROLLER_DISCONNECTED,
DISPATCH_CONTROLLER_DISCOVERED,
DISPATCH_CONTROLLER_RECONNECTED,
DISPATCH_CONTROLLER_UPDATE,
DISPATCH_ZONE_UPDATE,
DOMAIN,
TIMEOUT_DISCOVERY,
)
from .const import DATA_CONFIG, DISCOVERY_IDLE_SECONDS, DISCOVERY_SCAN_INTERVAL, DOMAIN
_LOGGER = logging.getLogger(__name__)
async def async_discover_controllers(
hass: HomeAssistant,
*,
refresh: bool = False,
wait_for_uid: str | None = None,
) -> dict[str, pizone.Controller]:
"""Return currently known controllers, optionally waiting for a UID during rescan.
@dataclass(slots=True)
class DiscoveryRuntime:
"""HA-owned handles for a running pizone discovery service."""
If ``refresh`` is true, waits for fresh discovery data using the pizone library's
built-in coalescing and cool-down logic. When ``wait_for_uid`` is provided, returns
as soon as that specific controller appears (or after the timeout).
service: pizone.DiscoveryService
unsub_scan: Callable[[], None]
unsub_stop: Callable[[], None]
cancel_idle_stop: Callable[[], None] | None = None
If discovery is not yet running, it is started first.
Raises:
OSError: Discovery service failed to start or controller fetch failed.
@dataclass(slots=True)
class DiscoveryServiceState:
"""Shared discovery slot stored in ``hass.data``.
``runtime`` is set while the service is running. ``starting`` is set while
``create_discovery`` is in flight so concurrent ensure callers share one
Future (same idea as ``helpers.singleton``). That covers the common case
(startup / several callers hitting ensure together). Stop pops the slot then
awaits ``close()``; a concurrent ensure during that await is possible but
unlikely (idle-stop overlapping a new discovery). Serializing that
open-during-close edge is left for a follow-up if needed.
"""
disco = await async_start_discovery_service(hass)
assert disco.pi_disco is not None
if not refresh:
return await disco.pi_disco.fetch_controllers()
runtime: DiscoveryRuntime | None = None
starting: asyncio.Future[pizone.DiscoveryService] | None = None
if wait_for_uid is not None:
await disco.pi_disco.fetch_controller(wait_for_uid, timeout=TIMEOUT_DISCOVERY)
return await disco.pi_disco.fetch_controllers()
return await disco.pi_disco.fetch_controllers(timeout=TIMEOUT_DISCOVERY)
DATA_DISCOVERY_SERVICE: HassKey[DiscoveryServiceState] = HassKey("izone_discovery")
def yaml_excluded_uids(hass: HomeAssistant) -> set[str]:
@@ -73,10 +70,10 @@ def yaml_excluded_uids(hass: HomeAssistant) -> set[str]:
@callback
def async_note_integration_discovery(
hass: HomeAssistant, ctrl: pizone.Controller
hass: HomeAssistant, endpoint: pizone.ControllerEndpoint
) -> None:
"""Start a config flow when the shared discovery service reports a controller."""
if ctrl.device_uid in yaml_excluded_uids(hass):
"""Start a config flow when discovery reports an unclaimed endpoint."""
if endpoint.uid in yaml_excluded_uids(hass):
return
if _async_blocks_runtime_integration_discovery(hass):
return
@@ -85,9 +82,9 @@ def async_note_integration_discovery(
DOMAIN,
context={
"source": config_entries.SOURCE_INTEGRATION_DISCOVERY,
"unique_id": ctrl.device_uid,
"unique_id": endpoint.uid,
},
data={CONF_HOST: ctrl.device_ip},
data={CONF_HOST: endpoint.host},
)
@@ -103,186 +100,194 @@ def _async_blocks_runtime_integration_discovery(hass: HomeAssistant) -> bool:
return False
class DiscoveryService(pizone.Listener):
"""Discovery data and interfacing with pizone library."""
@callback
def async_schedule_idle_stop(hass: HomeAssistant) -> None:
"""Schedule a delayed shutdown check for the shared discovery service."""
state = hass.data.get(DATA_DISCOVERY_SERVICE)
if state is None or state.runtime is None:
return
def __init__(self, hass: HomeAssistant) -> None:
"""Initialise discovery service."""
super().__init__()
self.hass = hass
self.pi_disco: pizone.DiscoveryService | None = None
self.remove_stop_listener: Callable[[], None] | None = None
self.remove_config_flow_listener: Callable[[], None] | None = None
self._idle_stop_handle: asyncio.TimerHandle | None = None
runtime = state.runtime
if runtime.cancel_idle_stop is not None:
runtime.cancel_idle_stop()
def _fire_idle_stop() -> None:
runtime.cancel_idle_stop = None
hass.async_create_task(async_maybe_stop_discovery(hass))
runtime.cancel_idle_stop = hass.loop.call_later(
DISCOVERY_IDLE_SECONDS, _fire_idle_stop
).cancel
async def async_ensure_discovery(hass: HomeAssistant) -> pizone.DiscoveryService:
"""Create and start the shared discovery service if needed.
Call from HomeKit / user-initiated setup (and later from entry setup for
``create_controller``). Do not call from bare domain ``async_setup``.
Concurrent callers share one create via ``DiscoveryServiceState.starting``.
Raises:
OSError: Discovery UDP socket could not be bound.
RuntimeError: A process-global discovery service already exists outside
this Home Assistant instance's tracking.
"""
state = hass.data.get(DATA_DISCOVERY_SERVICE)
if state is not None and state.runtime is not None:
return state.runtime.service
if state is not None and state.starting is not None:
return await state.starting
starting: asyncio.Future[pizone.DiscoveryService] = hass.loop.create_future()
state = DiscoveryServiceState(starting=starting)
hass.data[DATA_DISCOVERY_SERVICE] = state
_LOGGER.debug("Starting iZone discovery service")
@callback
def async_schedule_idle_stop(self) -> None:
"""Schedule a delayed shutdown check for discovery service."""
if self._idle_stop_handle is not None:
self._idle_stop_handle.cancel()
def _on_endpoint_discovered(
endpoint: pizone.ControllerEndpoint,
) -> None:
async_note_integration_discovery(hass, endpoint)
async_schedule_idle_stop(hass)
self._idle_stop_handle = self.hass.loop.call_later(
DISCOVERY_IDLE_SECONDS,
lambda: self.hass.async_create_task(
async_maybe_stop_discovery_service(self.hass)
),
)
@callback
def async_cancel_idle_stop(self) -> None:
"""Cancel any pending idle-stop timer."""
if self._idle_stop_handle is not None:
self._idle_stop_handle.cancel()
self._idle_stop_handle = None
# Listener interface
@override
def controller_discovered(self, ctrl: pizone.Controller) -> None:
"""Handle new controller discovery."""
self.async_schedule_idle_stop()
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCOVERED, ctrl)
@override
def controller_disconnected(self, ctrl: pizone.Controller, ex: Exception) -> None:
"""On disconnect from controller."""
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_DISCONNECTED, ctrl, ex)
@override
def controller_reconnected(self, ctrl: pizone.Controller) -> None:
"""On reconnect to controller."""
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_RECONNECTED, ctrl)
@override
def controller_update(self, ctrl: pizone.Controller) -> None:
"""System update message is received from the controller."""
async_dispatcher_send(self.hass, DISPATCH_CONTROLLER_UPDATE, ctrl)
@override
def zone_update(self, ctrl: pizone.Controller, zone: pizone.Zone) -> None:
"""Zone update message is received from the controller."""
async_dispatcher_send(self.hass, DISPATCH_ZONE_UPDATE, ctrl, zone)
async def async_start_discovery_service(hass: HomeAssistant) -> DiscoveryService:
"""Set up the pizone internal discovery."""
if disco := hass.data.get(DATA_DISCOVERY_SERVICE):
# Already started
return disco
_LOGGER.debug("Starting iZone Discovery Service")
# discovery local services
disco = DiscoveryService(hass)
# Start the pizone discovery service, disco is the listener
session = aiohttp_client.async_get_clientsession(hass)
disco.pi_disco = pizone.discovery(disco, session=session)
@callback
def _async_on_controller_discovered(ctrl: pizone.Controller) -> None:
async_note_integration_discovery(hass, ctrl)
disco.remove_config_flow_listener = async_dispatcher_connect(
hass, DISPATCH_CONTROLLER_DISCOVERED, _async_on_controller_discovered
)
await disco.pi_disco.start_discovery()
# Stored after start_discovery() so concurrent callers never receive a
# partially-initialised DiscoveryService (no active UDP transport or scan loop).
hass.data[DATA_DISCOVERY_SERVICE] = disco
async def async_stop_discovery_on_shutdown(event: Event) -> None:
"""Stop discovery on Home Assistant shutdown."""
# async_listen_once removes its own listener before running this callback.
# Clear our handle so async_stop_discovery_service does not try to remove it
# a second time, which logs an "unknown job listener" error.
disco.remove_stop_listener = None
await async_stop_discovery_service(hass)
disco.remove_stop_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, async_stop_discovery_on_shutdown
)
disco.async_schedule_idle_stop()
return disco
@callback
def _async_is_ignored_or_excluded_uid(hass: HomeAssistant, uid: str) -> bool:
"""Return True when UID is excluded by YAML or ignored/disabled by config entries."""
if uid in yaml_excluded_uids(hass):
return True
return any(
entry.unique_id == uid
and (
entry.source == config_entries.SOURCE_IGNORE
or entry.disabled_by is not None
try:
service = await pizone.create_discovery(
on_endpoint_discovered=_on_endpoint_discovered,
session=session,
)
for entry in hass.config_entries.async_entries(DOMAIN)
except BaseException as err:
if hass.data.get(DATA_DISCOVERY_SERVICE) is state:
hass.data.pop(DATA_DISCOVERY_SERVICE, None)
if not starting.done():
starting.set_exception(err)
# Mark retrieved so an un-awaited failure does not log loudly.
starting.exception()
raise
# Stopped while create was in flight — discard the orphaned service.
if starting.done():
await service.close()
return await starting
async def _async_scan(_now: datetime | None = None) -> None:
try:
await service.scan()
except ConnectionError:
_LOGGER.debug("iZone discovery scan skipped; transport not ready")
unsub_scan = async_track_time_interval(
hass, _async_scan, DISCOVERY_SCAN_INTERVAL, cancel_on_shutdown=True
)
async def _async_stop_on_shutdown(_event: Event) -> None:
# listen_once removes itself before this runs; avoid a second unsub.
if slot := hass.data.get(DATA_DISCOVERY_SERVICE):
if slot.runtime is not None:
slot.runtime.unsub_stop = lambda: None
await async_stop_discovery(hass)
@callback
def _async_has_actionable_entries(hass: HomeAssistant) -> bool:
"""Return True when there is at least one enabled, non-ignored iZone entry."""
return any(
hass.config_entries.async_entries(
DOMAIN, include_ignore=False, include_disabled=False
)
unsub_stop = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, _async_stop_on_shutdown
)
@callback
def _async_has_actionable_flows(hass: HomeAssistant) -> bool:
"""Return True when there is an in-progress iZone flow that can create/update state."""
return any(
flow["context"].get("source") != config_entries.SOURCE_IGNORE
for flow in hass.config_entries.flow.async_progress_by_handler(
DOMAIN, include_uninitialized=True
)
state.runtime = DiscoveryRuntime(
service=service,
unsub_scan=unsub_scan,
unsub_stop=unsub_stop,
)
state.starting = None
starting.set_result(service)
# Initial broadcast — 1.4 create_discovery binds :7005 but does not scan.
await _async_scan()
async_schedule_idle_stop(hass)
return service
async def async_maybe_stop_discovery_service(hass: HomeAssistant) -> None:
"""Stop discovery after idle delay when no actionable controllers remain."""
if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)):
async def async_discover_all_endpoints(
hass: HomeAssistant,
) -> dict[str, pizone.ControllerEndpoint]:
"""Scan and return all verified endpoints for user / HomeKit setup.
Starts shared discovery if needed.
Raises:
OSError: Discovery UDP socket could not be bound.
"""
service = await async_ensure_discovery(hass)
return {endpoint.uid: endpoint for endpoint in await service.discover_all()}
async def async_discover_endpoint(
hass: HomeAssistant, uid: str
) -> pizone.ControllerEndpoint | None:
"""Resolve one endpoint by UID for confirm / targeted HomeKit lookup.
Starts shared discovery if needed. Uses cache when the UID is already known.
Raises:
OSError: Discovery UDP socket could not be bound.
"""
service = await async_ensure_discovery(hass)
return await service.discover_by_uid(uid)
async def async_maybe_stop_discovery(hass: HomeAssistant) -> None:
"""Stop discovery when nothing actionable remains.
Keeps the UDP listener while any entry is loaded, mid-setup, or in
``SETUP_RETRY``, or while an actionable config flow is in progress.
"""
if DATA_DISCOVERY_SERVICE not in hass.data:
return
if _async_has_actionable_flows(hass) or _async_has_actionable_entries(hass):
disco.async_schedule_idle_stop()
return
controllers_map = await disco.pi_disco.fetch_controllers()
if not controllers_map:
await async_stop_discovery_service(hass)
return
if all(
_async_is_ignored_or_excluded_uid(hass, c.device_uid)
for c in controllers_map.values()
if (
hass.config_entries.async_loaded_entries(DOMAIN)
or any(
entry.state
in (
config_entries.ConfigEntryState.SETUP_IN_PROGRESS,
config_entries.ConfigEntryState.SETUP_RETRY,
)
for entry in hass.config_entries.async_entries(DOMAIN)
)
or any(
flow["context"].get("source") != config_entries.SOURCE_IGNORE
for flow in hass.config_entries.flow.async_progress_by_handler(
DOMAIN, include_uninitialized=True
)
)
):
await async_stop_discovery_service(hass)
async_schedule_idle_stop(hass)
return
disco.async_schedule_idle_stop()
await async_stop_discovery(hass)
async def async_stop_discovery_service(hass: HomeAssistant) -> None:
"""Stop the discovery service."""
if not (disco := hass.data.get(DATA_DISCOVERY_SERVICE)):
async def async_stop_discovery(hass: HomeAssistant) -> None:
"""Stop the shared discovery service and clear HA tracking."""
state = hass.data.pop(DATA_DISCOVERY_SERVICE, None)
if state is None:
return
if disco.remove_stop_listener is not None:
disco.remove_stop_listener()
disco.remove_stop_listener = None
if state.starting is not None and not state.starting.done():
state.starting.set_exception(
RuntimeError("iZone discovery stopped before start completed")
)
state.starting.exception()
if disco.remove_config_flow_listener is not None:
disco.remove_config_flow_listener()
disco.remove_config_flow_listener = None
if state.runtime is None:
return
disco.async_cancel_idle_stop()
runtime = state.runtime
if runtime.cancel_idle_stop is not None:
runtime.cancel_idle_stop()
runtime.cancel_idle_stop = None
await disco.pi_disco.close()
del hass.data[DATA_DISCOVERY_SERVICE]
_LOGGER.debug("Stopped iZone Discovery Service")
runtime.unsub_scan()
runtime.unsub_stop()
await runtime.service.close()
_LOGGER.debug("Stopped iZone discovery service")
+57
View File
@@ -0,0 +1,57 @@
"""Shared coordinator entity for iZone."""
from collections.abc import Awaitable
from typing import override
import pizone
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import IZoneCoordinator
class IZoneCoordinatorEntity(CoordinatorEntity[IZoneCoordinator]):
"""Coordinator entity with soft-fault availability and command helper."""
@property
def controller(self) -> pizone.Controller:
"""Return the pizone controller owned by the entry coordinator."""
return self.coordinator.controller
@property
@override
def available(self) -> bool:
"""Return True when the last refresh succeeded and the ASH is reachable."""
return super().available and self.controller.connected
async def _async_run_and_update(self, coro: Awaitable[None]) -> None:
"""Run a controller/zone command and push local state to the coordinator.
Rejected commands stay available and raise HomeAssistantError. Transport
failures mark the coordinator unavailable and also raise HomeAssistantError
so multi-step service calls stop. A successful command would clear that
failed flag via async_set_updated_data, but service calls skip unavailable
entities, so recovery in practice is a later successful coordinator refresh.
"""
try:
await coro
except pizone.ControllerCommandError as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_rejected",
translation_placeholders={
"uid": self.controller.device_uid,
"error": str(ex),
},
) from ex
except ConnectionError as ex:
self.coordinator.async_set_update_error(ex)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unable_to_connect",
translation_placeholders={"uid": self.controller.device_uid},
) from ex
else:
self.coordinator.async_set_updated_data(self.controller)
@@ -20,6 +20,14 @@
}
}
},
"exceptions": {
"command_rejected": {
"message": "Controller {uid} rejected command: {error}"
},
"unable_to_connect": {
"message": "Unable to connect to controller {uid}"
}
},
"services": {
"airflow_max": {
"description": "Sets the airflow maximum percent for a zone.",
+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()