"""Support for climate devices through the SmartThings cloud API.""" from __future__ import annotations import asyncio import logging from typing import Any from pysmartthings import Attribute, Capability, Command, SmartThings from homeassistant.components.climate import ( ATTR_HVAC_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, SWING_VERTICAL, ClimateEntity, ClimateEntityFeature, HVACAction, HVACMode, ) from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import FullDevice, SmartThingsConfigEntry from .const import MAIN from .entity import SmartThingsEntity ATTR_OPERATION_STATE = "operation_state" MODE_TO_STATE = { "auto": HVACMode.AUTO, "cool": HVACMode.COOL, "eco": HVACMode.AUTO, "rush hour": HVACMode.AUTO, "emergency heat": HVACMode.HEAT, "heat": HVACMode.HEAT, "off": HVACMode.OFF, } STATE_TO_MODE = { HVACMode.AUTO: "auto", HVACMode.COOL: "cool", HVACMode.HEAT: "heat", HVACMode.OFF: "off", } OPERATING_STATE_TO_ACTION = { "cooling": HVACAction.COOLING, "fan only": HVACAction.FAN, "heating": HVACAction.HEATING, "idle": HVACAction.IDLE, "pending cool": HVACAction.COOLING, "pending heat": HVACAction.HEATING, "vent economizer": HVACAction.FAN, "wind": HVACAction.FAN, } AC_MODE_TO_STATE = { "auto": HVACMode.AUTO, "cool": HVACMode.COOL, "dry": HVACMode.DRY, "coolClean": HVACMode.COOL, "dryClean": HVACMode.DRY, "heat": HVACMode.HEAT, "heatClean": HVACMode.HEAT, "fanOnly": HVACMode.FAN_ONLY, "fan": HVACMode.FAN_ONLY, "wind": HVACMode.FAN_ONLY, } STATE_TO_AC_MODE = { HVACMode.AUTO: "auto", HVACMode.COOL: "cool", HVACMode.DRY: "dry", HVACMode.HEAT: "heat", HVACMode.FAN_ONLY: "fanOnly", } SWING_TO_FAN_OSCILLATION = { SWING_BOTH: "all", SWING_HORIZONTAL: "horizontal", SWING_VERTICAL: "vertical", SWING_OFF: "fixed", } FAN_OSCILLATION_TO_SWING = { value: key for key, value in SWING_TO_FAN_OSCILLATION.items() } WIND = "wind" FAN = "fan" WINDFREE = "windFree" UNIT_MAP = {"C": UnitOfTemperature.CELSIUS, "F": UnitOfTemperature.FAHRENHEIT} _LOGGER = logging.getLogger(__name__) AC_CAPABILITIES = [ Capability.AIR_CONDITIONER_MODE, Capability.AIR_CONDITIONER_FAN_MODE, Capability.SWITCH, Capability.TEMPERATURE_MEASUREMENT, Capability.THERMOSTAT_COOLING_SETPOINT, ] THERMOSTAT_CAPABILITIES = [ Capability.TEMPERATURE_MEASUREMENT, Capability.THERMOSTAT_HEATING_SETPOINT, Capability.THERMOSTAT_MODE, ] async def async_setup_entry( hass: HomeAssistant, entry: SmartThingsConfigEntry, async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Add climate entities for a config entry.""" entry_data = entry.runtime_data entities: list[ClimateEntity] = [ SmartThingsAirConditioner(entry_data.client, device) for device in entry_data.devices.values() if all(capability in device.status[MAIN] for capability in AC_CAPABILITIES) ] entities.extend( SmartThingsThermostat(entry_data.client, device) for device in entry_data.devices.values() if all( capability in device.status[MAIN] for capability in THERMOSTAT_CAPABILITIES ) ) async_add_entities(entities) class SmartThingsThermostat(SmartThingsEntity, ClimateEntity): """Define a SmartThings climate entities.""" _attr_name = None def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, { Capability.THERMOSTAT_FAN_MODE, Capability.THERMOSTAT_MODE, Capability.TEMPERATURE_MEASUREMENT, Capability.THERMOSTAT_HEATING_SETPOINT, Capability.THERMOSTAT_OPERATING_STATE, Capability.THERMOSTAT_COOLING_SETPOINT, Capability.RELATIVE_HUMIDITY_MEASUREMENT, }, ) self._attr_supported_features = self._determine_features() def _determine_features(self) -> ClimateEntityFeature: flags = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.TARGET_TEMPERATURE_RANGE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) if self.supports_capability(Capability.THERMOSTAT_FAN_MODE): flags |= ClimateEntityFeature.FAN_MODE return flags async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" await self.execute_device_command( Capability.THERMOSTAT_FAN_MODE, Command.SET_THERMOSTAT_FAN_MODE, argument=fan_mode, ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" await self.execute_device_command( Capability.THERMOSTAT_MODE, Command.SET_THERMOSTAT_MODE, argument=STATE_TO_MODE[hvac_mode], ) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new operation mode and target temperatures.""" hvac_mode = self.hvac_mode # Operation state if operation_state := kwargs.get(ATTR_HVAC_MODE): await self.async_set_hvac_mode(operation_state) hvac_mode = operation_state # Heat/cool setpoint heating_setpoint = None cooling_setpoint = None if hvac_mode == HVACMode.HEAT: heating_setpoint = kwargs.get(ATTR_TEMPERATURE) elif hvac_mode == HVACMode.COOL: cooling_setpoint = kwargs.get(ATTR_TEMPERATURE) else: heating_setpoint = kwargs.get(ATTR_TARGET_TEMP_LOW) cooling_setpoint = kwargs.get(ATTR_TARGET_TEMP_HIGH) tasks = [] if heating_setpoint is not None: tasks.append( self.execute_device_command( Capability.THERMOSTAT_HEATING_SETPOINT, Command.SET_HEATING_SETPOINT, argument=round(heating_setpoint, 3), ) ) if cooling_setpoint is not None: tasks.append( self.execute_device_command( Capability.THERMOSTAT_COOLING_SETPOINT, Command.SET_COOLING_SETPOINT, argument=round(cooling_setpoint, 3), ) ) await asyncio.gather(*tasks) @property def current_humidity(self) -> float | None: """Return the current humidity.""" if self.supports_capability(Capability.RELATIVE_HUMIDITY_MEASUREMENT): return self.get_attribute_value( Capability.RELATIVE_HUMIDITY_MEASUREMENT, Attribute.HUMIDITY ) return None @property def current_temperature(self) -> float | None: """Return the current temperature.""" return self.get_attribute_value( Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE ) @property def fan_mode(self) -> str | None: """Return the fan setting.""" return self.get_attribute_value( Capability.THERMOSTAT_FAN_MODE, Attribute.THERMOSTAT_FAN_MODE ) @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" return self.get_attribute_value( Capability.THERMOSTAT_FAN_MODE, Attribute.SUPPORTED_THERMOSTAT_FAN_MODES ) @property def hvac_action(self) -> HVACAction | None: """Return the current running hvac operation if supported.""" if not self.supports_capability(Capability.THERMOSTAT_OPERATING_STATE): return None return OPERATING_STATE_TO_ACTION.get( self.get_attribute_value( Capability.THERMOSTAT_OPERATING_STATE, Attribute.THERMOSTAT_OPERATING_STATE, ) ) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" return MODE_TO_STATE.get( self.get_attribute_value( Capability.THERMOSTAT_MODE, Attribute.THERMOSTAT_MODE ) ) @property def hvac_modes(self) -> list[HVACMode]: """Return the list of available operation modes.""" if ( supported_thermostat_modes := self.get_attribute_value( Capability.THERMOSTAT_MODE, Attribute.SUPPORTED_THERMOSTAT_MODES ) ) is None: return [] return [ state for mode in supported_thermostat_modes if (state := MODE_TO_STATE.get(mode)) is not None ] @property def target_temperature(self) -> float | None: """Return the temperature we try to reach.""" if self.hvac_mode == HVACMode.COOL: return self.get_attribute_value( Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT ) if self.hvac_mode == HVACMode.HEAT: return self.get_attribute_value( Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT ) 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.get_attribute_value( Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT ) return None @property def target_temperature_low(self): """Return the lowbound target temperature we try to reach.""" if self.hvac_mode == HVACMode.HEAT_COOL: return self.get_attribute_value( Capability.THERMOSTAT_HEATING_SETPOINT, Attribute.HEATING_SETPOINT ) return None @property def temperature_unit(self) -> str: """Return the unit of measurement.""" # Offline third party thermostats may not have a unit # Since climate always requires a unit, default to Celsius if ( unit := self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ Attribute.TEMPERATURE ].unit ) is None: return UnitOfTemperature.CELSIUS return UNIT_MAP[unit] class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _attr_name = None def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" super().__init__( client, device, { Capability.AIR_CONDITIONER_MODE, Capability.SWITCH, Capability.FAN_OSCILLATION_MODE, Capability.AIR_CONDITIONER_FAN_MODE, Capability.THERMOSTAT_COOLING_SETPOINT, Capability.TEMPERATURE_MEASUREMENT, Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Capability.DEMAND_RESPONSE_LOAD_CONTROL, }, ) self._attr_hvac_modes = self._determine_hvac_modes() self._attr_preset_modes = self._determine_preset_modes() if self.supports_capability(Capability.FAN_OSCILLATION_MODE): self._attr_swing_modes = self._determine_swing_modes() self._attr_supported_features = self._determine_supported_features() def _determine_supported_features(self) -> ClimateEntityFeature: features = ( ClimateEntityFeature.TARGET_TEMPERATURE | ClimateEntityFeature.FAN_MODE | ClimateEntityFeature.TURN_OFF | ClimateEntityFeature.TURN_ON ) if self.supports_capability(Capability.FAN_OSCILLATION_MODE): features |= ClimateEntityFeature.SWING_MODE if (self._attr_preset_modes is not None) and len(self._attr_preset_modes) > 0: features |= ClimateEntityFeature.PRESET_MODE return features async def async_set_fan_mode(self, fan_mode: str) -> None: """Set new target fan mode.""" await self.execute_device_command( Capability.AIR_CONDITIONER_FAN_MODE, Command.SET_FAN_MODE, argument=fan_mode, ) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set new target operation mode.""" if hvac_mode == HVACMode.OFF: await self.async_turn_off() return tasks = [] # Turn on the device if it's off before setting mode. if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": tasks.append(self.async_turn_on()) mode = STATE_TO_AC_MODE[hvac_mode] # If new hvac_mode is HVAC_MODE_FAN_ONLY and AirConditioner support "wind" or "fan" mode the AirConditioner # new mode has to be "wind" or "fan" if hvac_mode == HVACMode.FAN_ONLY: for fan_mode in (WIND, FAN): if fan_mode in self.get_attribute_value( Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ): mode = fan_mode break tasks.append( self.execute_device_command( Capability.AIR_CONDITIONER_MODE, Command.SET_AIR_CONDITIONER_MODE, argument=mode, ) ) await asyncio.gather(*tasks) async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" tasks = [] # operation mode if operation_mode := kwargs.get(ATTR_HVAC_MODE): if operation_mode == HVACMode.OFF: tasks.append(self.async_turn_off()) else: if ( self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off" ): tasks.append(self.async_turn_on()) tasks.append(self.async_set_hvac_mode(operation_mode)) # temperature tasks.append( self.execute_device_command( Capability.THERMOSTAT_COOLING_SETPOINT, Command.SET_COOLING_SETPOINT, argument=kwargs[ATTR_TEMPERATURE], ) ) await asyncio.gather(*tasks) async def async_turn_on(self) -> None: """Turn device on.""" await self.execute_device_command( Capability.SWITCH, Command.ON, ) async def async_turn_off(self) -> None: """Turn device off.""" await self.execute_device_command( Capability.SWITCH, Command.OFF, ) @property def current_temperature(self) -> float | None: """Return the current temperature.""" return self.get_attribute_value( Capability.TEMPERATURE_MEASUREMENT, Attribute.TEMPERATURE ) @property def extra_state_attributes(self) -> dict[str, Any] | None: """Return device specific state attributes. Include attributes from the Demand Response Load Control (drlc) and Power Consumption capabilities. """ if not self.supports_capability(Capability.DEMAND_RESPONSE_LOAD_CONTROL): return None drlc_status = self.get_attribute_value( Capability.DEMAND_RESPONSE_LOAD_CONTROL, Attribute.DEMAND_RESPONSE_LOAD_CONTROL_STATUS, ) res = {} for key in ("duration", "start", "override", "drlcLevel"): if key in drlc_status: dict_key = {"drlcLevel": "drlc_status_level"}.get( key, f"drlc_status_{key}" ) res[dict_key] = drlc_status[key] return res @property def fan_mode(self) -> str: """Return the fan setting.""" return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.FAN_MODE ) @property def fan_modes(self) -> list[str]: """Return the list of available fan modes.""" return self.get_attribute_value( Capability.AIR_CONDITIONER_FAN_MODE, Attribute.SUPPORTED_AC_FAN_MODES ) @property def hvac_mode(self) -> HVACMode | None: """Return current operation ie. heat, cool, idle.""" if self.get_attribute_value(Capability.SWITCH, Attribute.SWITCH) == "off": return HVACMode.OFF return AC_MODE_TO_STATE.get( self.get_attribute_value( Capability.AIR_CONDITIONER_MODE, Attribute.AIR_CONDITIONER_MODE ) ) @property def target_temperature(self) -> float: """Return the temperature we try to reach.""" return self.get_attribute_value( Capability.THERMOSTAT_COOLING_SETPOINT, Attribute.COOLING_SETPOINT ) @property def temperature_unit(self) -> str: """Return the unit of measurement.""" unit = self._internal_state[Capability.TEMPERATURE_MEASUREMENT][ Attribute.TEMPERATURE ].unit assert unit return UNIT_MAP[unit] def _determine_swing_modes(self) -> list[str] | None: """Return the list of available swing modes.""" if ( supported_modes := self.get_attribute_value( Capability.FAN_OSCILLATION_MODE, Attribute.SUPPORTED_FAN_OSCILLATION_MODES, ) ) is None: return None return [FAN_OSCILLATION_TO_SWING.get(m, SWING_OFF) for m in supported_modes] async def async_set_swing_mode(self, swing_mode: str) -> None: """Set swing mode.""" await self.execute_device_command( Capability.FAN_OSCILLATION_MODE, Command.SET_FAN_OSCILLATION_MODE, argument=SWING_TO_FAN_OSCILLATION[swing_mode], ) @property def swing_mode(self) -> str: """Return the swing setting.""" return FAN_OSCILLATION_TO_SWING.get( self.get_attribute_value( Capability.FAN_OSCILLATION_MODE, Attribute.FAN_OSCILLATION_MODE ), SWING_OFF, ) @property def preset_mode(self) -> str | None: """Return the preset mode.""" if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): mode = self.get_attribute_value( Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.AC_OPTIONAL_MODE, ) if mode == WINDFREE: return WINDFREE return None def _determine_preset_modes(self) -> list[str] | None: """Return a list of available preset modes.""" if self.supports_capability(Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE): supported_modes = self.get_attribute_value( Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.SUPPORTED_AC_OPTIONAL_MODE, ) if supported_modes and WINDFREE in supported_modes: return [WINDFREE] return None async def async_set_preset_mode(self, preset_mode: str) -> None: """Set special modes (currently only windFree is supported).""" await self.execute_device_command( Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, argument=preset_mode, ) def _determine_hvac_modes(self) -> list[HVACMode]: """Determine the supported HVAC modes.""" modes = [HVACMode.OFF] if ( ac_modes := self.get_attribute_value( Capability.AIR_CONDITIONER_MODE, Attribute.SUPPORTED_AC_MODES ) ) is not None: modes.extend( state for mode in ac_modes if (state := AC_MODE_TO_STATE.get(mode)) is not None if state not in modes ) return modes