hass-core/homeassistant/components/nexia/climate.py
J. Nick Koston b8fdebd05c
Add aircleaner and humidify service to nexia climate (#33078)
* Add aircleaner and humidify service to nexia climate

* These were removed from the original merge to reduce review scope

* Additional tests for binary_sensor, sensor, and climate states

* Switch to signals for services

Get rid of everywhere we call device and change to zone or thermostat
as it was too confusing

Renames to make it clear that zone and thermostat are tightly coupled

* Make scene activation responsive

* no need to use update for only one key/value

* stray comma

* use async_call_later

* its async, need ()s

* cleaner

* merge entity platform services testing branch
2020-03-23 11:01:48 -05:00

468 lines
15 KiB
Python

"""Support for Nexia / Trane XL thermostats."""
import logging
from nexia.const import (
FAN_MODES,
OPERATION_MODE_AUTO,
OPERATION_MODE_COOL,
OPERATION_MODE_HEAT,
OPERATION_MODE_OFF,
SYSTEM_STATUS_COOL,
SYSTEM_STATUS_HEAT,
SYSTEM_STATUS_IDLE,
UNIT_FAHRENHEIT,
)
import voluptuous as vol
from homeassistant.components.climate import ClimateDevice
from homeassistant.components.climate.const import (
ATTR_HUMIDITY,
ATTR_MAX_HUMIDITY,
ATTR_MIN_HUMIDITY,
ATTR_TARGET_TEMP_HIGH,
ATTR_TARGET_TEMP_LOW,
CURRENT_HVAC_COOL,
CURRENT_HVAC_HEAT,
CURRENT_HVAC_IDLE,
CURRENT_HVAC_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_COOL,
HVAC_MODE_HEAT,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_OFF,
SUPPORT_AUX_HEAT,
SUPPORT_FAN_MODE,
SUPPORT_PRESET_MODE,
SUPPORT_TARGET_HUMIDITY,
SUPPORT_TARGET_TEMPERATURE,
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.const import (
ATTR_ENTITY_ID,
ATTR_TEMPERATURE,
TEMP_CELSIUS,
TEMP_FAHRENHEIT,
)
from homeassistant.helpers import entity_platform
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import (
ATTR_AIRCLEANER_MODE,
ATTR_DEHUMIDIFY_SETPOINT,
ATTR_DEHUMIDIFY_SUPPORTED,
ATTR_HUMIDIFY_SETPOINT,
ATTR_HUMIDIFY_SUPPORTED,
ATTR_ZONE_STATUS,
DOMAIN,
NEXIA_DEVICE,
SIGNAL_THERMOSTAT_UPDATE,
SIGNAL_ZONE_UPDATE,
UPDATE_COORDINATOR,
)
from .entity import NexiaThermostatZoneEntity
from .util import percent_conv
SERVICE_SET_AIRCLEANER_MODE = "set_aircleaner_mode"
SERVICE_SET_HUMIDIFY_SETPOINT = "set_humidify_setpoint"
SET_AIRCLEANER_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_AIRCLEANER_MODE): cv.string,
}
)
SET_HUMIDITY_SCHEMA = vol.Schema(
{
vol.Required(ATTR_ENTITY_ID): cv.entity_ids,
vol.Required(ATTR_HUMIDITY): vol.All(
vol.Coerce(int), vol.Range(min=35, max=65)
),
}
)
_LOGGER = logging.getLogger(__name__)
#
# Nexia has two bits to determine hvac mode
# There are actually eight states so we map to
# the most significant state
#
# 1. Zone Mode : Auto / Cooling / Heating / Off
# 2. Run Mode : Hold / Run Schedule
#
#
HA_TO_NEXIA_HVAC_MODE_MAP = {
HVAC_MODE_HEAT: OPERATION_MODE_HEAT,
HVAC_MODE_COOL: OPERATION_MODE_COOL,
HVAC_MODE_HEAT_COOL: OPERATION_MODE_AUTO,
HVAC_MODE_AUTO: OPERATION_MODE_AUTO,
HVAC_MODE_OFF: OPERATION_MODE_OFF,
}
NEXIA_TO_HA_HVAC_MODE_MAP = {
value: key for key, value in HA_TO_NEXIA_HVAC_MODE_MAP.items()
}
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up climate for a Nexia device."""
nexia_data = hass.data[DOMAIN][config_entry.entry_id]
nexia_home = nexia_data[NEXIA_DEVICE]
coordinator = nexia_data[UPDATE_COORDINATOR]
platform = entity_platform.current_platform.get()
platform.async_register_entity_service(
SERVICE_SET_HUMIDIFY_SETPOINT,
SET_HUMIDITY_SCHEMA,
SERVICE_SET_HUMIDIFY_SETPOINT,
)
platform.async_register_entity_service(
SERVICE_SET_AIRCLEANER_MODE, SET_AIRCLEANER_SCHEMA, SERVICE_SET_AIRCLEANER_MODE,
)
entities = []
for thermostat_id in nexia_home.get_thermostat_ids():
thermostat = nexia_home.get_thermostat_by_id(thermostat_id)
for zone_id in thermostat.get_zone_ids():
zone = thermostat.get_zone_by_id(zone_id)
entities.append(NexiaZone(coordinator, zone))
async_add_entities(entities, True)
class NexiaZone(NexiaThermostatZoneEntity, ClimateDevice):
"""Provides Nexia Climate support."""
def __init__(self, coordinator, zone):
"""Initialize the thermostat."""
super().__init__(
coordinator, zone, name=zone.get_name(), unique_id=zone.zone_id
)
self._undo_humidfy_dispatcher = None
self._undo_aircleaner_dispatcher = None
# The has_* calls are stable for the life of the device
# and do not do I/O
self._has_relative_humidity = self._thermostat.has_relative_humidity()
self._has_emergency_heat = self._thermostat.has_emergency_heat()
self._has_humidify_support = self._thermostat.has_humidify_support()
self._has_dehumidify_support = self._thermostat.has_dehumidify_support()
@property
def supported_features(self):
"""Return the list of supported features."""
supported = (
SUPPORT_TARGET_TEMPERATURE_RANGE
| SUPPORT_TARGET_TEMPERATURE
| SUPPORT_FAN_MODE
| SUPPORT_PRESET_MODE
)
if self._has_humidify_support or self._has_dehumidify_support:
supported |= SUPPORT_TARGET_HUMIDITY
if self._has_emergency_heat:
supported |= SUPPORT_AUX_HEAT
return supported
@property
def is_fan_on(self):
"""Blower is on."""
return self._thermostat.is_blower_active()
@property
def temperature_unit(self):
"""Return the unit of measurement."""
return TEMP_CELSIUS if self._thermostat.get_unit() == "C" else TEMP_FAHRENHEIT
@property
def current_temperature(self):
"""Return the current temperature."""
return self._zone.get_temperature()
@property
def fan_mode(self):
"""Return the fan setting."""
return self._thermostat.get_fan_mode()
@property
def fan_modes(self):
"""Return the list of available fan modes."""
return FAN_MODES
@property
def min_temp(self):
"""Minimum temp for the current setting."""
return (self._thermostat.get_setpoint_limits())[0]
@property
def max_temp(self):
"""Maximum temp for the current setting."""
return (self._thermostat.get_setpoint_limits())[1]
def set_fan_mode(self, fan_mode):
"""Set new target fan mode."""
self._thermostat.set_fan_mode(fan_mode)
self._signal_thermostat_update()
@property
def preset_mode(self):
"""Preset that is active."""
return self._zone.get_preset()
@property
def preset_modes(self):
"""All presets."""
return self._zone.get_presets()
def set_humidity(self, humidity):
"""Dehumidify target."""
self._thermostat.set_dehumidify_setpoint(humidity / 100.0)
self._signal_thermostat_update()
@property
def target_humidity(self):
"""Humidity indoors setpoint."""
if self._has_dehumidify_support:
return percent_conv(self._thermostat.get_dehumidify_setpoint())
if self._has_humidify_support:
return percent_conv(self._thermostat.get_humidify_setpoint())
return None
@property
def current_humidity(self):
"""Humidity indoors."""
if self._has_relative_humidity:
return percent_conv(self._thermostat.get_relative_humidity())
return None
@property
def target_temperature(self):
"""Temperature we try to reach."""
current_mode = self._zone.get_current_mode()
if current_mode == OPERATION_MODE_COOL:
return self._zone.get_cooling_setpoint()
if current_mode == OPERATION_MODE_HEAT:
return self._zone.get_heating_setpoint()
return None
@property
def target_temperature_step(self):
"""Step size of temperature units."""
if self._thermostat.get_unit() == UNIT_FAHRENHEIT:
return 1.0
return 0.5
@property
def target_temperature_high(self):
"""Highest temperature we are trying to reach."""
current_mode = self._zone.get_current_mode()
if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
return None
return self._zone.get_cooling_setpoint()
@property
def target_temperature_low(self):
"""Lowest temperature we are trying to reach."""
current_mode = self._zone.get_current_mode()
if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
return None
return self._zone.get_heating_setpoint()
@property
def hvac_action(self) -> str:
"""Operation ie. heat, cool, idle."""
system_status = self._thermostat.get_system_status()
zone_called = self._zone.is_calling()
if self._zone.get_requested_mode() == OPERATION_MODE_OFF:
return CURRENT_HVAC_OFF
if not zone_called:
return CURRENT_HVAC_IDLE
if system_status == SYSTEM_STATUS_COOL:
return CURRENT_HVAC_COOL
if system_status == SYSTEM_STATUS_HEAT:
return CURRENT_HVAC_HEAT
if system_status == SYSTEM_STATUS_IDLE:
return CURRENT_HVAC_IDLE
return CURRENT_HVAC_IDLE
@property
def hvac_mode(self):
"""Return current mode, as the user-visible name."""
mode = self._zone.get_requested_mode()
hold = self._zone.is_in_permanent_hold()
# If the device is in hold mode with
# OPERATION_MODE_AUTO
# overriding the schedule by still
# heating and cooling to the
# temp range.
if hold and mode == OPERATION_MODE_AUTO:
return HVAC_MODE_HEAT_COOL
return NEXIA_TO_HA_HVAC_MODE_MAP[mode]
@property
def hvac_modes(self):
"""List of HVAC available modes."""
return [
HVAC_MODE_OFF,
HVAC_MODE_AUTO,
HVAC_MODE_HEAT_COOL,
HVAC_MODE_HEAT,
HVAC_MODE_COOL,
]
def set_temperature(self, **kwargs):
"""Set target temperature."""
new_heat_temp = kwargs.get(ATTR_TARGET_TEMP_LOW, None)
new_cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH, None)
set_temp = kwargs.get(ATTR_TEMPERATURE, None)
deadband = self._thermostat.get_deadband()
cur_cool_temp = self._zone.get_cooling_setpoint()
cur_heat_temp = self._zone.get_heating_setpoint()
(min_temp, max_temp) = self._thermostat.get_setpoint_limits()
# Check that we're not going to hit any minimum or maximum values
if new_heat_temp and new_heat_temp + deadband > max_temp:
new_heat_temp = max_temp - deadband
if new_cool_temp and new_cool_temp - deadband < min_temp:
new_cool_temp = min_temp + deadband
# Check that we're within the deadband range, fix it if we're not
if new_heat_temp and new_heat_temp != cur_heat_temp:
if new_cool_temp - new_heat_temp < deadband:
new_cool_temp = new_heat_temp + deadband
if new_cool_temp and new_cool_temp != cur_cool_temp:
if new_cool_temp - new_heat_temp < deadband:
new_heat_temp = new_cool_temp - deadband
self._zone.set_heat_cool_temp(
heat_temperature=new_heat_temp,
cool_temperature=new_cool_temp,
set_temperature=set_temp,
)
self._signal_zone_update()
@property
def is_aux_heat(self):
"""Emergency heat state."""
return self._thermostat.is_emergency_heat_active()
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
data = super().device_state_attributes
data[ATTR_ZONE_STATUS] = self._zone.get_status()
if not self._has_relative_humidity:
return data
min_humidity = percent_conv(self._thermostat.get_humidity_setpoint_limits()[0])
max_humidity = percent_conv(self._thermostat.get_humidity_setpoint_limits()[1])
data.update(
{
ATTR_MIN_HUMIDITY: min_humidity,
ATTR_MAX_HUMIDITY: max_humidity,
ATTR_DEHUMIDIFY_SUPPORTED: self._has_dehumidify_support,
ATTR_HUMIDIFY_SUPPORTED: self._has_humidify_support,
}
)
if self._has_dehumidify_support:
dehumdify_setpoint = percent_conv(
self._thermostat.get_dehumidify_setpoint()
)
data[ATTR_DEHUMIDIFY_SETPOINT] = dehumdify_setpoint
if self._has_humidify_support:
humdify_setpoint = percent_conv(self._thermostat.get_humidify_setpoint())
data[ATTR_HUMIDIFY_SETPOINT] = humdify_setpoint
return data
def set_preset_mode(self, preset_mode: str):
"""Set the preset mode."""
self._zone.set_preset(preset_mode)
self._signal_zone_update()
def turn_aux_heat_off(self):
"""Turn. Aux Heat off."""
self._thermostat.set_emergency_heat(False)
self._signal_thermostat_update()
def turn_aux_heat_on(self):
"""Turn. Aux Heat on."""
self._thermostat.set_emergency_heat(True)
self._signal_thermostat_update()
def turn_off(self):
"""Turn. off the zone."""
self.set_hvac_mode(OPERATION_MODE_OFF)
self._signal_zone_update()
def turn_on(self):
"""Turn. on the zone."""
self.set_hvac_mode(OPERATION_MODE_AUTO)
self._signal_zone_update()
def set_hvac_mode(self, hvac_mode: str) -> None:
"""Set the system mode (Auto, Heat_Cool, Cool, Heat, etc)."""
if hvac_mode == HVAC_MODE_AUTO:
self._zone.call_return_to_schedule()
self._zone.set_mode(mode=OPERATION_MODE_AUTO)
else:
self._zone.call_permanent_hold()
self._zone.set_mode(mode=HA_TO_NEXIA_HVAC_MODE_MAP[hvac_mode])
self.schedule_update_ha_state()
def set_aircleaner_mode(self, aircleaner_mode):
"""Set the aircleaner mode."""
self._thermostat.set_air_cleaner(aircleaner_mode)
self._signal_thermostat_update()
def set_humidify_setpoint(self, humidity):
"""Set the humidify setpoint."""
self._thermostat.set_humidify_setpoint(humidity / 100.0)
self._signal_thermostat_update()
def _signal_thermostat_update(self):
"""Signal a thermostat update.
Whenever the underlying library does an action against
a thermostat, the data for the thermostat and all
connected zone is updated.
Update all the zones on the thermostat.
"""
dispatcher_send(
self.hass, f"{SIGNAL_THERMOSTAT_UPDATE}-{self._thermostat.thermostat_id}"
)
def _signal_zone_update(self):
"""Signal a zone update.
Whenever the underlying library does an action against
a zone, the data for the zone is updated.
Update a single zone.
"""
dispatcher_send(self.hass, f"{SIGNAL_ZONE_UPDATE}-{self._zone.zone_id}")
async def async_update(self):
"""Update the entity.
Only used by the generic entity update service.
"""
await self._coordinator.async_request_refresh()