diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py index 41ecf6f1045b3311e34568014fae9f6bc91c8a02..5c317794c2aedcb7f2102a006e9ce5aaf2f3df2b 100644 --- a/homeassistant/components/nexia/__init__.py +++ b/homeassistant/components/nexia/__init__.py @@ -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, } diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py index 2802c3d7bd472db087bd995ab1f50bfddd2ed8e6..5c33412c647421820f75abf15c577fed7758bb3c 100644 --- a/homeassistant/components/nexia/binary_sensor.py +++ b/homeassistant/components/nexia/binary_sensor.py @@ -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)() diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py index 7231f2b8ba9d97930fcf0631739c7d78f13682d9..8af1be20b1e95ebce46884b7bdd4030d771292e2 100644 --- a/homeassistant/components/nexia/climate.py +++ b/homeassistant/components/nexia/climate.py @@ -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 + + 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_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, - ), - } + 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. diff --git a/homeassistant/components/nexia/const.py b/homeassistant/components/nexia/const.py index 384c3aad1b6a8733457ebef66e06f05d6096d9af..dbe7b71705c6ff2f9caadccc5cb501cae7781e02 100644 --- a/homeassistant/components/nexia/const.py +++ b/homeassistant/components/nexia/const.py @@ -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" diff --git a/homeassistant/components/nexia/entity.py b/homeassistant/components/nexia/entity.py index ec02a7e5f21efd744f04c5dea7835f0ed2aff7a2..60675cc5888eb11030bc884eee9fafdf198cd6bd 100644 --- a/homeassistant/components/nexia/entity.py +++ b/homeassistant/components/nexia/entity.py @@ -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() diff --git a/homeassistant/components/nexia/scene.py b/homeassistant/components/nexia/scene.py index 4489a4de274a9937439bee55375e3670cb8c5009..fb851618aecb5a97196bc8f24d890165e37289ac 100644 --- a/homeassistant/components/nexia/scene.py +++ b/homeassistant/components/nexia/scene.py @@ -1,23 +1,18 @@ """Support for Nexia Automations.""" from homeassistant.components.scene import Scene -from homeassistant.const import ATTR_ATTRIBUTION - -from .const import ( - ATTR_DESCRIPTION, - ATTRIBUTION, - DATA_NEXIA, - DOMAIN, - NEXIA_DEVICE, - UPDATE_COORDINATOR, -) +from homeassistant.helpers.event import async_call_later + +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) diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py index 251101ccb1ef03e6a87667fb6da662425efae43c..abbffa2b8449a3d5cb001c19ea4646fb6fff3eb9 100644 --- a/homeassistant/components/nexia/sensor.py +++ b/homeassistant/components/nexia/sensor.py @@ -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 diff --git a/homeassistant/components/nexia/services.yaml b/homeassistant/components/nexia/services.yaml new file mode 100644 index 0000000000000000000000000000000000000000..725b215da5a6325baf360964ac5f6587dd4e5d63 --- /dev/null +++ b/homeassistant/components/nexia/services.yaml @@ -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 \ No newline at end of file diff --git a/homeassistant/components/nexia/util.py b/homeassistant/components/nexia/util.py new file mode 100644 index 0000000000000000000000000000000000000000..d2ff10c8d3491e060be3289f67a08717b0533679 --- /dev/null +++ b/homeassistant/components/nexia/util.py @@ -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) diff --git a/tests/components/nexia/test_binary_sensor.py b/tests/components/nexia/test_binary_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..64b2946ee2feaf0491adc1dd4739b4345bc05373 --- /dev/null +++ b/tests/components/nexia/test_binary_sensor.py @@ -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 + ) diff --git a/tests/components/nexia/test_climate.py b/tests/components/nexia/test_climate.py index 327c611d2774879988d8181985832eb0bf0109ab..e7675ff68b1bb9a60b9f6790a0c487019db2b681 100644 --- a/tests/components/nexia/test_climate.py +++ b/tests/components/nexia/test_climate.py @@ -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 + ) diff --git a/tests/components/nexia/test_scene.py b/tests/components/nexia/test_scene.py index e6a5e94f083a47a099d003d1153ef56a2329394e..4a325552e8032b92538ebbdd917d757a747821dc 100644 --- a/tests/components/nexia/test_scene.py +++ b/tests/components/nexia/test_scene.py @@ -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) diff --git a/tests/components/nexia/test_sensor.py b/tests/components/nexia/test_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..6e258d0ad55c111d6f8878812c157e8b72424186 --- /dev/null +++ b/tests/components/nexia/test_sensor.py @@ -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 + )