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
This commit is contained in:
J. Nick Koston 2020-03-23 11:01:48 -05:00 committed by GitHub
parent 0e3dc7976c
commit b8fdebd05c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 575 additions and 291 deletions

View File

@ -15,7 +15,7 @@ from homeassistant.exceptions import ConfigEntryNotReady
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DATA_NEXIA, DOMAIN, NEXIA_DEVICE, PLATFORMS, UPDATE_COORDINATOR
from .const import DOMAIN, NEXIA_DEVICE, PLATFORMS, UPDATE_COORDINATOR
_LOGGER = logging.getLogger(__name__)
@ -94,8 +94,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE),
)
hass.data[DOMAIN][entry.entry_id] = {}
hass.data[DOMAIN][entry.entry_id][DATA_NEXIA] = {
hass.data[DOMAIN][entry.entry_id] = {
NEXIA_DEVICE: nexia_home,
UPDATE_COORDINATOR: coordinator,
}

View File

@ -1,23 +1,15 @@
"""Support for Nexia / Trane XL Thermostats."""
from homeassistant.components.binary_sensor import BinarySensorDevice
from homeassistant.const import ATTR_ATTRIBUTION
from .const import (
ATTRIBUTION,
DATA_NEXIA,
DOMAIN,
MANUFACTURER,
NEXIA_DEVICE,
UPDATE_COORDINATOR,
)
from .entity import NexiaEntity
from .const import DOMAIN, NEXIA_DEVICE, UPDATE_COORDINATOR
from .entity import NexiaThermostatEntity
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up sensors for a Nexia device."""
nexia_data = hass.data[DOMAIN][config_entry.entry_id][DATA_NEXIA]
nexia_data = hass.data[DOMAIN][config_entry.entry_id]
nexia_home = nexia_data[NEXIA_DEVICE]
coordinator = nexia_data[UPDATE_COORDINATOR]
@ -42,48 +34,21 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities(entities, True)
class NexiaBinarySensor(NexiaEntity, BinarySensorDevice):
class NexiaBinarySensor(NexiaThermostatEntity, BinarySensorDevice):
"""Provices Nexia BinarySensor support."""
def __init__(self, coordinator, device, sensor_call, sensor_name):
def __init__(self, coordinator, thermostat, sensor_call, sensor_name):
"""Initialize the nexia sensor."""
super().__init__(coordinator)
self._coordinator = coordinator
self._device = device
self._name = f"{self._device.get_name()} {sensor_name}"
super().__init__(
coordinator,
thermostat,
name=f"{thermostat.get_name()} {sensor_name}",
unique_id=f"{thermostat.thermostat_id}_{sensor_call}",
)
self._call = sensor_call
self._unique_id = f"{self._device.thermostat_id}_{sensor_call}"
self._state = None
@property
def unique_id(self):
"""Return the unique id of the binary sensor."""
return self._unique_id
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._device.thermostat_id)},
"name": self._device.get_name(),
"model": self._device.get_model(),
"sw_version": self._device.get_firmware(),
"manufacturer": MANUFACTURER,
}
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
}
@property
def is_on(self):
"""Return the status of the sensor."""
return getattr(self._device, self._call)()
return getattr(self._thermostat, self._call)()

View File

@ -12,9 +12,11 @@ from nexia.const import (
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,
@ -36,26 +38,50 @@ from homeassistant.components.climate.const import (
SUPPORT_TARGET_TEMPERATURE_RANGE,
)
from homeassistant.const import (
ATTR_ATTRIBUTION,
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,
ATTRIBUTION,
DATA_NEXIA,
DOMAIN,
MANUFACTURER,
NEXIA_DEVICE,
SIGNAL_THERMOSTAT_UPDATE,
SIGNAL_ZONE_UPDATE,
UPDATE_COORDINATOR,
)
from .entity import NexiaEntity
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__)
@ -83,10 +109,21 @@ NEXIA_TO_HA_HVAC_MODE_MAP = {
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][DATA_NEXIA]
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)
@ -97,26 +134,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities(entities, True)
class NexiaZone(NexiaEntity, ClimateDevice):
class NexiaZone(NexiaThermostatZoneEntity, ClimateDevice):
"""Provides Nexia Climate support."""
def __init__(self, coordinator, device):
def __init__(self, coordinator, zone):
"""Initialize the thermostat."""
super().__init__(coordinator)
self.thermostat = device.thermostat
self._device = device
self._coordinator = coordinator
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 unique_id(self):
"""Device Uniqueid."""
return self._device.zone_id
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):
@ -139,27 +172,22 @@ class NexiaZone(NexiaEntity, ClimateDevice):
@property
def is_fan_on(self):
"""Blower is on."""
return self.thermostat.is_blower_active()
@property
def name(self):
"""Name of the zone."""
return self._device.get_name()
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
return TEMP_CELSIUS if self._thermostat.get_unit() == "C" else TEMP_FAHRENHEIT
@property
def current_temperature(self):
"""Return the current temperature."""
return self._device.get_temperature()
return self._zone.get_temperature()
@property
def fan_mode(self):
"""Return the fan setting."""
return self.thermostat.get_fan_mode()
return self._thermostat.get_fan_mode()
@property
def fan_modes(self):
@ -169,92 +197,92 @@ class NexiaZone(NexiaEntity, ClimateDevice):
@property
def min_temp(self):
"""Minimum temp for the current setting."""
return (self._device.thermostat.get_setpoint_limits())[0]
return (self._thermostat.get_setpoint_limits())[0]
@property
def max_temp(self):
"""Maximum temp for the current setting."""
return (self._device.thermostat.get_setpoint_limits())[1]
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.schedule_update_ha_state()
self._thermostat.set_fan_mode(fan_mode)
self._signal_thermostat_update()
@property
def preset_mode(self):
"""Preset that is active."""
return self._device.get_preset()
return self._zone.get_preset()
@property
def preset_modes(self):
"""All presets."""
return self._device.get_presets()
return self._zone.get_presets()
def set_humidity(self, humidity):
"""Dehumidify target."""
self.thermostat.set_dehumidify_setpoint(humidity / 100.0)
self.schedule_update_ha_state()
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 round(self.thermostat.get_dehumidify_setpoint() * 100.0, 1)
return percent_conv(self._thermostat.get_dehumidify_setpoint())
if self._has_humidify_support:
return round(self.thermostat.get_humidify_setpoint() * 100.0, 1)
return percent_conv(self._thermostat.get_humidify_setpoint())
return None
@property
def current_humidity(self):
"""Humidity indoors."""
if self._has_relative_humidity:
return round(self.thermostat.get_relative_humidity() * 100.0, 1)
return percent_conv(self._thermostat.get_relative_humidity())
return None
@property
def target_temperature(self):
"""Temperature we try to reach."""
current_mode = self._device.get_current_mode()
current_mode = self._zone.get_current_mode()
if current_mode == OPERATION_MODE_COOL:
return self._device.get_cooling_setpoint()
return self._zone.get_cooling_setpoint()
if current_mode == OPERATION_MODE_HEAT:
return self._device.get_heating_setpoint()
return self._zone.get_heating_setpoint()
return None
@property
def target_temperature_step(self):
"""Step size of temperature units."""
if self._device.thermostat.get_unit() == UNIT_FAHRENHEIT:
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._device.get_current_mode()
current_mode = self._zone.get_current_mode()
if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
return None
return self._device.get_cooling_setpoint()
return self._zone.get_cooling_setpoint()
@property
def target_temperature_low(self):
"""Lowest temperature we are trying to reach."""
current_mode = self._device.get_current_mode()
current_mode = self._zone.get_current_mode()
if current_mode in (OPERATION_MODE_COOL, OPERATION_MODE_HEAT):
return None
return self._device.get_heating_setpoint()
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._device.is_calling()
system_status = self._thermostat.get_system_status()
zone_called = self._zone.is_calling()
if self._device.get_requested_mode() == OPERATION_MODE_OFF:
if self._zone.get_requested_mode() == OPERATION_MODE_OFF:
return CURRENT_HVAC_OFF
if not zone_called:
return CURRENT_HVAC_IDLE
@ -269,8 +297,8 @@ class NexiaZone(NexiaEntity, ClimateDevice):
@property
def hvac_mode(self):
"""Return current mode, as the user-visible name."""
mode = self._device.get_requested_mode()
hold = self._device.is_in_permanent_hold()
mode = self._zone.get_requested_mode()
hold = self._zone.is_in_permanent_hold()
# If the device is in hold mode with
# OPERATION_MODE_AUTO
@ -299,10 +327,10 @@ class NexiaZone(NexiaEntity, ClimateDevice):
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._device.get_cooling_setpoint()
cur_heat_temp = self._device.get_heating_setpoint()
(min_temp, max_temp) = self.thermostat.get_setpoint_limits()
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:
@ -318,114 +346,119 @@ class NexiaZone(NexiaEntity, ClimateDevice):
if new_cool_temp - new_heat_temp < deadband:
new_heat_temp = new_cool_temp - deadband
self._device.set_heat_cool_temp(
self._zone.set_heat_cool_temp(
heat_temperature=new_heat_temp,
cool_temperature=new_cool_temp,
set_temperature=set_temp,
)
self.schedule_update_ha_state()
self._signal_zone_update()
@property
def is_aux_heat(self):
"""Emergency heat state."""
return self.thermostat.is_emergency_heat_active()
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._device.zone_id)},
"name": self._device.get_name(),
"model": self.thermostat.get_model(),
"sw_version": self.thermostat.get_firmware(),
"manufacturer": MANUFACTURER,
"via_device": (DOMAIN, self.thermostat.thermostat_id),
}
return self._thermostat.is_emergency_heat_active()
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
data = {
ATTR_ATTRIBUTION: ATTRIBUTION,
ATTR_ZONE_STATUS: self._device.get_status(),
}
data = super().device_state_attributes
if self._has_relative_humidity:
data.update(
{
ATTR_HUMIDIFY_SUPPORTED: self._has_humidify_support,
ATTR_DEHUMIDIFY_SUPPORTED: self._has_dehumidify_support,
ATTR_MIN_HUMIDITY: round(
self.thermostat.get_humidity_setpoint_limits()[0] * 100.0, 1,
),
ATTR_MAX_HUMIDITY: round(
self.thermostat.get_humidity_setpoint_limits()[1] * 100.0, 1,
),
}
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()
)
if self._has_dehumidify_support:
data.update(
{
ATTR_DEHUMIDIFY_SETPOINT: round(
self.thermostat.get_dehumidify_setpoint() * 100.0, 1
),
}
)
if self._has_humidify_support:
data.update(
{
ATTR_HUMIDIFY_SETPOINT: round(
self.thermostat.get_humidify_setpoint() * 100.0, 1
)
}
)
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._device.set_preset(preset_mode)
self.schedule_update_ha_state()
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.schedule_update_ha_state()
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.schedule_update_ha_state()
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.schedule_update_ha_state()
self._signal_zone_update()
def turn_on(self):
"""Turn. on the zone."""
self.set_hvac_mode(OPERATION_MODE_AUTO)
self.schedule_update_ha_state()
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._device.call_return_to_schedule()
self._device.set_mode(mode=OPERATION_MODE_AUTO)
self._zone.call_return_to_schedule()
self._zone.set_mode(mode=OPERATION_MODE_AUTO)
else:
self._device.call_permanent_hold()
self._device.set_mode(mode=HA_TO_NEXIA_HVAC_MODE_MAP[hvac_mode])
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.schedule_update_ha_state()
self._thermostat.set_air_cleaner(aircleaner_mode)
self._signal_thermostat_update()
def set_humidify_setpoint(self, humidify_setpoint):
def set_humidify_setpoint(self, humidity):
"""Set the humidify setpoint."""
self.thermostat.set_humidify_setpoint(humidify_setpoint / 100.0)
self.schedule_update_ha_state()
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.

View File

@ -7,7 +7,6 @@ ATTRIBUTION = "Data provided by mynexia.com"
NOTIFICATION_ID = "nexia_notification"
NOTIFICATION_TITLE = "Nexia Setup"
DATA_NEXIA = "nexia"
NEXIA_DEVICE = "device"
NEXIA_SCAN_INTERVAL = "scan_interval"
@ -16,6 +15,8 @@ DEFAULT_ENTITY_NAMESPACE = "nexia"
ATTR_DESCRIPTION = "description"
ATTR_AIRCLEANER_MODE = "aircleaner_mode"
ATTR_ZONE_STATUS = "zone_status"
ATTR_HUMIDIFY_SUPPORTED = "humidify_supported"
ATTR_DEHUMIDIFY_SUPPORTED = "dehumidify_supported"
@ -24,5 +25,7 @@ ATTR_DEHUMIDIFY_SETPOINT = "dehumidify_setpoint"
UPDATE_COORDINATOR = "update_coordinator"
MANUFACTURER = "Trane"
SIGNAL_ZONE_UPDATE = "NEXIA_CLIMATE_ZONE_UPDATE"
SIGNAL_THERMOSTAT_UPDATE = "NEXIA_CLIMATE_THERMOSTAT_UPDATE"

View File

@ -1,14 +1,26 @@
"""The nexia integration base entity."""
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import (
ATTRIBUTION,
DOMAIN,
MANUFACTURER,
SIGNAL_THERMOSTAT_UPDATE,
SIGNAL_ZONE_UPDATE,
)
class NexiaEntity(Entity):
"""Base class for nexia entities."""
def __init__(self, coordinator):
def __init__(self, coordinator, name, unique_id):
"""Initialize the entity."""
super().__init__()
self._unique_id = unique_id
self._name = name
self._coordinator = coordinator
@property
@ -16,6 +28,23 @@ class NexiaEntity(Entity):
"""Return True if entity is available."""
return self._coordinator.last_update_success
@property
def unique_id(self):
"""Return the unique id."""
return self._unique_id
@property
def name(self):
"""Return the name."""
return self._name
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
}
@property
def should_poll(self):
"""Return False, updates are controlled via coordinator."""
@ -28,3 +57,77 @@ class NexiaEntity(Entity):
async def async_will_remove_from_hass(self):
"""Undo subscription."""
self._coordinator.async_remove_listener(self.async_write_ha_state)
class NexiaThermostatEntity(NexiaEntity):
"""Base class for nexia devices attached to a thermostat."""
def __init__(self, coordinator, thermostat, name, unique_id):
"""Initialize the entity."""
super().__init__(coordinator, name, unique_id)
self._thermostat = thermostat
self._thermostat_update_subscription = None
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._thermostat.thermostat_id)},
"name": self._thermostat.get_name(),
"model": self._thermostat.get_model(),
"sw_version": self._thermostat.get_firmware(),
"manufacturer": MANUFACTURER,
}
async def async_added_to_hass(self):
"""Listen for signals for services."""
await super().async_added_to_hass()
self._thermostat_update_subscription = async_dispatcher_connect(
self.hass,
f"{SIGNAL_THERMOSTAT_UPDATE}-{self._thermostat.thermostat_id}",
self.async_write_ha_state,
)
async def async_will_remove_from_hass(self):
"""Unsub from signals for services."""
await super().async_will_remove_from_hass()
if self._thermostat_update_subscription:
self._thermostat_update_subscription()
class NexiaThermostatZoneEntity(NexiaThermostatEntity):
"""Base class for nexia devices attached to a thermostat."""
def __init__(self, coordinator, zone, name, unique_id):
"""Initialize the entity."""
super().__init__(coordinator, zone.thermostat, name, unique_id)
self._zone = zone
self._zone_update_subscription = None
@property
def device_info(self):
"""Return the device_info of the device."""
data = super().device_info
data.update(
{
"identifiers": {(DOMAIN, self._zone.zone_id)},
"name": self._zone.get_name(),
"via_device": (DOMAIN, self._zone.thermostat.thermostat_id),
}
)
return data
async def async_added_to_hass(self):
"""Listen for signals for services."""
await super().async_added_to_hass()
self._zone_update_subscription = async_dispatcher_connect(
self.hass,
f"{SIGNAL_ZONE_UPDATE}-{self._zone.zone_id}",
self.async_write_ha_state,
)
async def async_will_remove_from_hass(self):
"""Unsub from signals for services."""
await super().async_will_remove_from_hass()
if self._zone_update_subscription:
self._zone_update_subscription()

View File

@ -1,23 +1,18 @@
"""Support for Nexia Automations."""
from homeassistant.components.scene import Scene
from homeassistant.const import ATTR_ATTRIBUTION
from homeassistant.helpers.event import async_call_later
from .const import (
ATTR_DESCRIPTION,
ATTRIBUTION,
DATA_NEXIA,
DOMAIN,
NEXIA_DEVICE,
UPDATE_COORDINATOR,
)
from .const import ATTR_DESCRIPTION, DOMAIN, NEXIA_DEVICE, UPDATE_COORDINATOR
from .entity import NexiaEntity
SCENE_ACTIVATION_TIME = 5
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up automations for a Nexia device."""
nexia_data = hass.data[DOMAIN][config_entry.entry_id][DATA_NEXIA]
nexia_data = hass.data[DOMAIN][config_entry.entry_id]
nexia_home = nexia_data[NEXIA_DEVICE]
coordinator = nexia_data[UPDATE_COORDINATOR]
entities = []
@ -36,33 +31,28 @@ class NexiaAutomationScene(NexiaEntity, Scene):
def __init__(self, coordinator, automation):
"""Initialize the automation scene."""
super().__init__(coordinator)
super().__init__(
coordinator, name=automation.name, unique_id=automation.automation_id,
)
self._automation = automation
@property
def unique_id(self):
"""Return the unique id of the automation scene."""
# This is the automation unique_id
return self._automation.automation_id
@property
def name(self):
"""Return the name of the automation scene."""
return self._automation.name
@property
def device_state_attributes(self):
"""Return the scene specific state attributes."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
ATTR_DESCRIPTION: self._automation.description,
}
data = super().device_state_attributes
data[ATTR_DESCRIPTION] = self._automation.description
return data
@property
def icon(self):
"""Return the icon of the automation scene."""
return "mdi:script-text-outline"
def activate(self):
async def async_activate(self):
"""Activate an automation scene."""
self._automation.activate()
await self.hass.async_add_executor_job(self._automation.activate)
async def refresh_callback(_):
await self._coordinator.async_refresh()
async_call_later(self.hass, SCENE_ACTIVATION_TIME, refresh_callback)

View File

@ -3,7 +3,6 @@
from nexia.const import UNIT_CELSIUS
from homeassistant.const import (
ATTR_ATTRIBUTION,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
@ -11,21 +10,15 @@ from homeassistant.const import (
UNIT_PERCENTAGE,
)
from .const import (
ATTRIBUTION,
DATA_NEXIA,
DOMAIN,
MANUFACTURER,
NEXIA_DEVICE,
UPDATE_COORDINATOR,
)
from .entity import NexiaEntity
from .const import DOMAIN, NEXIA_DEVICE, UPDATE_COORDINATOR
from .entity import NexiaThermostatEntity, NexiaThermostatZoneEntity
from .util import percent_conv
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up sensors for a Nexia device."""
nexia_data = hass.data[DOMAIN][config_entry.entry_id][DATA_NEXIA]
nexia_data = hass.data[DOMAIN][config_entry.entry_id]
nexia_home = nexia_data[NEXIA_DEVICE]
coordinator = nexia_data[UPDATE_COORDINATOR]
entities = []
@ -35,7 +28,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
thermostat = nexia_home.get_thermostat_by_id(thermostat_id)
entities.append(
NexiaSensor(
NexiaThermostatSensor(
coordinator,
thermostat,
"get_system_status",
@ -46,7 +39,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
# Air cleaner
entities.append(
NexiaSensor(
NexiaThermostatSensor(
coordinator,
thermostat,
"get_air_cleaner_mode",
@ -58,7 +51,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
# Compressor Speed
if thermostat.has_variable_speed_compressor():
entities.append(
NexiaSensor(
NexiaThermostatSensor(
coordinator,
thermostat,
"get_current_compressor_speed",
@ -69,7 +62,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
)
entities.append(
NexiaSensor(
NexiaThermostatSensor(
coordinator,
thermostat,
"get_requested_compressor_speed",
@ -87,7 +80,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
else TEMP_FAHRENHEIT
)
entities.append(
NexiaSensor(
NexiaThermostatSensor(
coordinator,
thermostat,
"get_outdoor_temperature",
@ -99,7 +92,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
# Relative Humidity
if thermostat.has_relative_humidity():
entities.append(
NexiaSensor(
NexiaThermostatSensor(
coordinator,
thermostat,
"get_relative_humidity",
@ -120,7 +113,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
# Temperature
entities.append(
NexiaZoneSensor(
NexiaThermostatZoneSensor(
coordinator,
zone,
"get_temperature",
@ -132,13 +125,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
)
# Zone Status
entities.append(
NexiaZoneSensor(
NexiaThermostatZoneSensor(
coordinator, zone, "get_status", "Zone Status", None, None,
)
)
# Setpoint Status
entities.append(
NexiaZoneSensor(
NexiaThermostatZoneSensor(
coordinator,
zone,
"get_setpoint_status",
@ -151,18 +144,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities):
async_add_entities(entities, True)
def percent_conv(val):
"""Convert an actual percentage (0.0-1.0) to 0-100 scale."""
return val * 100.0
class NexiaSensor(NexiaEntity):
class NexiaThermostatSensor(NexiaThermostatEntity):
"""Provides Nexia thermostat sensor support."""
def __init__(
self,
coordinator,
device,
thermostat,
sensor_call,
sensor_name,
sensor_class,
@ -170,35 +158,18 @@ class NexiaSensor(NexiaEntity):
modifier=None,
):
"""Initialize the sensor."""
super().__init__(coordinator)
self._coordinator = coordinator
self._device = device
super().__init__(
coordinator,
thermostat,
name=f"{thermostat.get_name()} {sensor_name}",
unique_id=f"{thermostat.thermostat_id}_{sensor_call}",
)
self._call = sensor_call
self._sensor_name = sensor_name
self._class = sensor_class
self._state = None
self._name = f"{self._device.get_name()} {self._sensor_name}"
self._unit_of_measurement = sensor_unit
self._modifier = modifier
@property
def unique_id(self):
"""Return the unique id of the sensor."""
# This is the thermostat unique_id
return f"{self._device.thermostat_id}_{self._call}"
@property
def name(self):
"""Return the name of the sensor."""
return self._name
@property
def device_state_attributes(self):
"""Return the device specific state attributes."""
return {
ATTR_ATTRIBUTION: ATTRIBUTION,
}
@property
def device_class(self):
"""Return the device class of the sensor."""
@ -207,7 +178,7 @@ class NexiaSensor(NexiaEntity):
@property
def state(self):
"""Return the state of the sensor."""
val = getattr(self._device, self._call)()
val = getattr(self._thermostat, self._call)()
if self._modifier:
val = self._modifier(val)
if isinstance(val, float):
@ -219,25 +190,14 @@ class NexiaSensor(NexiaEntity):
"""Return the unit of measurement this sensor expresses itself in."""
return self._unit_of_measurement
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._device.thermostat_id)},
"name": self._device.get_name(),
"model": self._device.get_model(),
"sw_version": self._device.get_firmware(),
"manufacturer": MANUFACTURER,
}
class NexiaZoneSensor(NexiaSensor):
class NexiaThermostatZoneSensor(NexiaThermostatZoneEntity):
"""Nexia Zone Sensor Support."""
def __init__(
self,
coordinator,
device,
zone,
sensor_call,
sensor_name,
sensor_class,
@ -248,29 +208,32 @@ class NexiaZoneSensor(NexiaSensor):
super().__init__(
coordinator,
device,
sensor_call,
sensor_name,
sensor_class,
sensor_unit,
modifier,
zone,
name=f"{zone.get_name()} {sensor_name}",
unique_id=f"{zone.zone_id}_{sensor_call}",
)
self._device = device
self._call = sensor_call
self._class = sensor_class
self._state = None
self._unit_of_measurement = sensor_unit
self._modifier = modifier
@property
def unique_id(self):
"""Return the unique id of the sensor."""
# This is the zone unique_id
return f"{self._device.zone_id}_{self._call}"
def device_class(self):
"""Return the device class of the sensor."""
return self._class
@property
def device_info(self):
"""Return the device_info of the device."""
return {
"identifiers": {(DOMAIN, self._device.zone_id)},
"name": self._device.get_name(),
"model": self._device.thermostat.get_model(),
"sw_version": self._device.thermostat.get_firmware(),
"manufacturer": MANUFACTURER,
"via_device": (DOMAIN, self._device.thermostat.thermostat_id),
}
def state(self):
"""Return the state of the sensor."""
val = getattr(self._zone, self._call)()
if self._modifier:
val = self._modifier(val)
if isinstance(val, float):
val = round(val, 1)
return val
@property
def unit_of_measurement(self):
"""Return the unit of measurement this sensor expresses itself in."""
return self._unit_of_measurement

View File

@ -0,0 +1,19 @@
set_aircleaner_mode:
description: "The air cleaner mode."
fields:
entity_id:
description: "This setting will affect all zones connected to the thermostat."
example: climate.master_bedroom
aircleaner_mode:
description: "The air cleaner mode to set. Options include \"auto\", \"quick\", or \"allergy\"."
example: allergy
set_humidify_setpoint:
description: "The humidification set point."
fields:
entity_id:
description: "This setting will affect all zones connected to the thermostat."
example: climate.master_bedroom
humidity:
description: "The humidification setpoint as an int, range 35-65."
example: 45

View File

@ -0,0 +1,6 @@
"""Utils for Nexia / Trane XL Thermostats."""
def percent_conv(val):
"""Convert an actual percentage (0.0-1.0) to 0-100 scale."""
return round(val * 100.0, 1)

View File

@ -0,0 +1,35 @@
"""The binary_sensor tests for the nexia platform."""
from homeassistant.const import STATE_OFF, STATE_ON
from .util import async_init_integration
async def test_create_binary_sensors(hass):
"""Test creation of binary sensors."""
await async_init_integration(hass)
state = hass.states.get("binary_sensor.master_suite_blower_active")
assert state.state == STATE_ON
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Master Suite Blower Active",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("binary_sensor.downstairs_east_wing_blower_active")
assert state.state == STATE_OFF
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Downstairs East Wing Blower Active",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)

View File

@ -43,3 +43,38 @@ async def test_climate_zones(hass):
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("climate.kitchen")
assert state.state == HVAC_MODE_HEAT_COOL
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"current_humidity": 36.0,
"current_temperature": 25.0,
"dehumidify_setpoint": 50.0,
"dehumidify_supported": True,
"fan_mode": "auto",
"fan_modes": ["auto", "on", "circulate"],
"friendly_name": "Kitchen",
"humidify_supported": False,
"humidity": 50.0,
"hvac_action": "idle",
"hvac_modes": ["off", "auto", "heat_cool", "heat", "cool"],
"max_humidity": 65.0,
"max_temp": 37.2,
"min_humidity": 35.0,
"min_temp": 12.8,
"preset_mode": "None",
"preset_modes": ["None", "Home", "Away", "Sleep"],
"supported_features": 31,
"target_temp_high": 26.1,
"target_temp_low": 17.2,
"target_temp_step": 1.0,
"temperature": None,
"zone_status": "",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)

View File

@ -1,10 +1,10 @@
"""The lock tests for the august platform."""
"""The scene tests for the nexia platform."""
from .util import async_init_integration
async def test_automation_scenees(hass):
"""Test creation automation scenees."""
async def test_automation_scenes(hass):
"""Test creation automation scenes."""
await async_init_integration(hass)

View File

@ -0,0 +1,133 @@
"""The sensor tests for the nexia platform."""
from .util import async_init_integration
async def test_create_sensors(hass):
"""Test creation of sensors."""
await async_init_integration(hass)
state = hass.states.get("sensor.nick_office_temperature")
assert state.state == "23"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"device_class": "temperature",
"friendly_name": "Nick Office Temperature",
"unit_of_measurement": "°C",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.nick_office_zone_setpoint_status")
assert state.state == "Permanent Hold"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Nick Office Zone Setpoint Status",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.nick_office_zone_status")
assert state.state == "Relieving Air"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Nick Office Zone Status",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.master_suite_air_cleaner_mode")
assert state.state == "auto"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Master Suite Air Cleaner Mode",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.master_suite_current_compressor_speed")
assert state.state == "69.0"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Master Suite Current Compressor Speed",
"unit_of_measurement": "%",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.master_suite_outdoor_temperature")
assert state.state == "30.6"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"device_class": "temperature",
"friendly_name": "Master Suite Outdoor Temperature",
"unit_of_measurement": "°C",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.master_suite_relative_humidity")
assert state.state == "52.0"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"device_class": "humidity",
"friendly_name": "Master Suite Relative Humidity",
"unit_of_measurement": "%",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.master_suite_requested_compressor_speed")
assert state.state == "69.0"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Master Suite Requested Compressor Speed",
"unit_of_measurement": "%",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)
state = hass.states.get("sensor.master_suite_system_status")
assert state.state == "Cooling"
expected_attributes = {
"attribution": "Data provided by mynexia.com",
"friendly_name": "Master Suite System Status",
}
# Only test for a subset of attributes in case
# HA changes the implementation and a new one appears
assert all(
state.attributes[key] == expected_attributes[key] for key in expected_attributes
)