diff --git a/CODEOWNERS b/CODEOWNERS index ccb0cac17ea..4d9ec3a2f0f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -242,6 +242,7 @@ homeassistant/components/ness_alarm/* @nickw444 homeassistant/components/nest/* @awarecan homeassistant/components/netatmo/* @cgtobi homeassistant/components/netdata/* @fabaff +homeassistant/components/nexia/* @ryannazaretian @bdraco homeassistant/components/nextbus/* @vividboarder homeassistant/components/nilu/* @hfurubotten homeassistant/components/nissan_leaf/* @filcole diff --git a/homeassistant/components/nexia/.translations/en.json b/homeassistant/components/nexia/.translations/en.json new file mode 100644 index 00000000000..d3fabfb0b4d --- /dev/null +++ b/homeassistant/components/nexia/.translations/en.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Nexia", + "step": { + "user": { + "title": "Connect to mynexia.com", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This nexia home is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/nexia/__init__.py b/homeassistant/components/nexia/__init__.py new file mode 100644 index 00000000000..40ea7b6dcc6 --- /dev/null +++ b/homeassistant/components/nexia/__init__.py @@ -0,0 +1,119 @@ +"""Support for Nexia / Trane XL Thermostats.""" +import asyncio +from datetime import timedelta +from functools import partial +import logging + +from nexia.home import NexiaHome +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +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 + +_LOGGER = logging.getLogger(__name__) + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema( + { + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }, + extra=vol.ALLOW_EXTRA, + ), + }, + extra=vol.ALLOW_EXTRA, +) + +DEFAULT_UPDATE_RATE = 120 + + +async def async_setup(hass: HomeAssistant, config: dict) -> bool: + """Set up the nexia component from YAML.""" + + conf = config.get(DOMAIN) + hass.data.setdefault(DOMAIN, {}) + + if not conf: + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Configure the base Nexia device for Home Assistant.""" + + conf = entry.data + username = conf[CONF_USERNAME] + password = conf[CONF_PASSWORD] + + try: + nexia_home = await hass.async_add_executor_job( + partial(NexiaHome, username=username, password=password) + ) + except ConnectTimeout as ex: + _LOGGER.error("Unable to connect to Nexia service: %s", ex) + raise ConfigEntryNotReady + except HTTPError as http_ex: + if http_ex.response.status_code >= 400 and http_ex.response.status_code < 500: + _LOGGER.error( + "Access error from Nexia service, please check credentials: %s", + http_ex, + ) + return False + _LOGGER.error("HTTP error from Nexia service: %s", http_ex) + raise ConfigEntryNotReady + + async def _async_update_data(): + """Fetch data from API endpoint.""" + return await hass.async_add_job(nexia_home.update) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="Nexia update", + update_method=_async_update_data, + update_interval=timedelta(seconds=DEFAULT_UPDATE_RATE), + ) + + hass.data[DOMAIN][entry.entry_id] = {} + hass.data[DOMAIN][entry.entry_id][DATA_NEXIA] = { + NEXIA_DEVICE: nexia_home, + UPDATE_COORDINATOR: coordinator, + } + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok diff --git a/homeassistant/components/nexia/binary_sensor.py b/homeassistant/components/nexia/binary_sensor.py new file mode 100644 index 00000000000..2802c3d7bd4 --- /dev/null +++ b/homeassistant/components/nexia/binary_sensor.py @@ -0,0 +1,89 @@ +"""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 + + +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_home = nexia_data[NEXIA_DEVICE] + coordinator = nexia_data[UPDATE_COORDINATOR] + + entities = [] + for thermostat_id in nexia_home.get_thermostat_ids(): + thermostat = nexia_home.get_thermostat_by_id(thermostat_id) + entities.append( + NexiaBinarySensor( + coordinator, thermostat, "is_blower_active", "Blower Active" + ) + ) + if thermostat.has_emergency_heat(): + entities.append( + NexiaBinarySensor( + coordinator, + thermostat, + "is_emergency_heat_active", + "Emergency Heat Active", + ) + ) + + async_add_entities(entities, True) + + +class NexiaBinarySensor(NexiaEntity, BinarySensorDevice): + """Provices Nexia BinarySensor support.""" + + def __init__(self, coordinator, device, 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}" + 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)() diff --git a/homeassistant/components/nexia/climate.py b/homeassistant/components/nexia/climate.py new file mode 100644 index 00000000000..a1f6bb155f9 --- /dev/null +++ b/homeassistant/components/nexia/climate.py @@ -0,0 +1,389 @@ +"""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, +) + +from homeassistant.components.climate import ClimateDevice +from homeassistant.components.climate.const import ( + 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, +) +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, +) + +from .const import ( + ATTR_DEHUMIDIFY_SETPOINT, + ATTR_DEHUMIDIFY_SUPPORTED, + ATTR_HUMIDIFY_SETPOINT, + ATTR_HUMIDIFY_SUPPORTED, + ATTR_ZONE_STATUS, + ATTRIBUTION, + DATA_NEXIA, + DOMAIN, + MANUFACTURER, + NEXIA_DEVICE, + UPDATE_COORDINATOR, +) +from .entity import NexiaEntity + +_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][DATA_NEXIA] + nexia_home = nexia_data[NEXIA_DEVICE] + coordinator = nexia_data[UPDATE_COORDINATOR] + + 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(NexiaEntity, ClimateDevice): + """Provides Nexia Climate support.""" + + def __init__(self, coordinator, device): + """Initialize the thermostat.""" + super().__init__(coordinator) + self.thermostat = device.thermostat + self._device = device + self._coordinator = coordinator + # 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 + + @property + def supported_features(self): + """Return the list of supported features.""" + supported = 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 name(self): + """Name of the zone.""" + return self._device.get_name() + + @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._device.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 + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + self.thermostat.set_fan_mode(fan_mode) + self.schedule_update_ha_state() + + @property + def preset_mode(self): + """Preset that is active.""" + return self._device.get_preset() + + @property + def preset_modes(self): + """All presets.""" + return self._device.get_presets() + + def set_humidity(self, humidity): + """Dehumidify target.""" + self.thermostat.set_dehumidify_setpoint(humidity / 100.0) + self.schedule_update_ha_state() + + @property + def target_humidity(self): + """Humidity indoors setpoint.""" + if self._has_dehumidify_support: + return round(self.thermostat.get_dehumidify_setpoint() * 100.0, 1) + if self._has_humidify_support: + return round(self.thermostat.get_humidify_setpoint() * 100.0, 1) + 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 None + + @property + def target_temperature(self): + """Temperature we try to reach.""" + if self._device.get_current_mode() == "COOL": + return self._device.get_cooling_setpoint() + return self._device.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() + + if self._device.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._device.get_requested_mode() + hold = self._device.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._device.get_cooling_setpoint() + cur_heat_temp = self._device.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._device.set_heat_cool_temp( + heat_temperature=new_heat_temp, + cool_temperature=new_cool_temp, + set_temperature=set_temp, + ) + self.schedule_update_ha_state() + + @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), + } + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + data = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_ZONE_STATUS: self._device.get_status(), + } + + 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: + 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 + ) + } + ) + 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() + + def turn_aux_heat_off(self): + """Turn. Aux Heat off.""" + self.thermostat.set_emergency_heat(False) + self.schedule_update_ha_state() + + def turn_aux_heat_on(self): + """Turn. Aux Heat on.""" + self.thermostat.set_emergency_heat(True) + self.schedule_update_ha_state() + + def turn_off(self): + """Turn. off the zone.""" + self.set_hvac_mode(OPERATION_MODE_OFF) + self.schedule_update_ha_state() + + def turn_on(self): + """Turn. on the zone.""" + self.set_hvac_mode(OPERATION_MODE_AUTO) + self.schedule_update_ha_state() + + 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) + else: + self._device.call_permanent_hold() + self._device.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() + + def set_humidify_setpoint(self, humidify_setpoint): + """Set the humidify setpoint.""" + self.thermostat.set_humidify_setpoint(humidify_setpoint / 100.0) + self.schedule_update_ha_state() + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self._coordinator.async_request_refresh() diff --git a/homeassistant/components/nexia/config_flow.py b/homeassistant/components/nexia/config_flow.py new file mode 100644 index 00000000000..a991b6056c3 --- /dev/null +++ b/homeassistant/components/nexia/config_flow.py @@ -0,0 +1,87 @@ +"""Config flow for Nexia integration.""" +import logging + +from nexia.home import NexiaHome +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({CONF_USERNAME: str, CONF_PASSWORD: str}) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + try: + nexia_home = NexiaHome( + username=data[CONF_USERNAME], + password=data[CONF_PASSWORD], + auto_login=False, + auto_update=False, + ) + await hass.async_add_executor_job(nexia_home.login) + except ConnectTimeout as ex: + _LOGGER.error("Unable to connect to Nexia service: %s", ex) + raise CannotConnect + except HTTPError as http_ex: + _LOGGER.error("HTTP error from Nexia service: %s", http_ex) + if http_ex.response.status_code >= 400 and http_ex.response.status_code < 500: + raise InvalidAuth + raise CannotConnect + + if not nexia_home.get_name(): + raise InvalidAuth + + info = {"title": nexia_home.get_name(), "house_id": nexia_home.house_id} + _LOGGER.debug("Setup ok with info: %s", info) + return info + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Nexia.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + if "base" not in errors: + await self.async_set_unique_id(info["house_id"]) + self._abort_if_unique_id_configured() + return self.async_create_entry(title=info["title"], data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + async def async_step_import(self, user_input): + """Handle import.""" + return await self.async_step_user(user_input) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/nexia/const.py b/homeassistant/components/nexia/const.py new file mode 100644 index 00000000000..7def5f156b4 --- /dev/null +++ b/homeassistant/components/nexia/const.py @@ -0,0 +1,26 @@ +"""Nexia constants.""" + +PLATFORMS = ["sensor", "binary_sensor", "climate"] + +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" + +DOMAIN = "nexia" +DEFAULT_ENTITY_NAMESPACE = "nexia" + +ATTR_ZONE_STATUS = "zone_status" +ATTR_HUMIDIFY_SUPPORTED = "humidify_supported" +ATTR_DEHUMIDIFY_SUPPORTED = "dehumidify_supported" +ATTR_HUMIDIFY_SETPOINT = "humidify_setpoint" +ATTR_DEHUMIDIFY_SETPOINT = "dehumidify_setpoint" + +UPDATE_COORDINATOR = "update_coordinator" + + +MANUFACTURER = "Trane" diff --git a/homeassistant/components/nexia/entity.py b/homeassistant/components/nexia/entity.py new file mode 100644 index 00000000000..ec02a7e5f21 --- /dev/null +++ b/homeassistant/components/nexia/entity.py @@ -0,0 +1,30 @@ +"""The nexia integration base entity.""" + +from homeassistant.helpers.entity import Entity + + +class NexiaEntity(Entity): + """Base class for nexia entities.""" + + def __init__(self, coordinator): + """Initialize the entity.""" + super().__init__() + self._coordinator = coordinator + + @property + def available(self): + """Return True if entity is available.""" + return self._coordinator.last_update_success + + @property + def should_poll(self): + """Return False, updates are controlled via coordinator.""" + return False + + async def async_added_to_hass(self): + """Subscribe to updates.""" + self._coordinator.async_add_listener(self.async_write_ha_state) + + async def async_will_remove_from_hass(self): + """Undo subscription.""" + self._coordinator.async_remove_listener(self.async_write_ha_state) diff --git a/homeassistant/components/nexia/manifest.json b/homeassistant/components/nexia/manifest.json new file mode 100644 index 00000000000..02804bf0419 --- /dev/null +++ b/homeassistant/components/nexia/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "nexia", + "name": "Nexia", + "requirements": [ + "nexia==0.4.1" + ], + "dependencies": [], + "codeowners": [ + "@ryannazaretian", "@bdraco" + ], + "documentation": "https://www.home-assistant.io/integrations/nexia", + "config_flow": true +} diff --git a/homeassistant/components/nexia/sensor.py b/homeassistant/components/nexia/sensor.py new file mode 100644 index 00000000000..251101ccb1e --- /dev/null +++ b/homeassistant/components/nexia/sensor.py @@ -0,0 +1,276 @@ +"""Support for Nexia / Trane XL Thermostats.""" + +from nexia.const import UNIT_CELSIUS + +from homeassistant.const import ( + ATTR_ATTRIBUTION, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + TEMP_FAHRENHEIT, + UNIT_PERCENTAGE, +) + +from .const import ( + ATTRIBUTION, + DATA_NEXIA, + DOMAIN, + MANUFACTURER, + NEXIA_DEVICE, + UPDATE_COORDINATOR, +) +from .entity import NexiaEntity + + +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_home = nexia_data[NEXIA_DEVICE] + coordinator = nexia_data[UPDATE_COORDINATOR] + entities = [] + + # Thermostat / System Sensors + for thermostat_id in nexia_home.get_thermostat_ids(): + thermostat = nexia_home.get_thermostat_by_id(thermostat_id) + + entities.append( + NexiaSensor( + coordinator, + thermostat, + "get_system_status", + "System Status", + None, + None, + ) + ) + # Air cleaner + entities.append( + NexiaSensor( + coordinator, + thermostat, + "get_air_cleaner_mode", + "Air Cleaner Mode", + None, + None, + ) + ) + # Compressor Speed + if thermostat.has_variable_speed_compressor(): + entities.append( + NexiaSensor( + coordinator, + thermostat, + "get_current_compressor_speed", + "Current Compressor Speed", + None, + UNIT_PERCENTAGE, + percent_conv, + ) + ) + entities.append( + NexiaSensor( + coordinator, + thermostat, + "get_requested_compressor_speed", + "Requested Compressor Speed", + None, + UNIT_PERCENTAGE, + percent_conv, + ) + ) + # Outdoor Temperature + if thermostat.has_outdoor_temperature(): + unit = ( + TEMP_CELSIUS + if thermostat.get_unit() == UNIT_CELSIUS + else TEMP_FAHRENHEIT + ) + entities.append( + NexiaSensor( + coordinator, + thermostat, + "get_outdoor_temperature", + "Outdoor Temperature", + DEVICE_CLASS_TEMPERATURE, + unit, + ) + ) + # Relative Humidity + if thermostat.has_relative_humidity(): + entities.append( + NexiaSensor( + coordinator, + thermostat, + "get_relative_humidity", + "Relative Humidity", + DEVICE_CLASS_HUMIDITY, + UNIT_PERCENTAGE, + percent_conv, + ) + ) + + # Zone Sensors + for zone_id in thermostat.get_zone_ids(): + zone = thermostat.get_zone_by_id(zone_id) + unit = ( + TEMP_CELSIUS + if thermostat.get_unit() == UNIT_CELSIUS + else TEMP_FAHRENHEIT + ) + # Temperature + entities.append( + NexiaZoneSensor( + coordinator, + zone, + "get_temperature", + "Temperature", + DEVICE_CLASS_TEMPERATURE, + unit, + None, + ) + ) + # Zone Status + entities.append( + NexiaZoneSensor( + coordinator, zone, "get_status", "Zone Status", None, None, + ) + ) + # Setpoint Status + entities.append( + NexiaZoneSensor( + coordinator, + zone, + "get_setpoint_status", + "Zone Setpoint Status", + None, + None, + ) + ) + + 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): + """Provides Nexia thermostat sensor support.""" + + def __init__( + self, + coordinator, + device, + sensor_call, + sensor_name, + sensor_class, + sensor_unit, + modifier=None, + ): + """Initialize the sensor.""" + super().__init__(coordinator) + self._coordinator = coordinator + self._device = device + 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.""" + return self._class + + @property + def state(self): + """Return the state of the sensor.""" + val = getattr(self._device, 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 + + @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): + """Nexia Zone Sensor Support.""" + + def __init__( + self, + coordinator, + device, + sensor_call, + sensor_name, + sensor_class, + sensor_unit, + modifier=None, + ): + """Create a zone sensor.""" + + super().__init__( + coordinator, + device, + sensor_call, + sensor_name, + sensor_class, + sensor_unit, + modifier, + ) + self._device = device + + @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}" + + @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), + } diff --git a/homeassistant/components/nexia/strings.json b/homeassistant/components/nexia/strings.json new file mode 100644 index 00000000000..d3fabfb0b4d --- /dev/null +++ b/homeassistant/components/nexia/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "title": "Nexia", + "step": { + "user": { + "title": "Connect to mynexia.com", + "data": { + "username": "Username", + "password": "Password" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "This nexia home is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ac24ecb9209..c981a88984e 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -73,6 +73,7 @@ FLOWS = [ "neato", "nest", "netatmo", + "nexia", "notion", "opentherm_gw", "openuv", diff --git a/requirements_all.txt b/requirements_all.txt index 4eaffe245fc..2c8bd87d43f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -916,6 +916,9 @@ netdisco==2.6.0 # homeassistant.components.neurio_energy neurio==0.3.1 +# homeassistant.components.nexia +nexia==0.4.1 + # homeassistant.components.niko_home_control niko-home-control==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 72acaf4dd1d..45bd9b53458 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -343,6 +343,9 @@ nessclient==0.9.15 # homeassistant.components.ssdp netdisco==2.6.0 +# homeassistant.components.nexia +nexia==0.4.1 + # homeassistant.components.nsw_fuel_station nsw-fuel-api-client==1.0.10 diff --git a/tests/components/nexia/__init__.py b/tests/components/nexia/__init__.py new file mode 100644 index 00000000000..27e986cc148 --- /dev/null +++ b/tests/components/nexia/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nexia integration.""" diff --git a/tests/components/nexia/test_config_flow.py b/tests/components/nexia/test_config_flow.py new file mode 100644 index 00000000000..3cb57d77f12 --- /dev/null +++ b/tests/components/nexia/test_config_flow.py @@ -0,0 +1,76 @@ +"""Test the nexia config flow.""" +from asynctest import patch +from asynctest.mock import MagicMock +from requests.exceptions import ConnectTimeout + +from homeassistant import config_entries, setup +from homeassistant.components.nexia.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.nexia.config_flow.NexiaHome.get_name", + return_value="myhouse", + ), patch( + "homeassistant.components.nexia.config_flow.NexiaHome.login", + side_effect=MagicMock(), + ), patch( + "homeassistant.components.nexia.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.nexia.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "myhouse" + assert result2["data"] == { + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("homeassistant.components.nexia.config_flow.NexiaHome.login"): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "homeassistant.components.nexia.config_flow.NexiaHome.login", + side_effect=ConnectTimeout, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "username", CONF_PASSWORD: "password"}, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"}