mirror of
https://github.com/home-assistant/core.git
synced 2026-08-20 05:10:44 +01:00
531 lines
18 KiB
Python
531 lines
18 KiB
Python
"""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(int), vol.Range(min=0, max=100), 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,
|
|
}
|