"""Support for IntesisHome and airconwithme Smart AC Controllers.""" from __future__ import annotations import logging from random import randrange from typing import NamedTuple from pyintesishome import IHAuthenticationError, IHConnectionError, IntesisHome import voluptuous as vol from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity from homeassistant.components.climate.const import ( ATTR_HVAC_MODE, HVAC_MODE_COOL, HVAC_MODE_DRY, HVAC_MODE_FAN_ONLY, HVAC_MODE_HEAT, HVAC_MODE_HEAT_COOL, HVAC_MODE_OFF, PRESET_BOOST, PRESET_COMFORT, PRESET_ECO, SUPPORT_FAN_MODE, SUPPORT_PRESET_MODE, SUPPORT_SWING_MODE, SUPPORT_TARGET_TEMPERATURE, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, SWING_VERTICAL, ) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_DEVICE, CONF_PASSWORD, CONF_USERNAME, TEMP_CELSIUS, ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType _LOGGER = logging.getLogger(__name__) IH_DEVICE_INTESISHOME = "IntesisHome" IH_DEVICE_AIRCONWITHME = "airconwithme" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_DEVICE, default=IH_DEVICE_INTESISHOME): vol.In( [IH_DEVICE_AIRCONWITHME, IH_DEVICE_INTESISHOME] ), } ) class SwingSettings(NamedTuple): """Settings for swing mode.""" vvane: str hvane: str MAP_IH_TO_HVAC_MODE = { "auto": HVAC_MODE_HEAT_COOL, "cool": HVAC_MODE_COOL, "dry": HVAC_MODE_DRY, "fan": HVAC_MODE_FAN_ONLY, "heat": HVAC_MODE_HEAT, "off": HVAC_MODE_OFF, } MAP_HVAC_MODE_TO_IH = {v: k for k, v in MAP_IH_TO_HVAC_MODE.items()} MAP_IH_TO_PRESET_MODE = { "eco": PRESET_ECO, "comfort": PRESET_COMFORT, "powerful": PRESET_BOOST, } MAP_PRESET_MODE_TO_IH = {v: k for k, v in MAP_IH_TO_PRESET_MODE.items()} IH_SWING_STOP = "auto/stop" IH_SWING_SWING = "swing" MAP_SWING_TO_IH = { SWING_OFF: SwingSettings(vvane=IH_SWING_STOP, hvane=IH_SWING_STOP), SWING_BOTH: SwingSettings(vvane=IH_SWING_SWING, hvane=IH_SWING_SWING), SWING_HORIZONTAL: SwingSettings(vvane=IH_SWING_STOP, hvane=IH_SWING_SWING), SWING_VERTICAL: SwingSettings(vvane=IH_SWING_SWING, hvane=IH_SWING_STOP), } MAP_STATE_ICONS = { HVAC_MODE_COOL: "mdi:snowflake", HVAC_MODE_DRY: "mdi:water-off", HVAC_MODE_FAN_ONLY: "mdi:fan", HVAC_MODE_HEAT: "mdi:white-balance-sunny", HVAC_MODE_HEAT_COOL: "mdi:cached", } async def async_setup_platform( hass: HomeAssistant, config: ConfigType, async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: """Create the IntesisHome climate devices.""" ih_user = config[CONF_USERNAME] ih_pass = config[CONF_PASSWORD] device_type = config[CONF_DEVICE] controller = IntesisHome( ih_user, ih_pass, hass.loop, websession=async_get_clientsession(hass), device_type=device_type, ) try: await controller.poll_status() except IHAuthenticationError: _LOGGER.error("Invalid username or password") return except IHConnectionError as ex: _LOGGER.error("Error connecting to the %s server", device_type) raise PlatformNotReady from ex if ih_devices := controller.get_devices(): async_add_entities( [ IntesisAC(ih_device_id, device, controller) for ih_device_id, device in ih_devices.items() ], True, ) else: _LOGGER.error( "Error getting device list from %s API: %s", device_type, controller.error_message, ) await controller.stop() class IntesisAC(ClimateEntity): """Represents an Intesishome air conditioning device.""" def __init__(self, ih_device_id, ih_device, controller): """Initialize the thermostat.""" self._controller = controller self._device_id = ih_device_id self._ih_device = ih_device self._device_name = ih_device.get("name") self._device_type = controller.device_type self._connected = None self._setpoint_step = 1 self._current_temp = None self._max_temp = None self._hvac_mode_list = [] self._min_temp = None self._target_temp = None self._outdoor_temp = None self._hvac_mode = None self._preset = None self._preset_list = [PRESET_ECO, PRESET_COMFORT, PRESET_BOOST] self._run_hours = None self._rssi = None self._swing_list = [SWING_OFF] self._vvane = None self._hvane = None self._power = False self._fan_speed = None self._support = 0 self._power_consumption_heat = None self._power_consumption_cool = None # Setpoint support if controller.has_setpoint_control(ih_device_id): self._support |= SUPPORT_TARGET_TEMPERATURE # Setup swing list if controller.has_vertical_swing(ih_device_id): self._swing_list.append(SWING_VERTICAL) if controller.has_horizontal_swing(ih_device_id): self._swing_list.append(SWING_HORIZONTAL) if SWING_HORIZONTAL in self._swing_list and SWING_VERTICAL in self._swing_list: self._swing_list.append(SWING_BOTH) if len(self._swing_list) > 1: self._support |= SUPPORT_SWING_MODE # Setup fan speeds self._fan_modes = controller.get_fan_speed_list(ih_device_id) if self._fan_modes: self._support |= SUPPORT_FAN_MODE # Preset support if ih_device.get("climate_working_mode"): self._support |= SUPPORT_PRESET_MODE # Setup HVAC modes if modes := controller.get_mode_list(ih_device_id): mode_list = [MAP_IH_TO_HVAC_MODE[mode] for mode in modes] self._hvac_mode_list.extend(mode_list) self._hvac_mode_list.append(HVAC_MODE_OFF) async def async_added_to_hass(self): """Subscribe to event updates.""" _LOGGER.debug("Added climate device with state: %s", repr(self._ih_device)) await self._controller.add_update_callback(self.async_update_callback) try: await self._controller.connect() except IHConnectionError as ex: _LOGGER.error("Exception connecting to IntesisHome: %s", ex) raise PlatformNotReady from ex @property def name(self): """Return the name of the AC device.""" return self._device_name @property def temperature_unit(self): """Intesishome API uses celsius on the backend.""" return TEMP_CELSIUS @property def extra_state_attributes(self): """Return the device specific state attributes.""" attrs = {} if self._outdoor_temp: attrs["outdoor_temp"] = self._outdoor_temp if self._power_consumption_heat: attrs["power_consumption_heat_kw"] = round( self._power_consumption_heat / 1000, 1 ) if self._power_consumption_cool: attrs["power_consumption_cool_kw"] = round( self._power_consumption_cool / 1000, 1 ) return attrs @property def unique_id(self): """Return unique ID for this device.""" return self._device_id @property def target_temperature_step(self) -> float: """Return whether setpoint should be whole or half degree precision.""" return self._setpoint_step @property def preset_modes(self): """Return a list of HVAC preset modes.""" return self._preset_list @property def preset_mode(self): """Return the current preset mode.""" return self._preset async def async_set_temperature(self, **kwargs): """Set new target temperature.""" if hvac_mode := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(hvac_mode) if temperature := kwargs.get(ATTR_TEMPERATURE): _LOGGER.debug("Setting %s to %s degrees", self._device_type, temperature) await self._controller.set_temperature(self._device_id, temperature) self._target_temp = temperature # Write updated temperature to HA state to avoid flapping (API confirmation is slow) self.async_write_ha_state() async def async_set_hvac_mode(self, hvac_mode): """Set operation mode.""" _LOGGER.debug("Setting %s to %s mode", self._device_type, hvac_mode) if hvac_mode == HVAC_MODE_OFF: self._power = False await self._controller.set_power_off(self._device_id) # Write changes to HA, API can be slow to push changes self.async_write_ha_state() return # First check device is turned on if not self._controller.is_on(self._device_id): self._power = True await self._controller.set_power_on(self._device_id) # Set the mode await self._controller.set_mode(self._device_id, MAP_HVAC_MODE_TO_IH[hvac_mode]) # Send the temperature again in case changing modes has changed it if self._target_temp: await self._controller.set_temperature(self._device_id, self._target_temp) # Updates can take longer than 2 seconds, so update locally self._hvac_mode = hvac_mode self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode): """Set fan mode (from quiet, low, medium, high, auto).""" await self._controller.set_fan_speed(self._device_id, fan_mode) # Updates can take longer than 2 seconds, so update locally self._fan_speed = fan_mode self.async_write_ha_state() async def async_set_preset_mode(self, preset_mode): """Set preset mode.""" ih_preset_mode = MAP_PRESET_MODE_TO_IH.get(preset_mode) await self._controller.set_preset_mode(self._device_id, ih_preset_mode) async def async_set_swing_mode(self, swing_mode): """Set the vertical vane.""" if swing_settings := MAP_SWING_TO_IH.get(swing_mode): await self._controller.set_vertical_vane( self._device_id, swing_settings.vvane ) await self._controller.set_horizontal_vane( self._device_id, swing_settings.hvane ) async def async_update(self): """Copy values from controller dictionary to climate device.""" # Update values from controller's device dictionary self._connected = self._controller.is_connected self._current_temp = self._controller.get_temperature(self._device_id) self._fan_speed = self._controller.get_fan_speed(self._device_id) self._power = self._controller.is_on(self._device_id) self._min_temp = self._controller.get_min_setpoint(self._device_id) self._max_temp = self._controller.get_max_setpoint(self._device_id) self._rssi = self._controller.get_rssi(self._device_id) self._run_hours = self._controller.get_run_hours(self._device_id) self._target_temp = self._controller.get_setpoint(self._device_id) self._outdoor_temp = self._controller.get_outdoor_temperature(self._device_id) # Operation mode mode = self._controller.get_mode(self._device_id) self._hvac_mode = MAP_IH_TO_HVAC_MODE.get(mode) # Preset mode preset = self._controller.get_preset_mode(self._device_id) self._preset = MAP_IH_TO_PRESET_MODE.get(preset) # Swing mode # Climate module only supports one swing setting. self._vvane = self._controller.get_vertical_swing(self._device_id) self._hvane = self._controller.get_horizontal_swing(self._device_id) # Power usage self._power_consumption_heat = self._controller.get_heat_power_consumption( self._device_id ) self._power_consumption_cool = self._controller.get_cool_power_consumption( self._device_id ) async def async_will_remove_from_hass(self): """Shutdown the controller when the device is being removed.""" await self._controller.stop() @property def icon(self): """Return the icon for the current state.""" icon = None if self._power: icon = MAP_STATE_ICONS.get(self._hvac_mode) return icon async def async_update_callback(self, device_id=None): """Let HA know there has been an update from the controller.""" # Track changes in connection state if not self._controller.is_connected and self._connected: # Connection has dropped self._connected = False reconnect_minutes = 1 + randrange(10) _LOGGER.error( "Connection to %s API was lost. Reconnecting in %i minutes", self._device_type, reconnect_minutes, ) # Schedule reconnection async def try_connect(_now): await self._controller.connect() async_call_later(self.hass, reconnect_minutes * 60, try_connect) if self._controller.is_connected and not self._connected: # Connection has been restored self._connected = True _LOGGER.debug("Connection to %s API was restored", self._device_type) if not device_id or self._device_id == device_id: # Update all devices if no device_id was specified _LOGGER.debug( "%s API sent a status update for device %s", self._device_type, device_id, ) self.async_schedule_update_ha_state(True) @property def min_temp(self): """Return the minimum temperature for the current mode of operation.""" return self._min_temp @property def max_temp(self): """Return the maximum temperature for the current mode of operation.""" return self._max_temp @property def should_poll(self): """Poll for updates if pyIntesisHome doesn't have a socket open.""" return False @property def hvac_modes(self): """List of available operation modes.""" return self._hvac_mode_list @property def fan_mode(self): """Return whether the fan is on.""" return self._fan_speed @property def swing_mode(self): """Return current swing mode.""" if self._vvane == IH_SWING_SWING and self._hvane == IH_SWING_SWING: swing = SWING_BOTH elif self._vvane == IH_SWING_SWING: swing = SWING_VERTICAL elif self._hvane == IH_SWING_SWING: swing = SWING_HORIZONTAL else: swing = SWING_OFF return swing @property def fan_modes(self): """List of available fan modes.""" return self._fan_modes @property def swing_modes(self): """List of available swing positions.""" return self._swing_list @property def available(self) -> bool: """If the device hasn't been able to connect, mark as unavailable.""" return self._connected or self._connected is None @property def current_temperature(self): """Return the current temperature.""" return self._current_temp @property def hvac_mode(self): """Return the current mode of operation if unit is on.""" if self._power: return self._hvac_mode return HVAC_MODE_OFF @property def target_temperature(self): """Return the current setpoint temperature if unit is on.""" return self._target_temp @property def supported_features(self): """Return the list of supported features.""" return self._support