diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py index 865bcc85d53..ea2bd549c4b 100644 --- a/homeassistant/components/nest/__init__.py +++ b/homeassistant/components/nest/__init__.py @@ -96,7 +96,8 @@ CONFIG_SCHEMA = vol.Schema( extra=vol.ALLOW_EXTRA, ) -PLATFORMS = ["sensor", "camera"] +# Platforms for SDM API +PLATFORMS = ["sensor", "camera", "climate"] # Services for the legacy API diff --git a/homeassistant/components/nest/climate.py b/homeassistant/components/nest/climate.py index 7571845e66e..6e457da039c 100644 --- a/homeassistant/components/nest/climate.py +++ b/homeassistant/components/nest/climate.py @@ -1,355 +1,18 @@ -"""Support for Nest thermostats.""" -import logging +"""Support for Nest climate that dispatches between API versions.""" -from nest.nest import APIError -import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType -from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity -from homeassistant.components.climate.const import ( - ATTR_TARGET_TEMP_HIGH, - ATTR_TARGET_TEMP_LOW, - CURRENT_HVAC_COOL, - CURRENT_HVAC_HEAT, - CURRENT_HVAC_IDLE, - FAN_AUTO, - FAN_ON, - HVAC_MODE_AUTO, - HVAC_MODE_COOL, - HVAC_MODE_HEAT, - HVAC_MODE_OFF, - PRESET_AWAY, - PRESET_ECO, - PRESET_NONE, - SUPPORT_FAN_MODE, - SUPPORT_PRESET_MODE, - SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_RANGE, -) -from homeassistant.const import ( - ATTR_TEMPERATURE, - CONF_SCAN_INTERVAL, - TEMP_CELSIUS, - TEMP_FAHRENHEIT, -) -from homeassistant.helpers.dispatcher import async_dispatcher_connect +from .climate_legacy import async_setup_legacy_entry +from .climate_sdm import async_setup_sdm_entry +from .const import DATA_SDM -from . import DATA_NEST, DOMAIN as NEST_DOMAIN -from .const import SIGNAL_NEST_UPDATE -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1))} -) - -NEST_MODE_HEAT_COOL = "heat-cool" -NEST_MODE_ECO = "eco" -NEST_MODE_HEAT = "heat" -NEST_MODE_COOL = "cool" -NEST_MODE_OFF = "off" - -MODE_HASS_TO_NEST = { - HVAC_MODE_AUTO: NEST_MODE_HEAT_COOL, - HVAC_MODE_HEAT: NEST_MODE_HEAT, - HVAC_MODE_COOL: NEST_MODE_COOL, - HVAC_MODE_OFF: NEST_MODE_OFF, -} - -MODE_NEST_TO_HASS = {v: k for k, v in MODE_HASS_TO_NEST.items()} - -ACTION_NEST_TO_HASS = { - "off": CURRENT_HVAC_IDLE, - "heating": CURRENT_HVAC_HEAT, - "cooling": CURRENT_HVAC_COOL, -} - -PRESET_AWAY_AND_ECO = "Away and Eco" - -PRESET_MODES = [PRESET_NONE, PRESET_AWAY, PRESET_ECO, PRESET_AWAY_AND_ECO] - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Nest thermostat. - - No longer in use. - """ - - -async def async_setup_entry(hass, entry, async_add_entities): - """Set up the Nest climate device based on a config entry.""" - temp_unit = hass.config.units.temperature_unit - - thermostats = await hass.async_add_executor_job(hass.data[DATA_NEST].thermostats) - - all_devices = [ - NestThermostat(structure, device, temp_unit) - for structure, device in thermostats - ] - - async_add_entities(all_devices, True) - - -class NestThermostat(ClimateEntity): - """Representation of a Nest thermostat.""" - - def __init__(self, structure, device, temp_unit): - """Initialize the thermostat.""" - self._unit = temp_unit - self.structure = structure - self.device = device - self._fan_modes = [FAN_ON, FAN_AUTO] - - # Set the default supported features - self._support_flags = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE - - # Not all nest devices support cooling and heating remove unused - self._operation_list = [] - - if self.device.can_heat and self.device.can_cool: - self._operation_list.append(HVAC_MODE_AUTO) - self._support_flags = self._support_flags | SUPPORT_TARGET_TEMPERATURE_RANGE - - # Add supported nest thermostat features - if self.device.can_heat: - self._operation_list.append(HVAC_MODE_HEAT) - - if self.device.can_cool: - self._operation_list.append(HVAC_MODE_COOL) - - self._operation_list.append(HVAC_MODE_OFF) - - # feature of device - self._has_fan = self.device.has_fan - if self._has_fan: - self._support_flags = self._support_flags | SUPPORT_FAN_MODE - - # data attributes - self._away = None - self._location = None - self._name = None - self._humidity = None - self._target_temperature = None - self._temperature = None - self._temperature_scale = None - self._mode = None - self._action = None - self._fan = None - self._eco_temperature = None - self._is_locked = None - self._locked_temperature = None - self._min_temperature = None - self._max_temperature = None - - @property - def should_poll(self): - """Do not need poll thanks using Nest streaming API.""" - return False - - async def async_added_to_hass(self): - """Register update signal handler.""" - - async def async_update_state(): - """Update device state.""" - await self.async_update_ha_state(True) - - self.async_on_remove( - async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) - ) - - @property - def supported_features(self): - """Return the list of supported features.""" - return self._support_flags - - @property - def unique_id(self): - """Return unique ID for this device.""" - return self.device.serial - - @property - def device_info(self): - """Return information about the device.""" - return { - "identifiers": {(NEST_DOMAIN, self.device.device_id)}, - "name": self.device.name_long, - "manufacturer": "Nest Labs", - "model": "Thermostat", - "sw_version": self.device.software_version, - } - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def temperature_unit(self): - """Return the unit of measurement.""" - return self._temperature_scale - - @property - def current_temperature(self): - """Return the current temperature.""" - return self._temperature - - @property - def hvac_mode(self): - """Return current operation ie. heat, cool, idle.""" - if self._mode == NEST_MODE_ECO: - if self.device.previous_mode in MODE_NEST_TO_HASS: - return MODE_NEST_TO_HASS[self.device.previous_mode] - - # previous_mode not supported so return the first compatible mode - return self._operation_list[0] - - return MODE_NEST_TO_HASS[self._mode] - - @property - def hvac_action(self): - """Return the current hvac action.""" - return ACTION_NEST_TO_HASS[self._action] - - @property - def target_temperature(self): - """Return the temperature we try to reach.""" - if self._mode not in (NEST_MODE_HEAT_COOL, NEST_MODE_ECO): - return self._target_temperature - return None - - @property - def target_temperature_low(self): - """Return the lower bound temperature we try to reach.""" - if self._mode == NEST_MODE_ECO: - return self._eco_temperature[0] - if self._mode == NEST_MODE_HEAT_COOL: - return self._target_temperature[0] - return None - - @property - def target_temperature_high(self): - """Return the upper bound temperature we try to reach.""" - if self._mode == NEST_MODE_ECO: - return self._eco_temperature[1] - if self._mode == NEST_MODE_HEAT_COOL: - return self._target_temperature[1] - return None - - def set_temperature(self, **kwargs): - """Set new target temperature.""" - - temp = None - target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) - target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) - if self._mode == NEST_MODE_HEAT_COOL: - if target_temp_low is not None and target_temp_high is not None: - temp = (target_temp_low, target_temp_high) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - else: - temp = kwargs.get(ATTR_TEMPERATURE) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) - try: - if temp is not None: - self.device.target = temp - except APIError as api_error: - _LOGGER.error("An error occurred while setting temperature: %s", api_error) - # restore target temperature - self.schedule_update_ha_state(True) - - def set_hvac_mode(self, hvac_mode): - """Set operation mode.""" - self.device.mode = MODE_HASS_TO_NEST[hvac_mode] - - @property - def hvac_modes(self): - """List of available operation modes.""" - return self._operation_list - - @property - def preset_mode(self): - """Return current preset mode.""" - if self._away and self._mode == NEST_MODE_ECO: - return PRESET_AWAY_AND_ECO - - if self._away: - return PRESET_AWAY - - if self._mode == NEST_MODE_ECO: - return PRESET_ECO - - return PRESET_NONE - - @property - def preset_modes(self): - """Return preset modes.""" - return PRESET_MODES - - def set_preset_mode(self, preset_mode): - """Set preset mode.""" - if preset_mode == self.preset_mode: - return - - need_away = preset_mode in (PRESET_AWAY, PRESET_AWAY_AND_ECO) - need_eco = preset_mode in (PRESET_ECO, PRESET_AWAY_AND_ECO) - is_away = self._away - is_eco = self._mode == NEST_MODE_ECO - - if is_away != need_away: - self.structure.away = need_away - - if is_eco != need_eco: - if need_eco: - self.device.mode = NEST_MODE_ECO - else: - self.device.mode = self.device.previous_mode - - @property - def fan_mode(self): - """Return whether the fan is on.""" - if self._has_fan: - # Return whether the fan is on - return FAN_ON if self._fan else FAN_AUTO - # No Fan available so disable slider - return None - - @property - def fan_modes(self): - """List of available fan modes.""" - if self._has_fan: - return self._fan_modes - return None - - def set_fan_mode(self, fan_mode): - """Turn fan on/off.""" - if self._has_fan: - self.device.fan = fan_mode.lower() - - @property - def min_temp(self): - """Identify min_temp in Nest API or defaults if not available.""" - return self._min_temperature - - @property - def max_temp(self): - """Identify max_temp in Nest API or defaults if not available.""" - return self._max_temperature - - def update(self): - """Cache value from Python-nest.""" - self._location = self.device.where - self._name = self.device.name - self._humidity = self.device.humidity - self._temperature = self.device.temperature - self._mode = self.device.mode - self._action = self.device.hvac_state - self._target_temperature = self.device.target - self._fan = self.device.fan - self._away = self.structure.away == "away" - self._eco_temperature = self.device.eco_temperature - self._locked_temperature = self.device.locked_temperature - self._min_temperature = self.device.min_temperature - self._max_temperature = self.device.max_temperature - self._is_locked = self.device.is_locked - if self.device.temperature_scale == "C": - self._temperature_scale = TEMP_CELSIUS - else: - self._temperature_scale = TEMP_FAHRENHEIT +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the climate platform.""" + if DATA_SDM not in entry.data: + await async_setup_legacy_entry(hass, entry, async_add_entities) + return + await async_setup_sdm_entry(hass, entry, async_add_entities) diff --git a/homeassistant/components/nest/climate_legacy.py b/homeassistant/components/nest/climate_legacy.py new file mode 100644 index 00000000000..ee28a0905c3 --- /dev/null +++ b/homeassistant/components/nest/climate_legacy.py @@ -0,0 +1,355 @@ +"""Support for Nest thermostats.""" +import logging + +from nest.nest import APIError +import voluptuous as vol + +from homeassistant.components.climate import PLATFORM_SCHEMA, ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_IDLE, + FAN_AUTO, + FAN_ON, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_OFF, + PRESET_AWAY, + PRESET_ECO, + PRESET_NONE, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.const import ( + ATTR_TEMPERATURE, + CONF_SCAN_INTERVAL, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from . import DATA_NEST, DOMAIN as NEST_DOMAIN +from .const import SIGNAL_NEST_UPDATE + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Optional(CONF_SCAN_INTERVAL): vol.All(vol.Coerce(int), vol.Range(min=1))} +) + +NEST_MODE_HEAT_COOL = "heat-cool" +NEST_MODE_ECO = "eco" +NEST_MODE_HEAT = "heat" +NEST_MODE_COOL = "cool" +NEST_MODE_OFF = "off" + +MODE_HASS_TO_NEST = { + HVAC_MODE_AUTO: NEST_MODE_HEAT_COOL, + HVAC_MODE_HEAT: NEST_MODE_HEAT, + HVAC_MODE_COOL: NEST_MODE_COOL, + HVAC_MODE_OFF: NEST_MODE_OFF, +} + +MODE_NEST_TO_HASS = {v: k for k, v in MODE_HASS_TO_NEST.items()} + +ACTION_NEST_TO_HASS = { + "off": CURRENT_HVAC_IDLE, + "heating": CURRENT_HVAC_HEAT, + "cooling": CURRENT_HVAC_COOL, +} + +PRESET_AWAY_AND_ECO = "Away and Eco" + +PRESET_MODES = [PRESET_NONE, PRESET_AWAY, PRESET_ECO, PRESET_AWAY_AND_ECO] + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the Nest thermostat. + + No longer in use. + """ + + +async def async_setup_legacy_entry(hass, entry, async_add_entities): + """Set up the Nest climate device based on a config entry.""" + temp_unit = hass.config.units.temperature_unit + + thermostats = await hass.async_add_executor_job(hass.data[DATA_NEST].thermostats) + + all_devices = [ + NestThermostat(structure, device, temp_unit) + for structure, device in thermostats + ] + + async_add_entities(all_devices, True) + + +class NestThermostat(ClimateEntity): + """Representation of a Nest thermostat.""" + + def __init__(self, structure, device, temp_unit): + """Initialize the thermostat.""" + self._unit = temp_unit + self.structure = structure + self.device = device + self._fan_modes = [FAN_ON, FAN_AUTO] + + # Set the default supported features + self._support_flags = SUPPORT_TARGET_TEMPERATURE | SUPPORT_PRESET_MODE + + # Not all nest devices support cooling and heating remove unused + self._operation_list = [] + + if self.device.can_heat and self.device.can_cool: + self._operation_list.append(HVAC_MODE_AUTO) + self._support_flags |= SUPPORT_TARGET_TEMPERATURE_RANGE + + # Add supported nest thermostat features + if self.device.can_heat: + self._operation_list.append(HVAC_MODE_HEAT) + + if self.device.can_cool: + self._operation_list.append(HVAC_MODE_COOL) + + self._operation_list.append(HVAC_MODE_OFF) + + # feature of device + self._has_fan = self.device.has_fan + if self._has_fan: + self._support_flags |= SUPPORT_FAN_MODE + + # data attributes + self._away = None + self._location = None + self._name = None + self._humidity = None + self._target_temperature = None + self._temperature = None + self._temperature_scale = None + self._mode = None + self._action = None + self._fan = None + self._eco_temperature = None + self._is_locked = None + self._locked_temperature = None + self._min_temperature = None + self._max_temperature = None + + @property + def should_poll(self): + """Do not need poll thanks using Nest streaming API.""" + return False + + async def async_added_to_hass(self): + """Register update signal handler.""" + + async def async_update_state(): + """Update device state.""" + await self.async_update_ha_state(True) + + self.async_on_remove( + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, async_update_state) + ) + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._support_flags + + @property + def unique_id(self): + """Return unique ID for this device.""" + return self.device.serial + + @property + def device_info(self): + """Return information about the device.""" + return { + "identifiers": {(NEST_DOMAIN, self.device.device_id)}, + "name": self.device.name_long, + "manufacturer": "Nest Labs", + "model": "Thermostat", + "sw_version": self.device.software_version, + } + + @property + def name(self): + """Return the name of the nest, if any.""" + return self._name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self._temperature_scale + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._temperature + + @property + def hvac_mode(self): + """Return current operation ie. heat, cool, idle.""" + if self._mode == NEST_MODE_ECO: + if self.device.previous_mode in MODE_NEST_TO_HASS: + return MODE_NEST_TO_HASS[self.device.previous_mode] + + # previous_mode not supported so return the first compatible mode + return self._operation_list[0] + + return MODE_NEST_TO_HASS[self._mode] + + @property + def hvac_action(self): + """Return the current hvac action.""" + return ACTION_NEST_TO_HASS[self._action] + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + if self._mode not in (NEST_MODE_HEAT_COOL, NEST_MODE_ECO): + return self._target_temperature + return None + + @property + def target_temperature_low(self): + """Return the lower bound temperature we try to reach.""" + if self._mode == NEST_MODE_ECO: + return self._eco_temperature[0] + if self._mode == NEST_MODE_HEAT_COOL: + return self._target_temperature[0] + return None + + @property + def target_temperature_high(self): + """Return the upper bound temperature we try to reach.""" + if self._mode == NEST_MODE_ECO: + return self._eco_temperature[1] + if self._mode == NEST_MODE_HEAT_COOL: + return self._target_temperature[1] + return None + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + + temp = None + target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) + target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) + if self._mode == NEST_MODE_HEAT_COOL: + if target_temp_low is not None and target_temp_high is not None: + temp = (target_temp_low, target_temp_high) + _LOGGER.debug("Nest set_temperature-output-value=%s", temp) + else: + temp = kwargs.get(ATTR_TEMPERATURE) + _LOGGER.debug("Nest set_temperature-output-value=%s", temp) + try: + if temp is not None: + self.device.target = temp + except APIError as api_error: + _LOGGER.error("An error occurred while setting temperature: %s", api_error) + # restore target temperature + self.schedule_update_ha_state(True) + + def set_hvac_mode(self, hvac_mode): + """Set operation mode.""" + self.device.mode = MODE_HASS_TO_NEST[hvac_mode] + + @property + def hvac_modes(self): + """List of available operation modes.""" + return self._operation_list + + @property + def preset_mode(self): + """Return current preset mode.""" + if self._away and self._mode == NEST_MODE_ECO: + return PRESET_AWAY_AND_ECO + + if self._away: + return PRESET_AWAY + + if self._mode == NEST_MODE_ECO: + return PRESET_ECO + + return PRESET_NONE + + @property + def preset_modes(self): + """Return preset modes.""" + return PRESET_MODES + + def set_preset_mode(self, preset_mode): + """Set preset mode.""" + if preset_mode == self.preset_mode: + return + + need_away = preset_mode in (PRESET_AWAY, PRESET_AWAY_AND_ECO) + need_eco = preset_mode in (PRESET_ECO, PRESET_AWAY_AND_ECO) + is_away = self._away + is_eco = self._mode == NEST_MODE_ECO + + if is_away != need_away: + self.structure.away = need_away + + if is_eco != need_eco: + if need_eco: + self.device.mode = NEST_MODE_ECO + else: + self.device.mode = self.device.previous_mode + + @property + def fan_mode(self): + """Return whether the fan is on.""" + if self._has_fan: + # Return whether the fan is on + return FAN_ON if self._fan else FAN_AUTO + # No Fan available so disable slider + return None + + @property + def fan_modes(self): + """List of available fan modes.""" + if self._has_fan: + return self._fan_modes + return None + + def set_fan_mode(self, fan_mode): + """Turn fan on/off.""" + if self._has_fan: + self.device.fan = fan_mode.lower() + + @property + def min_temp(self): + """Identify min_temp in Nest API or defaults if not available.""" + return self._min_temperature + + @property + def max_temp(self): + """Identify max_temp in Nest API or defaults if not available.""" + return self._max_temperature + + def update(self): + """Cache value from Python-nest.""" + self._location = self.device.where + self._name = self.device.name + self._humidity = self.device.humidity + self._temperature = self.device.temperature + self._mode = self.device.mode + self._action = self.device.hvac_state + self._target_temperature = self.device.target + self._fan = self.device.fan + self._away = self.structure.away == "away" + self._eco_temperature = self.device.eco_temperature + self._locked_temperature = self.device.locked_temperature + self._min_temperature = self.device.min_temperature + self._max_temperature = self.device.max_temperature + self._is_locked = self.device.is_locked + if self.device.temperature_scale == "C": + self._temperature_scale = TEMP_CELSIUS + else: + self._temperature_scale = TEMP_FAHRENHEIT diff --git a/homeassistant/components/nest/climate_sdm.py b/homeassistant/components/nest/climate_sdm.py new file mode 100644 index 00000000000..9171f066c19 --- /dev/null +++ b/homeassistant/components/nest/climate_sdm.py @@ -0,0 +1,345 @@ +"""Support for Google Nest SDM climate devices.""" + +from typing import Optional + +from google_nest_sdm.device import Device +from google_nest_sdm.device_traits import FanTrait, TemperatureTrait +from google_nest_sdm.thermostat_traits import ( + ThermostatEcoTrait, + ThermostatHvacTrait, + ThermostatModeTrait, + ThermostatTemperatureSetpointTrait, +) + +from homeassistant.components.climate import ClimateEntity +from homeassistant.components.climate.const import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_OFF, + FAN_OFF, + FAN_ON, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_ECO, + PRESET_NONE, + SUPPORT_FAN_MODE, + SUPPORT_PRESET_MODE, + SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_RANGE, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.typing import HomeAssistantType + +from .const import DOMAIN, SIGNAL_NEST_UPDATE +from .device_info import DeviceInfo + +# Mapping for sdm.devices.traits.ThermostatMode mode field +THERMOSTAT_MODE_MAP = { + "OFF": HVAC_MODE_OFF, + "HEAT": HVAC_MODE_HEAT, + "COOL": HVAC_MODE_COOL, + "HEATCOOL": HVAC_MODE_HEAT_COOL, + "MANUAL_ECO": HVAC_MODE_AUTO, +} +THERMOSTAT_INV_MODE_MAP = {v: k for k, v in THERMOSTAT_MODE_MAP.items()} + +# Mode for sdm.devices.traits.ThermostatEco +THERMOSTAT_ECO_MODE = "MANUAL_ECO" + +# Mapping for sdm.devices.traits.ThermostatHvac status field +THERMOSTAT_HVAC_STATUS_MAP = { + "OFF": CURRENT_HVAC_OFF, + "HEATING": CURRENT_HVAC_HEAT, + "COOLING": CURRENT_HVAC_COOL, +} + +# Mapping to determine the trait that supports the target temperatures +# based on the current HVAC mode +THERMOSTAT_SETPOINT_TRAIT_MAP = { + HVAC_MODE_COOL: ThermostatTemperatureSetpointTrait.NAME, + HVAC_MODE_HEAT: ThermostatTemperatureSetpointTrait.NAME, + HVAC_MODE_HEAT_COOL: ThermostatTemperatureSetpointTrait.NAME, + HVAC_MODE_AUTO: ThermostatEcoTrait.NAME, +} +THERMOSTAT_RANGE_MODES = [HVAC_MODE_HEAT_COOL, HVAC_MODE_AUTO] + +PRESET_MODE_MAP = { + "MANUAL_ECO": PRESET_ECO, + "OFF": PRESET_NONE, +} +PRESET_INV_MODE_MAP = {v: k for k, v in PRESET_MODE_MAP.items()} + +FAN_MODE_MAP = { + "ON": FAN_ON, + "OFF": FAN_OFF, +} +FAN_INV_MODE_MAP = {v: k for k, v in FAN_MODE_MAP.items()} + + +async def async_setup_sdm_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up the client entities.""" + + subscriber = hass.data[DOMAIN][entry.entry_id] + device_manager = await subscriber.async_get_device_manager() + + entities = [] + for device in device_manager.devices.values(): + if ThermostatHvacTrait.NAME in device.traits: + entities.append(ThermostatEntity(device)) + async_add_entities(entities) + + +class ThermostatEntity(ClimateEntity): + """A nest thermostat climate entity.""" + + def __init__(self, device: Device): + """Initialize ThermostatEntity.""" + self._device = device + self._device_info = DeviceInfo(device) + self._supported_features = 0 + + @property + def should_poll(self) -> bool: + """Disable polling since entities have state pushed via pubsub.""" + return False + + @property + def unique_id(self) -> Optional[str]: + """Return a unique ID.""" + # The API "name" field is a unique device identifier. + return self._device.name + + @property + def name(self): + """Return the name of the entity.""" + return self._device_info.device_name + + @property + def device_info(self): + """Return device specific attributes.""" + return self._device_info.device_info + + async def async_added_to_hass(self): + """Run when entity is added to register update signal handler.""" + # Event messages trigger the SIGNAL_NEST_UPDATE, which is intercepted + # here to re-fresh the signals from _device. Unregister this callback + # when the entity is removed. + self._supported_features = self._get_supported_features() + self.async_on_remove( + async_dispatcher_connect( + self.hass, + SIGNAL_NEST_UPDATE, + self.async_write_ha_state, + ) + ) + + @property + def temperature_unit(self): + """Return the unit of temperature measurement for the system.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + if TemperatureTrait.NAME not in self._device.traits: + return None + trait = self._device.traits[TemperatureTrait.NAME] + return trait.ambient_temperature_celsius + + @property + def target_temperature(self): + """Return the temperature currently set to be reached.""" + trait = self._target_temperature_trait + if not trait: + return None + if self.hvac_mode == HVAC_MODE_HEAT: + return trait.heat_celsius + if self.hvac_mode == HVAC_MODE_COOL: + return trait.cool_celsius + return None + + @property + def target_temperature_high(self): + """Return the upper bound target temperature.""" + if self.hvac_mode not in THERMOSTAT_RANGE_MODES: + return None + trait = self._target_temperature_trait + if not trait: + return None + return trait.cool_celsius + + @property + def target_temperature_low(self): + """Return the lower bound target temperature.""" + if self.hvac_mode not in THERMOSTAT_RANGE_MODES: + return None + trait = self._target_temperature_trait + if not trait: + return None + return trait.heat_celsius + + @property + def _target_temperature_trait(self): + """Return the correct trait with a target temp depending on mode.""" + if not self.hvac_mode: + return None + if self.hvac_mode not in THERMOSTAT_SETPOINT_TRAIT_MAP: + return None + trait_name = THERMOSTAT_SETPOINT_TRAIT_MAP[self.hvac_mode] + if trait_name not in self._device.traits: + return None + return self._device.traits[trait_name] + + @property + def hvac_mode(self): + """Return the current operation (e.g. heat, cool, idle).""" + if ThermostatEcoTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatEcoTrait.NAME] + if trait.mode == THERMOSTAT_ECO_MODE: + return HVAC_MODE_AUTO + if ThermostatModeTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatModeTrait.NAME] + if trait.mode in THERMOSTAT_MODE_MAP: + return THERMOSTAT_MODE_MAP[trait.mode] + return HVAC_MODE_OFF + + @property + def hvac_modes(self): + """List of available operation modes.""" + supported_modes = [] + for mode in self._get_device_hvac_modes: + if mode in THERMOSTAT_MODE_MAP: + supported_modes.append(THERMOSTAT_MODE_MAP[mode]) + return supported_modes + + @property + def _get_device_hvac_modes(self): + """Return the set of SDM API hvac modes supported by the device.""" + modes = [] + if ThermostatModeTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatModeTrait.NAME] + modes.extend(trait.available_modes) + if ThermostatEcoTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatEcoTrait.NAME] + modes.extend(trait.available_modes) + return set(modes) + + @property + def hvac_action(self): + """Return the current HVAC action (heating, cooling).""" + if ThermostatHvacTrait.NAME not in self._device.traits: + return None + trait = self._device.traits[ThermostatHvacTrait.NAME] + if trait.status in THERMOSTAT_HVAC_STATUS_MAP: + return THERMOSTAT_HVAC_STATUS_MAP[trait.status] + return None + + @property + def preset_mode(self): + """Return the current active preset.""" + if ThermostatEcoTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatEcoTrait.NAME] + return PRESET_MODE_MAP.get(trait.mode, PRESET_NONE) + return PRESET_NONE + + @property + def preset_modes(self): + """Return the available presets.""" + modes = [] + if ThermostatEcoTrait.NAME in self._device.traits: + trait = self._device.traits[ThermostatEcoTrait.NAME] + for mode in trait.available_modes: + if mode in PRESET_MODE_MAP: + modes.append(PRESET_MODE_MAP[mode]) + return modes + + @property + def fan_mode(self): + """Return the current fan mode.""" + if FanTrait.NAME in self._device.traits: + trait = self._device.traits[FanTrait.NAME] + return FAN_MODE_MAP.get(trait.timer_mode, FAN_OFF) + return FAN_OFF + + @property + def fan_modes(self): + """Return the list of available fan modes.""" + if FanTrait.NAME in self._device.traits: + return list(FAN_INV_MODE_MAP.keys()) + return [] + + @property + def supported_features(self): + """Bitmap of supported features.""" + return self._supported_features + + def _get_supported_features(self): + """Compute the bitmap of supported features from the current state.""" + features = 0 + if HVAC_MODE_HEAT_COOL in self.hvac_modes or HVAC_MODE_AUTO in self.hvac_modes: + features |= SUPPORT_TARGET_TEMPERATURE_RANGE + if HVAC_MODE_HEAT in self.hvac_modes or HVAC_MODE_COOL in self.hvac_modes: + features |= SUPPORT_TARGET_TEMPERATURE + if ThermostatEcoTrait.NAME in self._device.traits: + features |= SUPPORT_PRESET_MODE + if FanTrait.NAME in self._device.traits: + # Fan trait may be present without actually support fan mode + fan_trait = self._device.traits[FanTrait.NAME] + if fan_trait.timer_mode is not None: + features |= SUPPORT_FAN_MODE + return features + + async def async_set_hvac_mode(self, hvac_mode): + """Set new target hvac mode.""" + if hvac_mode not in self.hvac_modes: + return + if hvac_mode not in THERMOSTAT_INV_MODE_MAP: + return + api_mode = THERMOSTAT_INV_MODE_MAP[hvac_mode] + if ThermostatModeTrait.NAME not in self._device.traits: + return + trait = self._device.traits[ThermostatModeTrait.NAME] + await trait.set_mode(api_mode) + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + low_temp = kwargs.get(ATTR_TARGET_TEMP_LOW) + high_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH) + temp = kwargs.get(ATTR_TEMPERATURE) + if ThermostatTemperatureSetpointTrait.NAME not in self._device.traits: + return + trait = self._device.traits[ThermostatTemperatureSetpointTrait.NAME] + if self.preset_mode == PRESET_ECO or self.hvac_mode == HVAC_MODE_HEAT_COOL: + if low_temp and high_temp: + return await trait.set_range(low_temp, high_temp) + elif self.hvac_mode == HVAC_MODE_COOL and temp: + return await trait.set_cool(temp) + elif self.hvac_mode == HVAC_MODE_HEAT and temp: + return await trait.set_heat(temp) + + async def async_set_preset_mode(self, preset_mode): + """Set new target preset mode.""" + if preset_mode not in self.preset_modes: + return + if ThermostatEcoTrait.NAME not in self._device.traits: + return + trait = self._device.traits[ThermostatEcoTrait.NAME] + await trait.set_mode(PRESET_INV_MODE_MAP[preset_mode]) + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + if fan_mode not in self.fan_modes: + return + if FanTrait.NAME not in self._device.traits: + return + trait = self._device.traits[FanTrait.NAME] + await trait.set_timer(FAN_INV_MODE_MAP[fan_mode]) diff --git a/homeassistant/components/nest/manifest.json b/homeassistant/components/nest/manifest.json index 9d688048fb6..0e8c0038914 100644 --- a/homeassistant/components/nest/manifest.json +++ b/homeassistant/components/nest/manifest.json @@ -6,7 +6,7 @@ "documentation": "https://www.home-assistant.io/integrations/nest", "requirements": [ "python-nest==4.1.0", - "google-nest-sdm==0.1.9" + "google-nest-sdm==0.1.12" ], "codeowners": [ "@awarecan", diff --git a/requirements_all.txt b/requirements_all.txt index 0ec18ab5937..e885ca4b9da 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -684,7 +684,7 @@ google-cloud-pubsub==2.1.0 google-cloud-texttospeech==0.4.0 # homeassistant.components.nest -google-nest-sdm==0.1.9 +google-nest-sdm==0.1.12 # homeassistant.components.google_travel_time googlemaps==2.5.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ce350ecc7b7..a67bf3275f7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -352,7 +352,7 @@ google-api-python-client==1.6.4 google-cloud-pubsub==2.1.0 # homeassistant.components.nest -google-nest-sdm==0.1.9 +google-nest-sdm==0.1.12 # homeassistant.components.gree greeclimate==0.9.5 diff --git a/tests/components/nest/climate_sdm_test.py b/tests/components/nest/climate_sdm_test.py new file mode 100644 index 00000000000..db5223b6681 --- /dev/null +++ b/tests/components/nest/climate_sdm_test.py @@ -0,0 +1,829 @@ +""" +Test for Nest climate platform for the Smart Device Management API. + +These tests fake out the subscriber/devicemanager, and are not using a real +pubsub subscriber. +""" + +from google_nest_sdm.device import Device +from google_nest_sdm.event import EventMessage + +from homeassistant.components.climate.const import ( + ATTR_CURRENT_TEMPERATURE, + ATTR_FAN_MODE, + ATTR_FAN_MODES, + ATTR_HVAC_ACTION, + ATTR_HVAC_MODES, + ATTR_PRESET_MODE, + ATTR_PRESET_MODES, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, + CURRENT_HVAC_COOL, + CURRENT_HVAC_HEAT, + CURRENT_HVAC_OFF, + FAN_OFF, + FAN_ON, + HVAC_MODE_AUTO, + HVAC_MODE_COOL, + HVAC_MODE_HEAT, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + PRESET_ECO, + PRESET_NONE, +) +from homeassistant.const import ATTR_TEMPERATURE + +from .common import async_setup_sdm_platform + +from tests.components.climate import common + +PLATFORM = "climate" + + +async def setup_climate(hass, raw_traits=None, auth=None): + """Load Nest climate devices.""" + devices = None + if raw_traits: + traits = raw_traits + traits["sdm.devices.traits.Info"] = {"customName": "My Thermostat"} + devices = { + "some-device-id": Device.MakeDevice( + { + "name": "some-device-id", + "type": "sdm.devices.types.Thermostat", + "traits": traits, + }, + auth=auth, + ), + } + return await async_setup_sdm_platform(hass, PLATFORM, devices) + + +async def test_no_devices(hass): + """Test no devices returned by the api.""" + await setup_climate(hass) + assert len(hass.states.async_all()) == 0 + + +async def test_climate_devices(hass): + """Test no eligible climate devices returned by the api.""" + await setup_climate(hass, {"sdm.devices.traits.CameraImage": {}}) + assert len(hass.states.async_all()) == 0 + + +async def test_thermostat_off(hass): + """Test a thermostat that is not running.""" + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 16.2, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_TEMPERATURE] is None + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] is None + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert ATTR_PRESET_MODE not in thermostat.attributes + assert ATTR_PRESET_MODES not in thermostat.attributes + assert ATTR_FAN_MODE not in thermostat.attributes + assert ATTR_FAN_MODES not in thermostat.attributes + + +async def test_thermostat_heat(hass): + """Test a thermostat that is heating.""" + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": { + "status": "HEATING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 16.2, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 22.0, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_TEMPERATURE] == 22.0 + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] is None + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert ATTR_PRESET_MODE not in thermostat.attributes + assert ATTR_PRESET_MODES not in thermostat.attributes + + +async def test_thermostat_cool(hass): + """Test a thermostat that is cooling.""" + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "COOL", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "coolCelsius": 28.0, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 29.9 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_TEMPERATURE] == 28.0 + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] is None + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] is None + assert ATTR_PRESET_MODE not in thermostat.attributes + assert ATTR_PRESET_MODES not in thermostat.attributes + + +async def test_thermostat_heatcool(hass): + """Test a thermostat that is cooling in heatcool mode.""" + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEATCOOL", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 22.0, + "coolCelsius": 28.0, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 29.9 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] == 22.0 + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] == 28.0 + assert thermostat.attributes[ATTR_TEMPERATURE] is None + assert ATTR_PRESET_MODE not in thermostat.attributes + assert ATTR_PRESET_MODES not in thermostat.attributes + + +async def test_thermostat_eco_off(hass): + """Test a thermostat cooling with eco off.""" + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEATCOOL", + }, + "sdm.devices.traits.ThermostatEco": { + "availableModes": ["MANUAL_ECO", "OFF"], + "mode": "OFF", + "heatCelsius": 20.0, + "coolCelsius": 22.0, + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 22.0, + "coolCelsius": 28.0, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT_COOL + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 29.9 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] == 22.0 + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] == 28.0 + assert thermostat.attributes[ATTR_TEMPERATURE] is None + assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_NONE + assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] + + +async def test_thermostat_eco_on(hass): + """Test a thermostat in eco mode.""" + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": { + "status": "COOLING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEATCOOL", + }, + "sdm.devices.traits.ThermostatEco": { + "availableModes": ["MANUAL_ECO", "OFF"], + "mode": "MANUAL_ECO", + "heatCelsius": 21.0, + "coolCelsius": 29.0, + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 29.9, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 22.0, + "coolCelsius": 28.0, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_AUTO + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_COOL + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 29.9 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_AUTO, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] == 21.0 + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] == 29.0 + assert thermostat.attributes[ATTR_TEMPERATURE] is None + assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_ECO + assert thermostat.attributes[ATTR_PRESET_MODES] == [PRESET_ECO, PRESET_NONE] + + +class FakeAuth: + """A fake implementation of the auth class that records requests.""" + + def __init__(self): + """Initialize FakeAuth.""" + self.method = None + self.url = None + self.json = None + + async def request(self, method, url, json): + """Capure the request arguments for tests to assert on.""" + self.method = method + self.url = url + self.json = json + + +async def test_thermostat_set_hvac_mode(hass): + """Test a thermostat changing hvac modes.""" + auth = FakeAuth() + subscriber = await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + }, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + + await common.async_set_hvac_mode(hass, HVAC_MODE_HEAT) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.ThermostatMode.SetMode", + "params": {"mode": "HEAT"}, + } + + # Local state does not reflect the update + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + + # Simulate pubsub message when mode changes + event = EventMessage( + { + "eventId": "some-event-id", + "timestamp": "2019-01-01T00:00:01Z", + "resourceUpdate": { + "name": "some-device-id", + "traits": { + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", + }, + }, + }, + }, + auth=None, + ) + subscriber.receive_event(event) + await hass.async_block_till_done() # Process dispatch/update signal + + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + + # Simulate pubsub message when the thermostat starts heating + event = EventMessage( + { + "eventId": "some-event-id", + "timestamp": "2019-01-01T00:00:01Z", + "resourceUpdate": { + "name": "some-device-id", + "traits": { + "sdm.devices.traits.ThermostatHvac": { + "status": "HEATING", + }, + }, + }, + }, + auth=None, + ) + subscriber.receive_event(event) + await hass.async_block_till_done() # Process dispatch/update signal + + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_HEAT + + +async def test_thermostat_set_eco_preset(hass): + """Test a thermostat put into eco mode.""" + auth = FakeAuth() + subscriber = await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatEco": { + "availableModes": ["MANUAL_ECO", "OFF"], + "mode": "OFF", + "heatCelsius": 15.0, + "coolCelsius": 28.0, + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + }, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + # Turn on eco mode + await common.async_set_preset_mode(hass, PRESET_ECO) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.ThermostatEco.SetMode", + "params": {"mode": "MANUAL_ECO"}, + } + + # Local state does not reflect the update + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_NONE + + # Simulate pubsub message when mode changes + event = EventMessage( + { + "eventId": "some-event-id", + "timestamp": "2019-01-01T00:00:01Z", + "resourceUpdate": { + "name": "some-device-id", + "traits": { + "sdm.devices.traits.ThermostatEco": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "MANUAL_ECO", + "heatCelsius": 15.0, + "coolCelsius": 28.0, + }, + }, + }, + }, + auth=auth, + ) + subscriber.receive_event(event) + await hass.async_block_till_done() # Process dispatch/update signal + + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_AUTO + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_PRESET_MODE] == PRESET_ECO + + # Turn off eco mode + await common.async_set_preset_mode(hass, PRESET_NONE) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.ThermostatEco.SetMode", + "params": {"mode": "OFF"}, + } + + +async def test_thermostat_set_cool(hass): + """Test a thermostat in cool mode with a temperature change.""" + auth = FakeAuth() + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "COOL", + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "coolCelsius": 25.0, + }, + }, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_COOL + + await common.async_set_temperature(hass, temperature=24.0) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetCool", + "params": {"coolCelsius": 24.0}, + } + + +async def test_thermostat_set_heat(hass): + """Test a thermostat heating mode with a temperature change.""" + auth = FakeAuth() + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 19.0, + }, + }, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + + await common.async_set_temperature(hass, temperature=20.0) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetHeat", + "params": {"heatCelsius": 20.0}, + } + + +async def test_thermostat_set_heat_cool(hass): + """Test a thermostat in heatcool mode with a temperature change.""" + auth = FakeAuth() + await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEATCOOL", + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 19.0, + "coolCelsius": 25.0, + }, + }, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT_COOL + + await common.async_set_temperature( + hass, target_temp_low=20.0, target_temp_high=24.0 + ) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.ThermostatTemperatureSetpoint.SetRange", + "params": {"heatCelsius": 20.0, "coolCelsius": 24.0}, + } + + +async def test_thermostat_fan_off(hass): + """Test a thermostat with the fan not running.""" + await setup_climate( + hass, + { + "sdm.devices.traits.Fan": { + "timerMode": "OFF", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 16.2, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_FAN_MODE] == FAN_OFF + assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + + +async def test_thermostat_fan_on(hass): + """Test a thermostat with the fan running.""" + await setup_climate( + hass, + { + "sdm.devices.traits.Fan": { + "timerMode": "ON", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatHvac": { + "status": "OFF", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 16.2, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + } + assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON + assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + + +async def test_thermostat_set_fan(hass): + """Test a thermostat enabling the fan.""" + auth = FakeAuth() + await setup_climate( + hass, + { + "sdm.devices.traits.Fan": { + "timerMode": "ON", + "timerTimeout": "2019-05-10T03:22:54Z", + }, + "sdm.devices.traits.ThermostatHvac": { + "status": "OFF", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + }, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_FAN_MODE] == FAN_ON + assert thermostat.attributes[ATTR_FAN_MODES] == [FAN_ON, FAN_OFF] + + # Turn off fan mode + await common.async_set_fan_mode(hass, FAN_OFF) + await hass.async_block_till_done() + + assert auth.method == "post" + assert auth.url == "some-device-id:executeCommand" + assert auth.json == { + "command": "sdm.devices.commands.Fan.SetTimer", + "params": {"timerMode": "OFF"}, + } + + +async def test_thermostat_fan_empty(hass): + """Test a fan trait with an empty response.""" + await setup_climate( + hass, + { + "sdm.devices.traits.Fan": {}, + "sdm.devices.traits.ThermostatHvac": {"status": "OFF"}, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "OFF", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 16.2, + }, + }, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_OFF + assert thermostat.attributes[ATTR_HVAC_ACTION] == CURRENT_HVAC_OFF + assert thermostat.attributes[ATTR_CURRENT_TEMPERATURE] == 16.2 + assert set(thermostat.attributes[ATTR_HVAC_MODES]) == { + HVAC_MODE_HEAT, + HVAC_MODE_COOL, + HVAC_MODE_HEAT_COOL, + HVAC_MODE_OFF, + } + assert ATTR_FAN_MODE not in thermostat.attributes + assert ATTR_FAN_MODES not in thermostat.attributes + + +async def test_thermostat_target_temp(hass): + """Test a thermostat changing hvac modes and affected on target temps.""" + auth = FakeAuth() + subscriber = await setup_climate( + hass, + { + "sdm.devices.traits.ThermostatHvac": { + "status": "HEATING", + }, + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEAT", + }, + "sdm.devices.traits.Temperature": { + "ambientTemperatureCelsius": 20.1, + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 23.0, + }, + }, + auth=auth, + ) + + assert len(hass.states.async_all()) == 1 + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT + assert thermostat.attributes[ATTR_TEMPERATURE] == 23.0 + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] is None + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] is None + + # Simulate pubsub message changing modes + event = EventMessage( + { + "eventId": "some-event-id", + "timestamp": "2019-01-01T00:00:01Z", + "resourceUpdate": { + "name": "some-device-id", + "traits": { + "sdm.devices.traits.ThermostatMode": { + "availableModes": ["HEAT", "COOL", "HEATCOOL", "OFF"], + "mode": "HEATCOOL", + }, + "sdm.devices.traits.ThermostatTemperatureSetpoint": { + "heatCelsius": 22.0, + "coolCelsius": 28.0, + }, + }, + }, + }, + auth=None, + ) + subscriber.receive_event(event) + await hass.async_block_till_done() # Process dispatch/update signal + + thermostat = hass.states.get("climate.my_thermostat") + assert thermostat is not None + assert thermostat.state == HVAC_MODE_HEAT_COOL + assert thermostat.attributes[ATTR_TARGET_TEMP_LOW] == 22.0 + assert thermostat.attributes[ATTR_TARGET_TEMP_HIGH] == 28.0 + assert thermostat.attributes[ATTR_TEMPERATURE] is None