* 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
468 lines
15 KiB
Python
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()
|