core/homeassistant/components/nexia/climate.py

468 lines
15 KiB
Python

"""Support for Nexia / Trane XL thermostats."""
import logging
from nexia.const import (
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 ClimateEntity
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, ClimateEntity):
"""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 self._thermostat.get_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)
new_cool_temp = kwargs.get(ATTR_TARGET_TEMP_HIGH)
set_temp = kwargs.get(ATTR_TEMPERATURE)
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()