diff --git a/homeassistant/components/izone/__init__.py b/homeassistant/components/izone/__init__.py index 3afc4d252e7e..2c4a85cd9218 100644 --- a/homeassistant/components/izone/__init__.py +++ b/homeassistant/components/izone/__init__.py @@ -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 diff --git a/homeassistant/components/izone/climate.py b/homeassistant/components/izone/climate.py index 74fab9e02799..00c5a2cc5c6a 100644 --- a/homeassistant/components/izone/climate.py +++ b/homeassistant/components/izone/climate.py @@ -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): diff --git a/homeassistant/components/izone/config_flow.py b/homeassistant/components/izone/config_flow.py index 7c526a0d3ae4..3d073ad17d88 100644 --- a/homeassistant/components/izone/config_flow.py +++ b/homeassistant/components/izone/config_flow.py @@ -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, ) diff --git a/homeassistant/components/izone/const.py b/homeassistant/components/izone/const.py index eb8f76ed1daa..e7592331c51f 100644 --- a/homeassistant/components/izone/const.py +++ b/homeassistant/components/izone/const.py @@ -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) diff --git a/homeassistant/components/izone/coordinator.py b/homeassistant/components/izone/coordinator.py new file mode 100644 index 000000000000..2be4ddd11a29 --- /dev/null +++ b/homeassistant/components/izone/coordinator.py @@ -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 diff --git a/homeassistant/components/izone/discovery.py b/homeassistant/components/izone/discovery.py index 862577307c58..4ade8f1e0170 100644 --- a/homeassistant/components/izone/discovery.py +++ b/homeassistant/components/izone/discovery.py @@ -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") diff --git a/homeassistant/components/izone/entity.py b/homeassistant/components/izone/entity.py new file mode 100644 index 000000000000..07f7fa4017d3 --- /dev/null +++ b/homeassistant/components/izone/entity.py @@ -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) diff --git a/homeassistant/components/izone/strings.json b/homeassistant/components/izone/strings.json index 42b91a22a357..b57445468cf4 100644 --- a/homeassistant/components/izone/strings.json +++ b/homeassistant/components/izone/strings.json @@ -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.", diff --git a/tests/components/izone/__init__.py b/tests/components/izone/__init__.py index 3753a65256df..2cd03818c3a5 100644 --- a/tests/components/izone/__init__.py +++ b/tests/components/izone/__init__.py @@ -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] diff --git a/tests/components/izone/conftest.py b/tests/components/izone/conftest.py index 56340c887cc3..037f3cf9bb08 100644 --- a/tests/components/izone/conftest.py +++ b/tests/components/izone/conftest.py @@ -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 diff --git a/tests/components/izone/snapshots/test_climate.ambr b/tests/components/izone/snapshots/test_climate.ambr index 751093d19689..5550899b8052 100644 --- a/tests/components/izone/snapshots/test_climate.ambr +++ b/tests/components/izone/snapshots/test_climate.ambr @@ -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, diff --git a/tests/components/izone/test_climate.py b/tests/components/izone/test_climate.py index 222712bd5657..73da431e9718 100644 --- a/tests/components/izone/test_climate.py +++ b/tests/components/izone/test_climate.py @@ -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 diff --git a/tests/components/izone/test_config_flow.py b/tests/components/izone/test_config_flow.py index 9869b0e90276..9bfeea287c17 100644 --- a/tests/components/izone/test_config_flow.py +++ b/tests/components/izone/test_config_flow.py @@ -15,9 +15,9 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component from .conftest import ( - async_install_discovery_service, async_load_yaml_exclude, create_mock_controller, + endpoint_from_controller, patch_discovered_controllers, ) @@ -31,22 +31,17 @@ def _make_homekit_info(md: str, host: str | None = None) -> SimpleNamespace: @pytest.fixture(autouse=True) def mock_izone_timeouts() -> Generator[None]: - """Mock iZone timeout constants to speed up tests.""" - with ( - patch( - "homeassistant.components.izone.discovery.TIMEOUT_DISCOVERY", - 0.01, - ), - patch( - "homeassistant.components.izone.discovery.DISCOVERY_IDLE_SECONDS", - 0.04, - ), + """Mock iZone idle-stop delay to speed up tests.""" + with patch( + "homeassistant.components.izone.discovery.DISCOVERY_IDLE_SECONDS", + 0.04, ): yield +@pytest.mark.usefixtures("mock_entry_setup") async def test_user_discovery_success( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """Test user flow confirms and creates an entry for a discovered controller.""" controller = create_mock_controller("000000001", "192.0.2.55") @@ -65,8 +60,9 @@ async def test_user_discovery_success( assert result["result"].unique_id == "000000001" +@pytest.mark.usefixtures("mock_entry_setup") async def test_user_discovery_default_selects_first_and_queues_other( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """Default dropdown selection configures first UID and queues the other for confirm.""" first = create_mock_controller("000000001", "192.0.2.1") @@ -96,8 +92,9 @@ async def test_user_discovery_default_selects_first_and_queues_other( assert progress[0]["context"]["unique_id"] == "000000002" +@pytest.mark.usefixtures("mock_entry_setup") async def test_broadcast_skips_already_configured_controller( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """Test broadcast discovery skips configured controllers and sets up an unconfigured one.""" configured_controller = create_mock_controller("000000001", "192.0.2.1") @@ -124,8 +121,9 @@ async def test_broadcast_skips_already_configured_controller( assert result["result"].unique_id == "000000002" +@pytest.mark.usefixtures("mock_entry_setup") async def test_user_discovery_skips_yaml_excluded_controllers( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """User flow should not offer controllers excluded by deprecated YAML config.""" excluded_controller = create_mock_controller("000000001", "192.0.2.1") @@ -147,8 +145,9 @@ async def test_user_discovery_skips_yaml_excluded_controllers( assert result["result"].unique_id == "000000002" +@pytest.mark.usefixtures("mock_entry_setup") async def test_broadcast_multiple_unconfigured_shows_choice( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """Test broadcast discovery shows a controller choice when multiple unconfigured controllers are found.""" first_controller = create_mock_controller("000000002", "192.0.2.1") @@ -192,7 +191,7 @@ async def test_broadcast_multiple_unconfigured_shows_choice( async def test_select_controller_aborts_when_choices_missing( hass: HomeAssistant, ) -> None: - """Test controller selection aborts if the discovered controller choices are missing.""" + """Controller selection aborts if discovered choices were lost on the flow.""" first_controller = create_mock_controller("000000001", "192.0.2.1") second_controller = create_mock_controller("000000002", "192.0.2.2") @@ -201,8 +200,10 @@ async def test_select_controller_aborts_when_choices_missing( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + # Public configure cannot clear flow-local discovery state; poke the in-progress + # instance so the empty-choices abort path is exercised. flow = hass.config_entries.flow._progress[result["flow_id"]] - flow._user_discovered_controllers = None + flow._user_discovered_endpoints = None result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -213,7 +214,7 @@ async def test_select_controller_aborts_when_choices_missing( async def test_select_controller_aborts_when_uid_not_in_choices( hass: HomeAssistant, ) -> None: - """Test controller selection aborts if a submitted UID is not in the choices.""" + """Controller selection aborts if the submitted UID is not in the choices.""" first_controller = create_mock_controller("000000001", "192.0.2.1") second_controller = create_mock_controller("000000002", "192.0.2.2") @@ -222,6 +223,8 @@ async def test_select_controller_aborts_when_uid_not_in_choices( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + # Schema validation rejects unknown UIDs; call the step directly with a UID that + # is not in the discovered set to cover the step's own abort. flow = hass.config_entries.flow._progress[result["flow_id"]] result = await flow.async_step_select_controller( {config_flow.SELECTED_CONTROLLER_UID: "000000099"} @@ -231,8 +234,9 @@ async def test_select_controller_aborts_when_uid_not_in_choices( assert result["reason"] == "no_devices_found" +@pytest.mark.usefixtures("mock_entry_setup") async def test_select_controller_creates_selected_uid_and_queues_others( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """A selected controller is configured and non-selected controllers are queued.""" first_controller = create_mock_controller("000000002", "192.0.2.1") @@ -312,68 +316,6 @@ async def test_user_flow_aborts_when_all_discovered_are_ignored( assert result["reason"] == "already_configured" -async def test_reuses_existing_discovery_service( - hass: HomeAssistant, mock_entry_setup: None -) -> None: - """Test config flow reuses the running discovery service without starting a new one.""" - controller = create_mock_controller("000000002", "192.0.2.2") - await async_install_discovery_service(hass, controller) - - with patch( - "homeassistant.components.izone.config_flow.pizone.discovery", - ) as mock_pizone_discovery: - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() - - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["title"] == "iZone 000000002" - assert result["data"] == {CONF_HOST: "192.0.2.2"} - assert result["result"].unique_id == "000000002" - mock_pizone_discovery.assert_not_called() - - -async def test_import_starts_discovery_service( - hass: HomeAssistant, -) -> None: - """Test YAML import starts discovery so runtime discovery can offer flows.""" - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - new=AsyncMock(), - ) as mock_start: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "discovery_started" - mock_start.assert_awaited_once_with(hass) - - -async def test_import_logs_and_aborts_when_discovery_service_cannot_start( - hass: HomeAssistant, -) -> None: - """Test YAML import aborts cleanly if discovery startup raises OSError.""" - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - new=AsyncMock(side_effect=OSError), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={}, - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "discovery_failed" - - async def test_import_aborts_when_another_izone_flow_in_progress( hass: HomeAssistant, ) -> None: @@ -396,8 +338,58 @@ async def test_import_aborts_when_another_izone_flow_in_progress( assert result["reason"] == "already_in_progress" +async def test_import_starts_discovery_and_aborts_discovery_started( + hass: HomeAssistant, +) -> None: + """YAML import starts shared discovery then aborts so runtime flows take over.""" + with patch( + "homeassistant.components.izone.discovery.async_ensure_discovery", + new=AsyncMock(), + ) as mock_ensure: + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_started" + mock_ensure.assert_awaited_once() + + +async def test_import_aborts_when_discovery_bind_fails(hass: HomeAssistant) -> None: + """YAML import aborts when discovery cannot bind the UDP socket.""" + with patch( + "homeassistant.components.izone.discovery.async_ensure_discovery", + new=AsyncMock(side_effect=OSError("bind failed")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +async def test_user_flow_aborts_when_discovery_bind_fails(hass: HomeAssistant) -> None: + """User flow aborts when discovery cannot bind the UDP socket.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_all_endpoints", + new=AsyncMock(side_effect=OSError("bind failed")), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "discovery_failed" + + +@pytest.mark.usefixtures("mock_entry_setup") async def test_homekit_confirm_uses_discovered_host( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """Test HomeKit flow confirms and uses the discovered controller IP, not the HomeKit host.""" controller = create_mock_controller(device_ip="192.0.2.3") @@ -502,8 +494,9 @@ async def test_homekit_flow_sets_device_uid_once( assert set_unique_id_calls == ["000000001"] +@pytest.mark.usefixtures("mock_entry_setup") async def test_homekit_aborts_while_user_confirm_is_open( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """HomeKit onboarding for same UID is blocked while a user flow is already active.""" controller = create_mock_controller("000000001", "192.0.2.3") @@ -560,9 +553,14 @@ async def test_homekit_aborts_when_uid_already_configured( version=2, ).add_to_hass(hass) - with patch( - "homeassistant.components.izone.discovery.async_discover_controllers", - ) as mock_discover_controllers: + with ( + patch( + "homeassistant.components.izone.discovery.async_discover_all_endpoints", + ) as mock_discover_all, + patch( + "homeassistant.components.izone.discovery.async_discover_endpoint", + ) as mock_discover_one, + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, @@ -571,7 +569,8 @@ async def test_homekit_aborts_when_uid_already_configured( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" - mock_discover_controllers.assert_not_called() + mock_discover_all.assert_not_called() + mock_discover_one.assert_not_called() async def test_homekit_aborts_when_uid_configured_during_discovery( @@ -580,19 +579,21 @@ async def test_homekit_aborts_when_uid_configured_during_discovery( """Test HomeKit aborts if the discovered UID gets configured mid-resolution.""" controller = create_mock_controller("000000001", "192.0.2.3") - async def _fetch_with_midflight_config(timeout=None): + async def _discover_with_midflight_config( + hass: HomeAssistant, + ) -> dict[str, object]: MockConfigEntry( domain=DOMAIN, unique_id="000000001", data={}, version=2, ).add_to_hass(hass) - return {controller.device_uid: controller} + return {controller.device_uid: endpoint_from_controller(controller)} - with patch_discovered_controllers(controller) as service: - service.pi_disco.fetch_controllers = AsyncMock( - side_effect=_fetch_with_midflight_config - ) + with patch( + "homeassistant.components.izone.discovery.async_discover_all_endpoints", + new=AsyncMock(side_effect=_discover_with_midflight_config), + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, @@ -603,26 +604,84 @@ async def test_homekit_aborts_when_uid_configured_during_discovery( assert result["reason"] == "already_configured" -async def test_homekit_aborts_when_uid_not_found_in_discovery( +@pytest.mark.parametrize( + ("controllers", "homekit_md", "homekit_host"), + [ + pytest.param([], "iZone 000000001", "192.0.2.3", id="empty_discovery"), + pytest.param( + [create_mock_controller("000000003", "192.0.2.33")], + "iZone 000000001", + None, + id="uid_missing_from_discovery", + ), + pytest.param( + [create_mock_controller("000000002", "192.0.2.44")], + "iZone 000000001", + "203.0.113.1", + id="different_uid_discovered", + ), + ], +) +async def test_homekit_aborts_when_target_uid_not_discovered( hass: HomeAssistant, + controllers: list[Mock], + homekit_md: str, + homekit_host: str | None, ) -> None: - """Test HomeKit aborts when the discovered UID cannot be found via iZone discovery.""" - with patch_discovered_controllers([]): + """HomeKit aborts when iZone discovery does not yield the advertised UID.""" + with patch_discovered_controllers(controllers): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + data=_make_homekit_info(homekit_md, homekit_host), ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "no_devices_found" -async def test_homekit_aborts_when_controller_unavailable_during_discovery_wait( +@pytest.mark.usefixtures("mock_entry_setup") +async def test_homekit_resolves_uid_via_discover_endpoint( hass: HomeAssistant, ) -> None: - """HomeKit aborts when the advertised UID is not found during iZone discovery.""" - with patch_discovered_controllers([]): + """HomeKit falls back to discover_by_uid when the UID is missing from discover_all.""" + target = create_mock_controller("000000001", "192.0.2.1") + other = create_mock_controller("000000002", "192.0.2.2") + target_endpoint = endpoint_from_controller(target) + + with ( + patch( + "homeassistant.components.izone.discovery.async_discover_all_endpoints", + new=AsyncMock( + return_value={other.device_uid: endpoint_from_controller(other)} + ), + ), + patch( + "homeassistant.components.izone.discovery.async_discover_endpoint", + new=AsyncMock(return_value=target_endpoint), + ), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "203.0.113.1"), + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "confirm" + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"] == {CONF_HOST: "192.0.2.1"} + assert result["result"].unique_id == "000000001" + + +async def test_homekit_aborts_when_discovery_bind_fails(hass: HomeAssistant) -> None: + """HomeKit aborts when discovery cannot bind the UDP socket.""" + with patch( + "homeassistant.components.izone.discovery.async_discover_all_endpoints", + new=AsyncMock(side_effect=OSError("bind failed")), + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_HOMEKIT}, @@ -630,41 +689,11 @@ async def test_homekit_aborts_when_controller_unavailable_during_discovery_wait( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" + assert result["reason"] == "discovery_failed" async def test_user_flow_aborts_when_no_controllers_found(hass: HomeAssistant) -> None: """User flow aborts when broadcast discovery returns no controllers.""" - with patch_discovered_controllers([]): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - await hass.async_block_till_done() - - -async def test_user_flow_abort_when_discovery_service_cannot_start( - hass: HomeAssistant, -) -> None: - """User flow aborts when discovery startup fails.""" - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - side_effect=OSError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "discovery_failed" - - -async def test_user_discovery_with_shared_service_without_matches_aborts( - hass: HomeAssistant, mock_entry_setup: None -) -> None: - """User flow aborts when discovery refresh returns no controllers.""" with patch_discovered_controllers([]): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -688,112 +717,6 @@ async def test_homekit_without_model_aborts( assert result["reason"] == "no_devices_found" -async def test_homekit_without_model_does_not_start_discovery( - hass: HomeAssistant, -) -> None: - """Test HomeKit flow does not trigger iZone discovery for a non-iZone model.""" - with patch( - "homeassistant.components.izone.discovery.async_discover_controllers", - ) as mock_discover_controllers: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("Other Device"), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - mock_discover_controllers.assert_not_called() - - -async def test_homekit_aborts_when_matching_uid_not_discovered( - hass: HomeAssistant, -) -> None: - """Test HomeKit aborts when no discovered controller matches model UID.""" - controller = create_mock_controller("000000003", "192.0.2.33") - - with patch_discovered_controllers(controller): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001"), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_homekit_aborts_when_nothing_found(hass: HomeAssistant) -> None: - """Test HomeKit aborts when iZone discovery finds no controllers.""" - with patch_discovered_controllers([]): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001", "203.0.113.1"), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_homekit_aborts_when_discovered_uid_missing( - hass: HomeAssistant, -) -> None: - """Test HomeKit aborts when discovery returns controllers but not the advertised UID.""" - different_controller = create_mock_controller("000000002", "192.0.2.44") - - with patch_discovered_controllers(different_controller): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001", "203.0.113.1"), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_homekit_flow_aborts_at_confirm_when_controller_disappears( - hass: HomeAssistant, -) -> None: - """HomeKit confirm aborts when the controller is gone by the time the user confirms.""" - controller = create_mock_controller("000000001", "192.0.2.3") - - with patch_discovered_controllers(controller): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001", "203.0.113.1"), - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm" - - with patch_discovered_controllers([]): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_homekit_aborts_when_discovery_startup_fails( - hass: HomeAssistant, -) -> None: - """Test HomeKit flow aborts when discovery service cannot start.""" - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - side_effect=OSError, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001", "203.0.113.1"), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "discovery_failed" - - async def test_integration_discovery_aborts_for_yaml_excluded_uid( hass: HomeAssistant, ) -> None: @@ -830,15 +753,16 @@ async def test_integration_discovery_aborts_for_ignored_uid( "source": config_entries.SOURCE_INTEGRATION_DISCOVERY, "unique_id": "000000002", }, - data={"host": "192.0.2.2"}, + data={CONF_HOST: "192.0.2.2"}, ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("mock_entry_setup") async def test_runtime_integration_discovery_starts_confirm_flow( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """When the discovery service sees an unconfigured UID, offer setup.""" MockConfigEntry( @@ -849,7 +773,9 @@ async def test_runtime_integration_discovery_starts_confirm_flow( ).add_to_hass(hass) new_ctrl = create_mock_controller("000000002", "192.0.2.2") - izone_discovery.async_note_integration_discovery(hass, new_ctrl) + izone_discovery.async_note_integration_discovery( + hass, endpoint_from_controller(new_ctrl) + ) await hass.async_block_till_done(wait_background_tasks=True) progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) @@ -860,8 +786,9 @@ async def test_runtime_integration_discovery_starts_confirm_flow( assert progress[0]["step_id"] == "confirm" +@pytest.mark.usefixtures("mock_entry_setup") async def test_integration_discovery_confirm_creates_entry( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """Full path: integration-discovery flow confirmed by the user creates an entry.""" controller = create_mock_controller("000000002", "192.0.2.2") @@ -877,9 +804,8 @@ async def test_integration_discovery_confirm_creates_entry( assert result["type"] is FlowResultType.FORM assert result["step_id"] == "confirm" - with patch_discovered_controllers(controller): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + await hass.async_block_till_done() assert result["type"] is FlowResultType.CREATE_ENTRY assert result["title"] == "iZone 000000002" @@ -903,7 +829,9 @@ async def test_runtime_integration_discovery_skips_yaml_excluded_uid( with patch( "homeassistant.helpers.discovery_flow.async_create_flow" ) as mock_create_flow: - izone_discovery.async_note_integration_discovery(hass, excluded_ctrl) + izone_discovery.async_note_integration_discovery( + hass, endpoint_from_controller(excluded_ctrl) + ) await hass.async_block_till_done(wait_background_tasks=True) mock_create_flow.assert_not_called() @@ -921,7 +849,9 @@ async def test_runtime_integration_discovery_skips_when_uid_already_configured( ).add_to_hass(hass) ctrl = create_mock_controller("000000002", "192.0.2.2") - izone_discovery.async_note_integration_discovery(hass, ctrl) + izone_discovery.async_note_integration_discovery( + hass, endpoint_from_controller(ctrl) + ) await hass.async_block_till_done(wait_background_tasks=True) assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) @@ -939,7 +869,9 @@ async def test_runtime_integration_discovery_skips_for_ignored_unique_id( ).add_to_hass(hass) ctrl = create_mock_controller("000000002", "192.0.2.2") - izone_discovery.async_note_integration_discovery(hass, ctrl) + izone_discovery.async_note_integration_discovery( + hass, endpoint_from_controller(ctrl) + ) await hass.async_block_till_done(wait_background_tasks=True) assert not hass.config_entries.flow.async_progress_by_handler(DOMAIN) @@ -969,14 +901,17 @@ async def test_runtime_integration_discovery_skips_during_user_select_controller with patch( "homeassistant.helpers.discovery_flow.async_create_flow" ) as mock_create_flow: - izone_discovery.async_note_integration_discovery(hass, new_ctrl) + izone_discovery.async_note_integration_discovery( + hass, endpoint_from_controller(new_ctrl) + ) await hass.async_block_till_done(wait_background_tasks=True) mock_create_flow.assert_not_called() +@pytest.mark.usefixtures("mock_entry_setup") async def test_runtime_integration_discovery_skips_during_user_confirm( - hass: HomeAssistant, mock_entry_setup: None + hass: HomeAssistant, ) -> None: """Runtime discovery stays suppressed while an interactive user flow is active.""" first = create_mock_controller("000000001", "192.0.2.1") @@ -987,7 +922,9 @@ async def test_runtime_integration_discovery_skips_during_user_confirm( ) assert result["step_id"] == "confirm" - izone_discovery.async_note_integration_discovery(hass, second) + izone_discovery.async_note_integration_discovery( + hass, endpoint_from_controller(second) + ) await hass.async_block_till_done(wait_background_tasks=True) progress = hass.config_entries.flow.async_progress_by_handler(DOMAIN) @@ -1013,13 +950,6 @@ async def test_async_setup_starts_import_flow(hass: HomeAssistant) -> None: mock_create_task.assert_called_once() -def test_flow_uid_for_matching_returns_none_when_no_uid() -> None: - """Flow UID extraction returns None when context has no unique_id.""" - flow = SimpleNamespace(context={}, init_data=None) - - assert config_flow._flow_uid_for_matching(flow) is None - - def test_is_matching_returns_false_when_either_flow_has_no_uid() -> None: """Flow matching should fail when a stable UID cannot be derived.""" first = SimpleNamespace(context={}, init_data=None) @@ -1036,51 +966,6 @@ def test_is_matching_returns_true_for_same_flow_uid() -> None: assert config_flow.IZoneConfigFlow.is_matching(first, second) is True -async def test_homekit_aborts_for_yaml_excluded_uid_without_discovery( - hass: HomeAssistant, -) -> None: - """HomeKit setup aborts immediately for YAML excluded UIDs.""" - await async_load_yaml_exclude(hass, "000000001") - - with patch( - "homeassistant.components.izone.discovery.async_discover_controllers" - ) as mock_discover_controllers: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001", "192.0.2.3"), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - mock_discover_controllers.assert_not_called() - - -async def test_homekit_aborts_for_ignored_uid( - hass: HomeAssistant, -) -> None: - """HomeKit setup aborts for UIDs that have been ignored.""" - MockConfigEntry( - domain=DOMAIN, - unique_id="000000001", - source=config_entries.SOURCE_IGNORE, - data={}, - ).add_to_hass(hass) - - with patch( - "homeassistant.components.izone.discovery.async_discover_controllers" - ) as mock_discover_controllers: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_HOMEKIT}, - data=_make_homekit_info("iZone 000000001", "192.0.2.3"), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "already_configured" - mock_discover_controllers.assert_not_called() - - async def test_confirm_asserts_when_controller_data_is_missing( hass: HomeAssistant, ) -> None: @@ -1092,32 +977,13 @@ async def test_confirm_asserts_when_controller_data_is_missing( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + # Corrupt flow-local state that the public path always sets before confirm. flow = hass.config_entries.flow._progress[result["flow_id"]] flow._discovered_controller_ip = None with pytest.raises(AssertionError): await flow.async_step_confirm() -async def test_confirm_aborts_when_refresh_discovers_no_controllers( - hass: HomeAssistant, -) -> None: - """Confirm aborts when a follow-up discovery refresh returns no controllers.""" - controller = create_mock_controller("000000001", "192.0.2.1") - - with patch_discovered_controllers(controller): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - flow = hass.config_entries.flow._progress[result["flow_id"]] - - with patch_discovered_controllers([]): - result = await flow.async_step_confirm({}) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - async def test_confirm_asserts_when_unique_id_is_not_string( hass: HomeAssistant, ) -> None: @@ -1132,95 +998,24 @@ async def test_confirm_asserts_when_unique_id_is_not_string( flow = hass.config_entries.flow._progress[result["flow_id"]] flow.context["unique_id"] = None - with ( - patch_discovered_controllers(controller), - pytest.raises(AssertionError), - ): + with pytest.raises(AssertionError): await flow.async_step_confirm({}) -async def test_confirm_aborts_when_unique_id_controller_not_found( - hass: HomeAssistant, -) -> None: - """Confirm aborts when the flow UID is not returned by discovery.""" - controller = create_mock_controller("000000001", "192.0.2.1") - - with patch_discovered_controllers(controller): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - flow = hass.config_entries.flow._progress[result["flow_id"]] - flow.context["unique_id"] = "000009999" - - with patch_discovered_controllers(controller): - result = await flow.async_step_confirm({}) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "no_devices_found" - - -async def test_confirm_aborts_when_discovery_startup_fails( - hass: HomeAssistant, -) -> None: - """Test confirm step aborts when discovery service cannot start.""" - controller = create_mock_controller("000000001", "192.0.2.1") - - with patch_discovered_controllers(controller): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - - flow = hass.config_entries.flow._progress[result["flow_id"]] - - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - side_effect=OSError, - ): - result = await flow.async_step_confirm({}) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "discovery_failed" - - -def test_filter_yaml_exclude_returns_original_when_no_exclusions( - hass: HomeAssistant, -) -> None: - """YAML exclusion helper returns unchanged mapping when no excludes are present.""" - controllers = {"000000001": create_mock_controller("000000001", "192.0.2.1")} - - assert ( - config_flow.IZoneConfigFlow._filter_yaml_exclude(hass, controllers) - is controllers - ) - - -async def test_filter_yaml_exclude_removes_excluded_controllers( - hass: HomeAssistant, -) -> None: - """YAML exclusion helper removes matching UIDs from discovered controllers.""" - first = create_mock_controller("000000001", "192.0.2.1") - second = create_mock_controller("000000002", "192.0.2.2") - await async_load_yaml_exclude(hass, "000000002") - - filtered = config_flow.IZoneConfigFlow._filter_yaml_exclude( - hass, - {first.device_uid: first, second.device_uid: second}, - ) - - assert filtered == {first.device_uid: first} - - def test_async_fan_out_skips_uids_already_in_progress() -> None: - """Fan-out should skip scheduling flows for UIDs already in progress.""" - candidate = create_mock_controller("000000002", "192.0.2.2") + """Fan-out skips scheduling flows for UIDs already in progress.""" + candidate = endpoint_from_controller( + create_mock_controller("000000002", "192.0.2.2") + ) + # Drive the helper with a stub flow: happy-path fan-out tests only cover the + # "schedule missing UIDs" branch, not the already-in-progress skip. fake_flow = SimpleNamespace( _async_current_ids=Mock(return_value=set()), _async_in_progress=Mock(return_value=[{"context": {"unique_id": "000000002"}}]), _async_schedule_integration_discovery_flow=Mock(), ) - config_flow.IZoneConfigFlow._async_fan_out_discovered_controllers( + config_flow.IZoneConfigFlow._async_fan_out_discovered_endpoints( fake_flow, [candidate], selected_uid="000000001", @@ -1229,49 +1024,71 @@ def test_async_fan_out_skips_uids_already_in_progress() -> None: fake_flow._async_schedule_integration_discovery_flow.assert_not_called() +async def test_homekit_aborts_for_yaml_excluded_uid_without_discovery( + hass: HomeAssistant, +) -> None: + """HomeKit setup aborts immediately for YAML excluded UIDs.""" + await async_load_yaml_exclude(hass, "000000001") + + with ( + patch( + "homeassistant.components.izone.discovery.async_discover_all_endpoints", + ) as mock_discover_all, + patch( + "homeassistant.components.izone.discovery.async_discover_endpoint", + ) as mock_discover_one, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_devices_found" + mock_discover_all.assert_not_called() + mock_discover_one.assert_not_called() + + +async def test_homekit_aborts_for_ignored_uid( + hass: HomeAssistant, +) -> None: + """HomeKit setup aborts for UIDs that have been ignored.""" + MockConfigEntry( + domain=DOMAIN, + unique_id="000000001", + source=config_entries.SOURCE_IGNORE, + data={}, + ).add_to_hass(hass) + + with ( + patch( + "homeassistant.components.izone.discovery.async_discover_all_endpoints", + ) as mock_discover_all, + patch( + "homeassistant.components.izone.discovery.async_discover_endpoint", + ) as mock_discover_one, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_HOMEKIT}, + data=_make_homekit_info("iZone 000000001", "192.0.2.3"), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + mock_discover_all.assert_not_called() + mock_discover_one.assert_not_called() + + async def test_async_migrate_entry_clears_legacy_data( hass: HomeAssistant, ) -> None: - """v1→v2 migration clears legacy entry data; UID/host binding is deferred to setup. + """v1→v2 migration clears legacy entry data without network I/O. ConfigEntryNotReady retry semantics only work inside async_setup_entry — raising from async_migrate_entry permanently lands the entry in MIGRATION_ERROR with no - retry path. All network-dependent work is therefore intentionally deferred to - async_setup_entry, which also persists CONF_HOST when the UID is resolved. - """ - entry = MockConfigEntry( - domain=DOMAIN, - version=1, - unique_id=DOMAIN, - title="iZone Aircon", - data={"host": "192.0.2.1"}, - ) - entry.add_to_hass(hass) - controller = create_mock_controller("000000001") - - with ( - patch_discovered_controllers(controller), - patch( - "homeassistant.components.izone.climate.async_setup_entry", - return_value=True, - ), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.version == 2 - assert entry.data == {CONF_HOST: "192.0.2.1"} - assert entry.unique_id == "000000001" - assert entry.title == "iZone 000000001" - - -async def test_async_migrate_entry_does_not_raise_on_discovery_failure( - hass: HomeAssistant, -) -> None: - """Migration succeeds without network calls regardless of discovery state. - - The retry-on-not-ready path only works in async_setup_entry; migration never - makes network calls (see test_async_migrate_entry_clears_legacy_data). + retry path. Setup then heals unique_id=DOMAIN / missing CONF_HOST via discovery. """ entry = MockConfigEntry( domain=DOMAIN, @@ -1283,327 +1100,11 @@ async def test_async_migrate_entry_does_not_raise_on_discovery_failure( entry.add_to_hass(hass) with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - side_effect=OSError, + "homeassistant.components.izone.async_setup_entry", + return_value=True, ): await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert entry.version == 2 assert entry.data == {} - assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY - - -async def test_async_migrate_entry_does_not_raise_for_multiple_eligible( - hass: HomeAssistant, -) -> None: - """Migration does not raise for multiple eligible controllers. - - The multi-controller failure case is handled in async_setup_entry, not here. - """ - entry = MockConfigEntry( - domain=DOMAIN, - version=1, - unique_id=DOMAIN, - title="iZone Aircon", - data={"host": "192.0.2.1"}, - ) - entry.add_to_hass(hass) - controller1 = create_mock_controller("000000001") - controller2 = create_mock_controller("000000002", "192.0.2.2") - - with patch_discovered_controllers([controller1, controller2]): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.version == 2 - assert entry.data == {} - assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR - - -async def test_setup_entry_raises_not_ready_when_discovery_service_fails( - hass: HomeAssistant, -) -> None: - """async_setup_entry raises ConfigEntryNotReady when async_start_discovery_service raises OSError.""" - entry = MockConfigEntry( - domain=DOMAIN, - version=2, - unique_id="000000001", - data={CONF_HOST: "192.0.2.1"}, - ) - entry.add_to_hass(hass) - - with patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - new=AsyncMock(side_effect=OSError), - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY - - -@pytest.mark.parametrize( - ("initial_title", "expected_title"), - [ - pytest.param("iZone Aircon", "iZone 000000001", id="default_title_updated"), - pytest.param("My AC", "My AC", id="custom_title_preserved"), - ], -) -async def test_setup_entry_resolves_legacy_uid_and_updates_title( - hass: HomeAssistant, - initial_title: str, - expected_title: str, -) -> None: - """Legacy entry has its UID and title resolved at setup time, not migration time.""" - entry = MockConfigEntry( - domain=DOMAIN, - version=2, - unique_id=DOMAIN, - title=initial_title, - data={}, - ) - entry.add_to_hass(hass) - controller = create_mock_controller("000000001", "192.0.2.2") - - with ( - patch_discovered_controllers(controller), - patch.object( - hass.config_entries, - "async_forward_entry_setups", - new=AsyncMock(return_value=None), - ), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.unique_id == "000000001" - assert entry.title == expected_title - assert entry.data == {CONF_HOST: "192.0.2.2"} - - -@pytest.mark.parametrize( - ("return_value", "side_effect"), - [ - pytest.param({}, None, id="no_controllers_found"), - pytest.param(None, OSError, id="discovery_oserror"), - ], -) -async def test_setup_entry_raises_not_ready_for_legacy_entry_on_discovery_failure( - hass: HomeAssistant, - return_value: dict | None, - side_effect: type[Exception] | None, -) -> None: - """Legacy entry raises ConfigEntryNotReady when discovery finds nothing or fails. - - Because this is raised from async_setup_entry (not async_migrate_entry), HA - will schedule a retry — unlike the old behaviour where the exception would - permanently land the entry in MIGRATION_ERROR. - """ - entry = MockConfigEntry( - domain=DOMAIN, - version=2, - unique_id=DOMAIN, - title="iZone Aircon", - data={}, - ) - entry.add_to_hass(hass) - - if side_effect is OSError: - patch_ctx = patch( - "homeassistant.components.izone.discovery.async_start_discovery_service", - side_effect=OSError, - ) - else: - patch_ctx = patch_discovered_controllers([]) - - with patch_ctx: - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY - - -async def test_setup_entry_raises_config_error_for_legacy_entry_with_multiple_eligible( - hass: HomeAssistant, -) -> None: - """Legacy entry raises ConfigEntryError when multiple controllers are eligible. - - This is a permanent failure for the legacy entry. The 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. - """ - entry = MockConfigEntry( - domain=DOMAIN, - version=2, - unique_id=DOMAIN, - title="iZone Aircon", - data={}, - ) - entry.add_to_hass(hass) - controllers = { - "000000001": create_mock_controller("000000001", "192.0.2.1"), - "000000002": create_mock_controller("000000002", "192.0.2.2"), - } - - with patch_discovered_controllers( - [controllers["000000001"], controllers["000000002"]] - ): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is config_entries.ConfigEntryState.SETUP_ERROR - - -@pytest.mark.parametrize( - ("excluded_uid", "already_configured_uid"), - [ - pytest.param("000000001", "999999999", id="filtered_by_exclude_list"), - pytest.param("999999999", "000000001", id="filtered_by_configured_entry"), - ], -) -async def test_setup_entry_picks_eligible_controller_after_filtering_for_legacy_entry( - hass: HomeAssistant, - excluded_uid: str, - already_configured_uid: str, -) -> None: - """Legacy entry picks the one controller not filtered out. - - In each case one of two discovered controllers is ineligible — either its - UID is in the exclude list or it is already owned by another config entry. - The dummy UID "999999999" is used for the filter that should have no effect. - """ - await async_load_yaml_exclude(hass, excluded_uid) - entry = MockConfigEntry( - domain=DOMAIN, - version=2, - unique_id=DOMAIN, - title="iZone Aircon", - data={}, - ) - entry.add_to_hass(hass) - MockConfigEntry( - domain=DOMAIN, version=2, unique_id=already_configured_uid, data={} - ).add_to_hass(hass) - controllers = { - "000000001": create_mock_controller("000000001", "192.0.2.1"), - "000000002": create_mock_controller("000000002", "192.0.2.2"), - } - - with ( - patch_discovered_controllers( - [controllers["000000001"], controllers["000000002"]] - ), - patch.object( - hass.config_entries, - "async_forward_entry_setups", - new=AsyncMock(return_value=None), - ), - ): - assert await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.unique_id == "000000002" - assert entry.data == {CONF_HOST: "192.0.2.2"} - - -@pytest.mark.parametrize( - ("excluded_uid", "already_configured_uid"), - [ - pytest.param("000000001", "999999999", id="all_excluded"), - pytest.param("999999999", "000000001", id="all_already_configured"), - ], -) -async def test_setup_entry_raises_not_ready_for_legacy_entry_when_no_eligible_after_filter( - hass: HomeAssistant, - excluded_uid: str, - already_configured_uid: str, -) -> None: - """Legacy entry raises ConfigEntryNotReady when all controllers are filtered out. - - HA will retry async_setup_entry, giving the user time to resolve the filter - configuration. - """ - await async_load_yaml_exclude(hass, excluded_uid) - entry = MockConfigEntry( - domain=DOMAIN, - version=2, - unique_id=DOMAIN, - title="iZone Aircon", - data={}, - ) - entry.add_to_hass(hass) - MockConfigEntry( - domain=DOMAIN, version=2, unique_id=already_configured_uid, data={} - ).add_to_hass(hass) - controller = create_mock_controller("000000001", "192.0.2.1") - - with patch_discovered_controllers(controller): - await hass.config_entries.async_setup(entry.entry_id) - await hass.async_block_till_done() - - assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY - - -def test_discovery_listener_methods_dispatch_expected_signals( - hass: HomeAssistant, -) -> None: - """Listener callbacks dispatch the expected Home Assistant signals.""" - service = izone_discovery.DiscoveryService(hass) - controller = create_mock_controller("000000001", "192.0.2.1") - zone = Mock() - err = RuntimeError("boom") - - with patch( - "homeassistant.components.izone.discovery.async_dispatcher_send" - ) as mock_send: - service.controller_disconnected(controller, err) - service.controller_reconnected(controller) - service.controller_update(controller) - service.zone_update(controller, zone) - - assert mock_send.call_args_list == [ - ((hass, izone_discovery.DISPATCH_CONTROLLER_DISCONNECTED, controller, err),), - ((hass, izone_discovery.DISPATCH_CONTROLLER_RECONNECTED, controller),), - ((hass, izone_discovery.DISPATCH_CONTROLLER_UPDATE, controller),), - ((hass, izone_discovery.DISPATCH_ZONE_UPDATE, controller, zone),), - ] - - -async def test_start_discovery_listener_forwards_discovered_controller_to_flow( - hass: HomeAssistant, - mock_pizone_discovery_service: Mock, -) -> None: - """Discovery dispatcher callback should forward discovered controllers to config flow.""" - captured_listener = None - - def _capture_listener(*args: object) -> Mock: - nonlocal captured_listener - captured_listener = args[2] - return Mock() - - controller = create_mock_controller("000000004", "192.0.2.4") - - 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, - ), - patch( - "homeassistant.components.izone.discovery.async_dispatcher_connect", - side_effect=_capture_listener, - ), - patch( - "homeassistant.components.izone.discovery.async_note_integration_discovery" - ) as mock_note, - ): - await izone_discovery.async_start_discovery_service(hass) - assert captured_listener is not None - captured_listener(controller) - - mock_note.assert_called_once_with(hass, controller) diff --git a/tests/components/izone/test_discovery.py b/tests/components/izone/test_discovery.py index 0639ea92796e..85ef53c11ebe 100644 --- a/tests/components/izone/test_discovery.py +++ b/tests/components/izone/test_discovery.py @@ -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") diff --git a/tests/components/izone/test_init.py b/tests/components/izone/test_init.py new file mode 100644 index 000000000000..f96761c41767 --- /dev/null +++ b/tests/components/izone/test_init.py @@ -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()