"""Support for Honeywell (US) Total Connect Comfort climate systems.""" from __future__ import annotations import datetime from typing import Any import aiosomecomfort from homeassistant.components.climate import ( ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, FAN_AUTO, FAN_DIFFUSE, FAN_ON, PRESET_AWAY, PRESET_NONE, ClimateEntity, ClimateEntityFeature, HVACAction, HVACMode, ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from . import HoneywellData from .const import ( _LOGGER, CONF_COOL_AWAY_TEMPERATURE, CONF_HEAT_AWAY_TEMPERATURE, DOMAIN, ) ATTR_FAN_ACTION = "fan_action" ATTR_PERMANENT_HOLD = "permanent_hold" PRESET_HOLD = "Hold" HEATING_MODES = {"heat", "emheat", "auto"} COOLING_MODES = {"cool", "auto"} HVAC_MODE_TO_HW_MODE = { "SwitchOffAllowed": {HVACMode.OFF: "off"}, "SwitchAutoAllowed": {HVACMode.HEAT_COOL: "auto"}, "SwitchCoolAllowed": {HVACMode.COOL: "cool"}, "SwitchHeatAllowed": {HVACMode.HEAT: "heat"}, } HW_MODE_TO_HVAC_MODE = { "off": HVACMode.OFF, "emheat": HVACMode.HEAT, "heat": HVACMode.HEAT, "cool": HVACMode.COOL, "auto": HVACMode.HEAT_COOL, } HW_MODE_TO_HA_HVAC_ACTION = { "off": HVACAction.IDLE, "fan": HVACAction.FAN, "heat": HVACAction.HEATING, "cool": HVACAction.COOLING, } FAN_MODE_TO_HW = { "fanModeOnAllowed": {FAN_ON: "on"}, "fanModeAutoAllowed": {FAN_AUTO: "auto"}, "fanModeCirculateAllowed": {FAN_DIFFUSE: "circulate"}, } HW_FAN_MODE_TO_HA = { "on": FAN_ON, "auto": FAN_AUTO, "circulate": FAN_DIFFUSE, "follow schedule": FAN_AUTO, } SCAN_INTERVAL = datetime.timedelta(seconds=30) async def async_setup_entry( hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: """Set up the Honeywell thermostat.""" cool_away_temp = entry.options.get(CONF_COOL_AWAY_TEMPERATURE) heat_away_temp = entry.options.get(CONF_HEAT_AWAY_TEMPERATURE) data: HoneywellData = hass.data[DOMAIN][entry.entry_id] async_add_entities( [ HoneywellUSThermostat(data, device, cool_away_temp, heat_away_temp) for device in data.devices.values() ] ) class HoneywellUSThermostat(ClimateEntity): """Representation of a Honeywell US Thermostat.""" _attr_has_entity_name = True def __init__( self, data: HoneywellData, device: aiosomecomfort.device.Device, cool_away_temp: int | None, heat_away_temp: int | None, ) -> None: """Initialize the thermostat.""" self._data = data self._device = device self._cool_away_temp = cool_away_temp self._heat_away_temp = heat_away_temp self._away = False self._attr_unique_id = device.deviceid self._attr_device_info = DeviceInfo( identifiers={(DOMAIN, device.deviceid)}, name=device.name, manufacturer="Honeywell", ) self._attr_temperature_unit = UnitOfTemperature.FAHRENHEIT if device.temperature_unit == "C": self._attr_temperature_unit = UnitOfTemperature.CELSIUS self._attr_preset_modes = [PRESET_NONE, PRESET_AWAY, PRESET_HOLD] # not all honeywell HVACs support all modes self._hvac_mode_map = { key2: value2 for key1, value1 in HVAC_MODE_TO_HW_MODE.items() if device.raw_ui_data[key1] for key2, value2 in value1.items() } self._attr_hvac_modes = list(self._hvac_mode_map) self._attr_supported_features = ( ClimateEntityFeature.PRESET_MODE | ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE ) if device._data["canControlHumidification"]: self._attr_supported_features |= ClimateEntityFeature.TARGET_HUMIDITY if device.raw_ui_data["SwitchEmergencyHeatAllowed"]: self._attr_supported_features |= ClimateEntityFeature.AUX_HEAT if not device._data["hasFan"]: return # not all honeywell fans support all modes self._fan_mode_map = { key2: value2 for key1, value1 in FAN_MODE_TO_HW.items() if device.raw_fan_data[key1] for key2, value2 in value1.items() } self._attr_fan_modes = list(self._fan_mode_map) self._attr_supported_features |= ClimateEntityFeature.FAN_MODE @property def extra_state_attributes(self) -> dict[str, Any]: """Return the device specific state attributes.""" data: dict[str, Any] = {} data[ATTR_FAN_ACTION] = "running" if self._device.fan_running else "idle" data[ATTR_PERMANENT_HOLD] = self._is_permanent_hold() if self._device.raw_dr_data: data["dr_phase"] = self._device.raw_dr_data.get("Phase") return data @property def min_temp(self) -> float: """Return the minimum temperature.""" if self.hvac_mode == HVACMode.COOL: return self._device.raw_ui_data["CoolLowerSetptLimit"] if self.hvac_mode == HVACMode.HEAT: return self._device.raw_ui_data["HeatLowerSetptLimit"] if self.hvac_mode == HVACMode.HEAT_COOL: return min( [ self._device.raw_ui_data["CoolLowerSetptLimit"], self._device.raw_ui_data["HeatLowerSetptLimit"], ] ) return DEFAULT_MIN_TEMP @property def max_temp(self) -> float: """Return the maximum temperature.""" if self.hvac_mode == HVACMode.COOL: return self._device.raw_ui_data["CoolUpperSetptLimit"] if self.hvac_mode == HVACMode.HEAT: return self._device.raw_ui_data["HeatUpperSetptLimit"] if self.hvac_mode == HVACMode.HEAT_COOL: return max( [ self._device.raw_ui_data["CoolUpperSetptLimit"], self._device.raw_ui_data["HeatUpperSetptLimit"], ] ) return DEFAULT_MAX_TEMP @property def current_humidity(self) -> int | None: """Return the current humidity.""" return self._device.current_humidity @property def hvac_mode(self) -> HVACMode: """Return hvac operation ie. heat, cool mode.""" return HW_MODE_TO_HVAC_MODE[self._device.system_mode] @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" if self.hvac_mode == HVACMode.OFF: return None return HW_MODE_TO_HA_HVAC_ACTION.get(self._device.equipment_output_status) @property def current_temperature(self) -> float | None: """Return the current temperature.""" return self._device.current_temperature @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: return self._device.setpoint_cool if self.hvac_mode == HVACMode.HEAT: return self._device.setpoint_heat return None @property def target_temperature_high(self) -> float | None: """Return the highbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._device.setpoint_cool return None @property def target_temperature_low(self) -> float | None: """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self._device.setpoint_heat return None @property def preset_mode(self) -> str | None: """Return the current preset mode, e.g., home, away, temp.""" if self._away: return PRESET_AWAY if self._is_permanent_hold(): return PRESET_HOLD return None @property def is_aux_heat(self) -> bool | None: """Return true if aux heater.""" return self._device.system_mode == "emheat" @property def fan_mode(self) -> str | None: """Return the fan setting.""" return HW_FAN_MODE_TO_HA.get(self._device.fan_mode) def _is_permanent_hold(self) -> bool: heat_status = self._device.raw_ui_data.get("StatusHeat", 0) cool_status = self._device.raw_ui_data.get("StatusCool", 0) return heat_status == 2 or cool_status == 2 async def _set_temperature(self, **kwargs) -> None: """Set new target temperature.""" if (temperature := kwargs.get(ATTR_TEMPERATURE)) is None: return try: # Get current mode mode = self._device.system_mode # Set hold if this is not the case if self._device.hold_heat is False and self._device.hold_cool is False: # Get next period time hour_heat, minute_heat = divmod( self._device.raw_ui_data["HeatNextPeriod"] * 15, 60 ) hour_cool, minute_cool = divmod( self._device.raw_ui_data["CoolNextPeriod"] * 15, 60 ) # Set temporary hold time and temperature if mode in COOLING_MODES: await self._device.set_hold_cool( datetime.time(hour_cool, minute_cool), temperature ) if mode in HEATING_MODES: await self._device.set_hold_heat( datetime.time(hour_heat, minute_heat), temperature ) # Set temperature if not in auto - set the temperature else: if mode == "cool": await self._device.set_setpoint_cool(temperature) if mode == "heat": await self._device.set_setpoint_heat(temperature) except aiosomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" if {HVACMode.COOL, HVACMode.HEAT} & set(self._hvac_mode_map): await self._set_temperature(**kwargs) try: if temperature := kwargs.get(ATTR_TARGET_TEMP_HIGH): await self._device.set_setpoint_cool(temperature) if temperature := kwargs.get(ATTR_TARGET_TEMP_LOW): await self._device.set_setpoint_heat(temperature) except aiosomecomfort.SomeComfortError as err: _LOGGER.error("Invalid temperature %.1f: %s", temperature, err) async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" await self._device.set_fan_mode(self._fan_mode_map[fan_mode]) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target hvac mode.""" await self._device.set_system_mode(self._hvac_mode_map[hvac_mode]) async def _turn_away_mode_on(self) -> None: """Turn away on. Somecomfort does have a proprietary away mode, but it doesn't really work the way it should. For example: If you set a temperature manually it doesn't get overwritten when away mode is switched on. """ self._away = True try: # Get current mode mode = self._device.system_mode except aiosomecomfort.SomeComfortError: _LOGGER.error("Can not get system mode") return try: # Set permanent hold # and Set temperature if mode in COOLING_MODES: await self._device.set_hold_cool(True, self._cool_away_temp) if mode in HEATING_MODES: await self._device.set_hold_heat(True, self._heat_away_temp) except aiosomecomfort.SomeComfortError: _LOGGER.error( "Temperature out of range. Mode: %s, Heat Temperature: %.1f, Cool Temperature: %.1f", mode, self._heat_away_temp, self._cool_away_temp, ) async def _turn_hold_mode_on(self) -> None: """Turn permanent hold on.""" try: # Get current mode mode = self._device.system_mode except aiosomecomfort.SomeComfortError: _LOGGER.error("Can not get system mode") return # Check that we got a valid mode back if mode in HW_MODE_TO_HVAC_MODE: try: # Set permanent hold if mode in COOLING_MODES: await self._device.set_hold_cool(True) if mode in HEATING_MODES: await self._device.set_hold_heat(True) except aiosomecomfort.SomeComfortError: _LOGGER.error("Couldn't set permanent hold") else: _LOGGER.error("Invalid system mode returned: %s", mode) async def _turn_away_mode_off(self) -> None: """Turn away/hold off.""" self._away = False try: # Disabling all hold modes await self._device.set_hold_cool(False) await self._device.set_hold_heat(False) except aiosomecomfort.SomeComfortError: _LOGGER.error("Can not stop hold mode") async def async_set_preset_mode(self, preset_mode: str) -> None: """Set new preset mode.""" if preset_mode == PRESET_AWAY: await self._turn_away_mode_on() elif preset_mode == PRESET_HOLD: self._away = False await self._turn_hold_mode_on() else: await self._turn_away_mode_off() async def async_turn_aux_heat_on(self) -> None: """Turn auxiliary heater on.""" await self._device.set_system_mode("emheat") async def async_turn_aux_heat_off(self) -> None: """Turn auxiliary heater off.""" if HVACMode.HEAT in self.hvac_modes: await self.async_set_hvac_mode(HVACMode.HEAT) else: await self.async_set_hvac_mode(HVACMode.OFF) async def async_update(self) -> None: """Get the latest state from the service.""" try: await self._device.refresh() self._attr_available = True except ( aiosomecomfort.SomeComfortError, OSError, ): try: await self._data.client.login() except aiosomecomfort.AuthError: self._attr_available = False await self.hass.async_create_task( self.hass.config_entries.async_reload(self._data.entry_id) ) except aiosomecomfort.SomeComfortError: self._attr_available = False