"""Support for the iZone HVAC.""" from collections.abc import Mapping import logging from typing import Any, override from pizone import Controller, Zone import voluptuous as vol from homeassistant.components.climate import ( FAN_AUTO, FAN_HIGH, FAN_LOW, FAN_MEDIUM, FAN_TOP, PRESET_ECO, PRESET_NONE, ClimateEntity, ClimateEntityFeature, HVACMode, ) from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, PRECISION_TENTHS, UnitOfTemperature, ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_platform from homeassistant.helpers.device_registry import DeviceInfo 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 DOMAIN from .coordinator import IZoneConfigEntry, IZoneCoordinator from .entity import IZoneCoordinatorEntity _LOGGER = logging.getLogger(__name__) _IZONE_FAN_TO_HA = { Controller.Fan.LOW: FAN_LOW, Controller.Fan.MED: FAN_MEDIUM, Controller.Fan.HIGH: FAN_HIGH, Controller.Fan.TOP: FAN_TOP, Controller.Fan.AUTO: FAN_AUTO, } ATTR_AIRFLOW = "airflow" IZONE_SERVICE_AIRFLOW_MIN = "airflow_min" IZONE_SERVICE_AIRFLOW_MAX = "airflow_max" IZONE_SERVICE_AIRFLOW_SCHEMA: VolDictType = { vol.Required(ATTR_AIRFLOW): vol.All( vol.Coerce(float), vol.In(range(0, 101, 5)), vol.Coerce(int), msg="invalid airflow", ), } async def async_setup_entry( hass: HomeAssistant, entry: IZoneConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """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( IZONE_SERVICE_AIRFLOW_MIN, IZONE_SERVICE_AIRFLOW_SCHEMA, "async_set_airflow_min", ) platform.async_register_entity_service( IZONE_SERVICE_AIRFLOW_MAX, IZONE_SERVICE_AIRFLOW_SCHEMA, "async_set_airflow_max", ) class ControllerDevice(IZoneCoordinatorEntity, ClimateEntity): """Representation of iZone Controller.""" _attr_precision = PRECISION_TENTHS _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_has_entity_name = True _attr_name = None _attr_target_temperature_step = 0.5 def __init__(self, coordinator: IZoneCoordinator) -> None: """Initialise ControllerDevice.""" super().__init__(coordinator) controller = coordinator.controller self._unknown_fan_logged = False self._unknown_mode_logged = False self._attr_supported_features = ( ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) # Typically, iZone will automatically set the controller's target # temperature; but there are situations where Home Assistant should be # allowed to set it: # # 1. The controller is in RAS mode (i.e., not in master/slave mode). # 2. The controller is in master mode, but the control zone is set to # zone 13 (i.e., the master unit itself), or an invalid zone # (greater than the total number of zones). In this case, the # master unit is controlling the temperature directly. # 3. Any of the zones do not have a temperature sensor if ( controller.ras_mode == "RAS" or ( controller.ras_mode == "master" and controller.zone_ctrl > controller.zones_total ) or any(zone.temp_current is None for zone in controller.zones) ): self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE self._state_to_pizone = { HVACMode.COOL: Controller.Mode.COOL, HVACMode.HEAT: Controller.Mode.HEAT, HVACMode.HEAT_COOL: Controller.Mode.AUTO, HVACMode.FAN_ONLY: Controller.Mode.VENT, HVACMode.DRY: Controller.Mode.DRY, } if controller.free_air_enabled: self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE self._fan_to_pizone = {} for fan in controller.fan_modes: self._fan_to_pizone[_IZONE_FAN_TO_HA[fan]] = fan self._attr_unique_id = controller.device_uid self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, controller.device_uid)}, manufacturer="IZone", model=controller.sys_type, name=f"iZone Controller {controller.device_uid}", ) # Create the zones self.zones = {} for zone in controller.zones: self.zones[zone] = ZoneDevice(coordinator, self, zone) @property @override def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "supply_temperature": show_temp( self.hass, self.supply_temperature, self.temperature_unit, self.precision, ), "temp_setpoint": show_temp( self.hass, self.controller.temp_setpoint, self.temperature_unit, PRECISION_HALVES, ), "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, # report target temp as extra state attribute "control_zone_setpoint": show_temp( self.hass, self.control_zone_setpoint, self.temperature_unit, PRECISION_HALVES, ), } @property @override def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" if not self.controller.is_on: return HVACMode.OFF try: mode = self.controller.mode except ValueError as err: if not self._unknown_mode_logged: _LOGGER.warning( "Unknown SysMode from iZone controller %s (%s); " "HVAC mode unavailable. Please open an issue at " "https://github.com/home-assistant/core/issues " "and attach diagnostics", self.controller.device_uid, err, ) self._unknown_mode_logged = True return None if mode is Controller.Mode.FREE_AIR: return HVACMode.FAN_ONLY for key, value in self._state_to_pizone.items(): if value is mode: return key raise RuntimeError("Should be unreachable") @property @override def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" if self.controller.free_air: return [HVACMode.OFF, HVACMode.FAN_ONLY] return [HVACMode.OFF, *self._state_to_pizone] @property @override def preset_mode(self) -> str: """Eco mode is external air.""" return PRESET_ECO if self.controller.free_air else PRESET_NONE @property @override def preset_modes(self) -> list[str]: """Available preset modes, normal or eco.""" if self.controller.free_air_enabled: return [PRESET_NONE, PRESET_ECO] return [PRESET_NONE] @property @override def current_temperature(self) -> float | None: """Return the current temperature.""" if self.controller.free_air: return self.controller.temp_supply return self.controller.temp_return @property def control_zone_name(self): """Return the zone that currently controls the AC unit. Only relevant if target temp not set by controller. """ if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: return None 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.name @property def control_zone_setpoint(self) -> float | None: """Return the temperature setpoint of the controlling zone. Only relevant if target temp not set by controller. """ if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: return None 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 @override def target_temperature(self) -> float | None: """Return the temperature we try to reach. Either from control zone or master unit. """ if self._attr_supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: 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 @property @override def fan_mode(self) -> str | None: """Return the fan setting.""" try: return _IZONE_FAN_TO_HA[self.controller.fan] except ValueError as err: if not self._unknown_fan_logged: _LOGGER.warning( "Unknown SysFan from iZone controller %s (%s); " "reporting fan_mode unknown. Please open an issue at " "https://github.com/home-assistant/core/issues " "and attach diagnostics", self.controller.device_uid, err, ) self._unknown_fan_logged = True # Display-only; omit from fan_modes so it cannot be selected. return "unknown" @property @override def fan_modes(self) -> list[str] | None: """Return the list of available fan modes.""" return list(self._fan_to_pizone) @property @override def min_temp(self) -> float: """Return the minimum temperature.""" return self.controller.temp_min @property @override def max_temp(self) -> float: """Return the maximum temperature.""" return self.controller.temp_max @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if not self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE: self.async_schedule_update_ha_state(True) return if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: 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._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._async_run_and_update(self.controller.set_on(False)) return 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._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._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._async_run_and_update(self.controller.set_on(True)) class ZoneDevice(IZoneCoordinatorEntity, ClimateEntity): """Representation of iZone Zone.""" _attr_precision = PRECISION_TENTHS _attr_has_entity_name = True _attr_name = None _attr_temperature_unit = UnitOfTemperature.CELSIUS _attr_target_temperature_step = 0.5 _attr_supported_features = ( ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) def __init__( self, coordinator: IZoneCoordinator, controller_entity: ControllerDevice, zone: Zone, ) -> None: """Initialise ZoneDevice.""" super().__init__(coordinator) self._zone = zone if zone.type is not Zone.Type.AUTO: self._state_to_pizone = { HVACMode.OFF: Zone.Mode.CLOSE, HVACMode.FAN_ONLY: Zone.Mode.OPEN, } else: self._state_to_pizone = { HVACMode.OFF: Zone.Mode.CLOSE, HVACMode.FAN_ONLY: Zone.Mode.OPEN, HVACMode.HEAT_COOL: Zone.Mode.AUTO, } self._attr_supported_features |= ClimateEntityFeature.TARGET_TEMPERATURE 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_entity.unique_id, zone.index) # type:ignore[arg-type] }, manufacturer="IZone", model=zone.type.name.title(), name=zone.name.title(), via_device_id=dr.async_get_device_id_by_identifier( coordinator.hass, (DOMAIN, controller_entity.unique_id), config_entry_id=coordinator.config_entry.entry_id, ), ) @property @override def supported_features(self) -> ClimateEntityFeature: """Return the list of supported features.""" if self._zone.mode is Zone.Mode.AUTO: return self._attr_supported_features return self._attr_supported_features & ~ClimateEntityFeature.TARGET_TEMPERATURE @property @override def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" mode = self._zone.mode for key, value in self._state_to_pizone.items(): if value is mode: return key return None @property @override def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" return list(self._state_to_pizone) @property @override def current_temperature(self) -> float | None: """Return the current temperature.""" return self._zone.temp_current @property @override def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self._zone.type is not Zone.Type.AUTO: return None return self._zone.temp_setpoint @property @override def min_temp(self) -> float: """Return the minimum temperature.""" return self.controller.temp_min @property @override def max_temp(self) -> float: """Return the maximum temperature.""" return self.controller.temp_max @property def airflow_min(self): """Return the minimum air flow.""" return self._zone.airflow_min @property def airflow_max(self): """Return the maximum air flow.""" return self._zone.airflow_max async def async_set_airflow_min(self, **kwargs): """Set new airflow minimum.""" await self._async_run_and_update( self._zone.set_airflow_min(int(kwargs[ATTR_AIRFLOW])) ) async def async_set_airflow_max(self, **kwargs): """Set new airflow maximum.""" await self._async_run_and_update( self._zone.set_airflow_max(int(kwargs[ATTR_AIRFLOW])) ) @override async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if self._zone.mode is not Zone.Mode.AUTO: return if (temp := kwargs.get(ATTR_TEMPERATURE)) is not None: 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._async_run_and_update(self._zone.set_mode(mode)) @property def is_on(self) -> bool: """Return true if on.""" return self._zone.mode is not Zone.Mode.CLOSE @override async def async_turn_on(self) -> None: """Turn device on (open zone).""" if self._zone.type is Zone.Type.AUTO: await self._async_run_and_update(self._zone.set_mode(Zone.Mode.AUTO)) else: 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._async_run_and_update(self._zone.set_mode(Zone.Mode.CLOSE)) @property def zone_index(self): """Return the zone index for matching to CtrlZone.""" return self._zone.index @property @override def extra_state_attributes(self) -> Mapping[str, Any]: """Return the optional state attributes.""" return { "airflow_max": self._zone.airflow_max, "airflow_min": self._zone.airflow_min, "zone_index": self.zone_index, }