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