From 260d9f8e16cf8593a5629a660f0864209afbbcfd Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Tue, 26 Jan 2021 03:18:20 -0500 Subject: [PATCH] Upgrade econet to use new API (#44427) Co-authored-by: Martin Hjelmare --- .coveragerc | 6 +- CODEOWNERS | 1 + homeassistant/components/econet/__init__.py | 159 ++++++++++- .../components/econet/binary_sensor.py | 82 ++++++ .../components/econet/config_flow.py | 61 +++++ homeassistant/components/econet/const.py | 4 +- homeassistant/components/econet/manifest.json | 10 +- homeassistant/components/econet/sensor.py | 122 +++++++++ homeassistant/components/econet/services.yaml | 19 -- homeassistant/components/econet/strings.json | 22 ++ .../components/econet/translations/en.json | 21 ++ .../components/econet/water_heater.py | 258 ++++++------------ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 3 + tests/components/econet/__init__.py | 1 + tests/components/econet/test_config_flow.py | 140 ++++++++++ 17 files changed, 717 insertions(+), 195 deletions(-) create mode 100644 homeassistant/components/econet/binary_sensor.py create mode 100644 homeassistant/components/econet/config_flow.py create mode 100644 homeassistant/components/econet/sensor.py delete mode 100644 homeassistant/components/econet/services.yaml create mode 100644 homeassistant/components/econet/strings.json create mode 100644 homeassistant/components/econet/translations/en.json create mode 100644 tests/components/econet/__init__.py create mode 100644 tests/components/econet/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 4599ddfbd7b..da6cfd2a3b9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -215,7 +215,11 @@ omit = homeassistant/components/ecobee/notify.py homeassistant/components/ecobee/sensor.py homeassistant/components/ecobee/weather.py - homeassistant/components/econet/* + homeassistant/components/econet/__init__.py + homeassistant/components/econet/binary_sensor.py + homeassistant/components/econet/const.py + homeassistant/components/econet/sensor.py + homeassistant/components/econet/water_heater.py homeassistant/components/ecovacs/* homeassistant/components/edl21/* homeassistant/components/eddystone_temperature/sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 8ade4488540..8c4c88d2a7d 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -120,6 +120,7 @@ homeassistant/components/dweet/* @fabaff homeassistant/components/dynalite/* @ziv1234 homeassistant/components/eafm/* @Jc2k homeassistant/components/ecobee/* @marthoc +homeassistant/components/econet/* @vangorra @w1ll1am23 homeassistant/components/ecovacs/* @OverloadUT homeassistant/components/edl21/* @mtdcr homeassistant/components/egardia/* @jeroenterheerdt diff --git a/homeassistant/components/econet/__init__.py b/homeassistant/components/econet/__init__.py index 48b7dad4c7c..dce4550eb1b 100644 --- a/homeassistant/components/econet/__init__.py +++ b/homeassistant/components/econet/__init__.py @@ -1 +1,158 @@ -"""The econet component.""" +"""Support for EcoNet products.""" +import asyncio +from datetime import timedelta +import logging + +from aiohttp.client_exceptions import ClientError +from pyeconet import EcoNetApiInterface +from pyeconet.equipment import EquipmentType +from pyeconet.errors import ( + GenericHTTPError, + InvalidCredentialsError, + InvalidResponseFormat, + PyeconetError, +) + +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.dispatcher import dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval + +from .const import API_CLIENT, DOMAIN, EQUIPMENT + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["binary_sensor", "sensor", "water_heater"] +PUSH_UPDATE = "econet.push_update" + +INTERVAL = timedelta(minutes=60) + + +async def async_setup(hass, config): + """Set up the EcoNet component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][API_CLIENT] = {} + hass.data[DOMAIN][EQUIPMENT] = {} + return True + + +async def async_setup_entry(hass, config_entry): + """Set up EcoNet as config entry.""" + + email = config_entry.data[CONF_EMAIL] + password = config_entry.data[CONF_PASSWORD] + + try: + api = await EcoNetApiInterface.login(email, password=password) + except InvalidCredentialsError: + _LOGGER.error("Invalid credentials provided") + return False + except PyeconetError as err: + _LOGGER.error("Config entry failed: %s", err) + raise ConfigEntryNotReady from err + + try: + equipment = await api.get_equipment_by_type([EquipmentType.WATER_HEATER]) + except (ClientError, GenericHTTPError, InvalidResponseFormat) as err: + raise ConfigEntryNotReady from err + hass.data[DOMAIN][API_CLIENT][config_entry.entry_id] = api + hass.data[DOMAIN][EQUIPMENT][config_entry.entry_id] = equipment + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, component) + ) + + api.subscribe() + + def update_published(): + """Handle a push update.""" + dispatcher_send(hass, PUSH_UPDATE) + + for _eqip in equipment[EquipmentType.WATER_HEATER]: + _eqip.set_update_callback(update_published) + + async def resubscribe(now): + """Resubscribe to the MQTT updates.""" + await hass.async_add_executor_job(api.unsubscribe) + api.subscribe() + + async def fetch_update(now): + """Fetch the latest changes from the API.""" + await api.refresh_equipment() + + async_track_time_interval(hass, resubscribe, INTERVAL) + async_track_time_interval(hass, fetch_update, INTERVAL + timedelta(minutes=1)) + + return True + + +async def async_unload_entry(hass, entry): + """Unload a EcoNet config entry.""" + tasks = [ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + + await asyncio.gather(*tasks) + + hass.data[DOMAIN][API_CLIENT].pop(entry.entry_id) + hass.data[DOMAIN][EQUIPMENT].pop(entry.entry_id) + + return True + + +class EcoNetEntity(Entity): + """Define a base EcoNet entity.""" + + def __init__(self, econet): + """Initialize.""" + self._econet = econet + + async def async_added_to_hass(self): + """Subscribe to device events.""" + await super().async_added_to_hass() + self.async_on_remove( + self.hass.helpers.dispatcher.async_dispatcher_connect( + PUSH_UPDATE, self.on_update_received + ) + ) + + @callback + def on_update_received(self): + """Update was pushed from the ecoent API.""" + self.async_write_ha_state() + + @property + def available(self): + """Return if the the device is online or not.""" + return self._econet.connected + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + "identifiers": {(DOMAIN, self._econet.device_id)}, + "manufacturer": "Rheem", + "name": self._econet.device_name, + } + + @property + def name(self): + """Return the name of the entity.""" + return self._econet.device_name + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return f"{self._econet.device_id}_{self._econet.device_name}" + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return False diff --git a/homeassistant/components/econet/binary_sensor.py b/homeassistant/components/econet/binary_sensor.py new file mode 100644 index 00000000000..ec8131c5105 --- /dev/null +++ b/homeassistant/components/econet/binary_sensor.py @@ -0,0 +1,82 @@ +"""Support for Rheem EcoNet water heaters.""" +import logging + +from pyeconet.equipment import EquipmentType + +from homeassistant.components.binary_sensor import ( + DEVICE_CLASS_OPENING, + DEVICE_CLASS_POWER, + BinarySensorEntity, +) + +from . import EcoNetEntity +from .const import DOMAIN, EQUIPMENT + +_LOGGER = logging.getLogger(__name__) + +SENSOR_NAME_RUNNING = "running" +SENSOR_NAME_SHUTOFF_VALVE = "shutoff_valve" +SENSOR_NAME_VACATION = "vacation" + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up EcoNet binary sensor based on a config entry.""" + equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + binary_sensors = [] + for water_heater in equipment[EquipmentType.WATER_HEATER]: + if water_heater.has_shutoff_valve: + binary_sensors.append( + EcoNetBinarySensor( + water_heater, + SENSOR_NAME_SHUTOFF_VALVE, + ) + ) + if water_heater.running is not None: + binary_sensors.append(EcoNetBinarySensor(water_heater, SENSOR_NAME_RUNNING)) + if water_heater.vacation is not None: + binary_sensors.append( + EcoNetBinarySensor(water_heater, SENSOR_NAME_VACATION) + ) + async_add_entities(binary_sensors) + + +class EcoNetBinarySensor(EcoNetEntity, BinarySensorEntity): + """Define a Econet binary sensor.""" + + def __init__(self, econet_device, device_name): + """Initialize.""" + super().__init__(econet_device) + self._econet = econet_device + self._device_name = device_name + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + if self._device_name == SENSOR_NAME_SHUTOFF_VALVE: + return self._econet.shutoff_valve_open + if self._device_name == SENSOR_NAME_RUNNING: + return self._econet.running + if self._device_name == SENSOR_NAME_VACATION: + return self._econet.vacation + return False + + @property + def device_class(self): + """Return the class of this sensor, from DEVICE_CLASSES.""" + if self._device_name == SENSOR_NAME_SHUTOFF_VALVE: + return DEVICE_CLASS_OPENING + if self._device_name == SENSOR_NAME_RUNNING: + return DEVICE_CLASS_POWER + return None + + @property + def name(self): + """Return the name of the entity.""" + return f"{self._econet.device_name}_{self._device_name}" + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return ( + f"{self._econet.device_id}_{self._econet.device_name}_{self._device_name}" + ) diff --git a/homeassistant/components/econet/config_flow.py b/homeassistant/components/econet/config_flow.py new file mode 100644 index 00000000000..78aff2eac8f --- /dev/null +++ b/homeassistant/components/econet/config_flow.py @@ -0,0 +1,61 @@ +"""Config flow to configure the EcoNet component.""" +from pyeconet import EcoNetApiInterface +from pyeconet.errors import InvalidCredentialsError, PyeconetError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD + +from .const import DOMAIN # pylint: disable=unused-import + + +class EcoNetFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle an EcoNet config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize the config flow.""" + self.data_schema = vol.Schema( + { + vol.Required(CONF_EMAIL): str, + vol.Required(CONF_PASSWORD): str, + } + ) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return self.async_show_form( + step_id="user", + data_schema=self.data_schema, + ) + + await self.async_set_unique_id(user_input[CONF_EMAIL]) + self._abort_if_unique_id_configured() + errors = {} + + try: + await EcoNetApiInterface.login( + user_input[CONF_EMAIL], user_input[CONF_PASSWORD] + ) + except InvalidCredentialsError: + errors["base"] = "invalid_auth" + except PyeconetError: + errors["base"] = "cannot_connect" + + if errors: + return self.async_show_form( + step_id="user", + data_schema=self.data_schema, + errors=errors, + ) + + return self.async_create_entry( + title=user_input[CONF_EMAIL], + data={ + CONF_EMAIL: user_input[CONF_EMAIL], + CONF_PASSWORD: user_input[CONF_PASSWORD], + }, + ) diff --git a/homeassistant/components/econet/const.py b/homeassistant/components/econet/const.py index 88b1b851aa6..46c70021048 100644 --- a/homeassistant/components/econet/const.py +++ b/homeassistant/components/econet/const.py @@ -1,5 +1,5 @@ """Constants for Econet integration.""" DOMAIN = "econet" -SERVICE_ADD_VACATION = "add_vacation" -SERVICE_DELETE_VACATION = "delete_vacation" +API_CLIENT = "api_client" +EQUIPMENT = "equipment" diff --git a/homeassistant/components/econet/manifest.json b/homeassistant/components/econet/manifest.json index 21476d2b7ff..7e4cf0106ba 100644 --- a/homeassistant/components/econet/manifest.json +++ b/homeassistant/components/econet/manifest.json @@ -1,7 +1,9 @@ + { "domain": "econet", - "name": "Rheem EcoNET Water Products", + "name": "Rheem EcoNet Products", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/econet", - "requirements": ["pyeconet==0.0.11"], - "codeowners": [] -} + "requirements": ["pyeconet==0.1.12"], + "codeowners": ["@vangorra", "@w1ll1am23"] +} \ No newline at end of file diff --git a/homeassistant/components/econet/sensor.py b/homeassistant/components/econet/sensor.py new file mode 100644 index 00000000000..6ae14d18aa1 --- /dev/null +++ b/homeassistant/components/econet/sensor.py @@ -0,0 +1,122 @@ +"""Support for Rheem EcoNet water heaters.""" +import logging + +from pyeconet.equipment import EquipmentType + +from homeassistant.const import ( + ENERGY_KILO_WATT_HOUR, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + VOLUME_GALLONS, +) + +from . import EcoNetEntity +from .const import DOMAIN, EQUIPMENT + +ENERGY_KILO_BRITISH_THERMAL_UNIT = "kBtu" +TANK_HEALTH = "tank_health" +AVAILIBLE_HOT_WATER = "availible_hot_water" +COMPRESSOR_HEALTH = "compressor_health" +OVERRIDE_STATUS = "oveerride_status" +WATER_USAGE_TODAY = "water_usage_today" +POWER_USAGE_TODAY = "power_usage_today" +ALERT_COUNT = "alert_count" +WIFI_SIGNAL = "wifi_signal" +RUNNING_STATE = "running_state" + + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up EcoNet sensor based on a config entry.""" + equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + sensors = [] + for water_heater in equipment[EquipmentType.WATER_HEATER]: + if water_heater.tank_hot_water_availability is not None: + sensors.append(EcoNetSensor(water_heater, AVAILIBLE_HOT_WATER)) + if water_heater.tank_health is not None: + sensors.append(EcoNetSensor(water_heater, TANK_HEALTH)) + if water_heater.compressor_health is not None: + sensors.append(EcoNetSensor(water_heater, COMPRESSOR_HEALTH)) + if water_heater.override_status: + sensors.append(EcoNetSensor(water_heater, OVERRIDE_STATUS)) + if water_heater.running_state is not None: + sensors.append(EcoNetSensor(water_heater, RUNNING_STATE)) + # All units have this + sensors.append(EcoNetSensor(water_heater, ALERT_COUNT)) + # These aren't part of the device and start off as None in pyeconet so always add them + sensors.append(EcoNetSensor(water_heater, WATER_USAGE_TODAY)) + sensors.append(EcoNetSensor(water_heater, POWER_USAGE_TODAY)) + sensors.append(EcoNetSensor(water_heater, WIFI_SIGNAL)) + async_add_entities(sensors) + + +class EcoNetSensor(EcoNetEntity): + """Define a Econet sensor.""" + + def __init__(self, econet_device, device_name): + """Initialize.""" + super().__init__(econet_device) + self._econet = econet_device + self._device_name = device_name + + @property + def state(self): + """Return sensors state.""" + if self._device_name == AVAILIBLE_HOT_WATER: + return self._econet.tank_hot_water_availability + if self._device_name == TANK_HEALTH: + return self._econet.tank_health + if self._device_name == COMPRESSOR_HEALTH: + return self._econet.compressor_health + if self._device_name == OVERRIDE_STATUS: + return self._econet.oveerride_status + if self._device_name == WATER_USAGE_TODAY: + if self._econet.todays_water_usage: + return round(self._econet.todays_water_usage, 2) + return None + if self._device_name == POWER_USAGE_TODAY: + if self._econet.todays_energy_usage: + return round(self._econet.todays_energy_usage, 2) + return None + if self._device_name == WIFI_SIGNAL: + if self._econet.wifi_signal: + return self._econet.wifi_signal + return None + if self._device_name == ALERT_COUNT: + return self._econet.alert_count + if self._device_name == RUNNING_STATE: + return self._econet.running_state + return None + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + if self._device_name == AVAILIBLE_HOT_WATER: + return PERCENTAGE + if self._device_name == TANK_HEALTH: + return PERCENTAGE + if self._device_name == COMPRESSOR_HEALTH: + return PERCENTAGE + if self._device_name == WATER_USAGE_TODAY: + return VOLUME_GALLONS + if self._device_name == POWER_USAGE_TODAY: + if self._econet.energy_type == ENERGY_KILO_BRITISH_THERMAL_UNIT.upper(): + return ENERGY_KILO_BRITISH_THERMAL_UNIT + return ENERGY_KILO_WATT_HOUR + if self._device_name == WIFI_SIGNAL: + return SIGNAL_STRENGTH_DECIBELS_MILLIWATT + return None + + @property + def name(self): + """Return the name of the entity.""" + return f"{self._econet.device_name}_{self._device_name}" + + @property + def unique_id(self): + """Return the unique ID of the entity.""" + return ( + f"{self._econet.device_id}_{self._econet.device_name}_{self._device_name}" + ) diff --git a/homeassistant/components/econet/services.yaml b/homeassistant/components/econet/services.yaml deleted file mode 100644 index b531764c290..00000000000 --- a/homeassistant/components/econet/services.yaml +++ /dev/null @@ -1,19 +0,0 @@ -add_vacation: - description: Add a vacation to your water heater. - fields: - entity_id: - description: Name(s) of entities to change. - example: "water_heater.econet" - start_date: - description: The timestamp of when the vacation should start. (Optional, defaults to now) - example: 1513186320 - end_date: - description: The timestamp of when the vacation should end. - example: 1513445520 - -delete_vacation: - description: Delete your existing vacation from your water heater. - fields: - entity_id: - description: Name(s) of entities to change. - example: "water_heater.econet" diff --git a/homeassistant/components/econet/strings.json b/homeassistant/components/econet/strings.json new file mode 100644 index 00000000000..9d043e47ebc --- /dev/null +++ b/homeassistant/components/econet/strings.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + }, + "abort": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "step": { + "user": { + "title": "Setup Rheem EcoNet Account", + "data": { + "email": "[%key:common::config_flow::data::email%]", + "password": "[%key:common::config_flow::data::password%]" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/translations/en.json b/homeassistant/components/econet/translations/en.json new file mode 100644 index 00000000000..4061c094c1f --- /dev/null +++ b/homeassistant/components/econet/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication" + }, + "step": { + "user": { + "data": { + "email": "Email", + "password": "Password" + }, + "title": "Setup Rheem EcoNet Account" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/econet/water_heater.py b/homeassistant/components/econet/water_heater.py index 0c31e3e50e0..af3399b53af 100644 --- a/homeassistant/components/econet/water_heater.py +++ b/homeassistant/components/econet/water_heater.py @@ -1,12 +1,11 @@ """Support for Rheem EcoNet water heaters.""" -import datetime import logging -from pyeconet.api import PyEcoNet -import voluptuous as vol +from pyeconet.equipment import EquipmentType +from pyeconet.equipment.water_heater import WaterHeaterOperationMode from homeassistant.components.water_heater import ( - PLATFORM_SCHEMA, + ATTR_TEMPERATURE, STATE_ECO, STATE_ELECTRIC, STATE_GAS, @@ -14,222 +13,125 @@ from homeassistant.components.water_heater import ( STATE_HIGH_DEMAND, STATE_OFF, STATE_PERFORMANCE, + SUPPORT_AWAY_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, WaterHeaterEntity, ) -from homeassistant.const import ( - ATTR_ENTITY_ID, - ATTR_TEMPERATURE, - CONF_PASSWORD, - CONF_USERNAME, - TEMP_FAHRENHEIT, -) -import homeassistant.helpers.config_validation as cv +from homeassistant.const import TEMP_FAHRENHEIT +from homeassistant.core import callback -from .const import DOMAIN, SERVICE_ADD_VACATION, SERVICE_DELETE_VACATION +from . import EcoNetEntity +from .const import DOMAIN, EQUIPMENT _LOGGER = logging.getLogger(__name__) -ATTR_VACATION_START = "next_vacation_start_date" -ATTR_VACATION_END = "next_vacation_end_date" -ATTR_ON_VACATION = "on_vacation" -ATTR_TODAYS_ENERGY_USAGE = "todays_energy_usage" -ATTR_IN_USE = "in_use" - -ATTR_START_DATE = "start_date" -ATTR_END_DATE = "end_date" - -ATTR_LOWER_TEMP = "lower_temp" -ATTR_UPPER_TEMP = "upper_temp" -ATTR_IS_ENABLED = "is_enabled" +ECONET_STATE_TO_HA = { + WaterHeaterOperationMode.ENERGY_SAVING: STATE_ECO, + WaterHeaterOperationMode.HIGH_DEMAND: STATE_HIGH_DEMAND, + WaterHeaterOperationMode.OFF: STATE_OFF, + WaterHeaterOperationMode.HEAT_PUMP_ONLY: STATE_HEAT_PUMP, + WaterHeaterOperationMode.ELECTRIC_MODE: STATE_ELECTRIC, + WaterHeaterOperationMode.GAS: STATE_GAS, + WaterHeaterOperationMode.PERFORMANCE: STATE_PERFORMANCE, +} +HA_STATE_TO_ECONET = {value: key for key, value in ECONET_STATE_TO_HA.items()} SUPPORT_FLAGS_HEATER = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE -ADD_VACATION_SCHEMA = vol.Schema( - { - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, - vol.Optional(ATTR_START_DATE): cv.positive_int, - vol.Required(ATTR_END_DATE): cv.positive_int, - } -) -DELETE_VACATION_SCHEMA = vol.Schema({vol.Optional(ATTR_ENTITY_ID): cv.entity_ids}) - -ECONET_DATA = "econet" - -ECONET_STATE_TO_HA = { - "Energy Saver": STATE_ECO, - "gas": STATE_GAS, - "High Demand": STATE_HIGH_DEMAND, - "Off": STATE_OFF, - "Performance": STATE_PERFORMANCE, - "Heat Pump Only": STATE_HEAT_PUMP, - "Electric-Only": STATE_ELECTRIC, - "Electric": STATE_ELECTRIC, - "Heat Pump": STATE_HEAT_PUMP, -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - {vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string} -) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the EcoNet water heaters.""" - - hass.data[ECONET_DATA] = {} - hass.data[ECONET_DATA]["water_heaters"] = [] - - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) - - econet = PyEcoNet(username, password) - water_heaters = econet.get_water_heaters() - hass_water_heaters = [ - EcoNetWaterHeater(water_heater) for water_heater in water_heaters - ] - add_entities(hass_water_heaters) - hass.data[ECONET_DATA]["water_heaters"].extend(hass_water_heaters) - - def service_handle(service): - """Handle the service calls.""" - entity_ids = service.data.get("entity_id") - all_heaters = hass.data[ECONET_DATA]["water_heaters"] - _heaters = [ - x for x in all_heaters if not entity_ids or x.entity_id in entity_ids - ] - - for _water_heater in _heaters: - if service.service == SERVICE_ADD_VACATION: - start = service.data.get(ATTR_START_DATE) - end = service.data.get(ATTR_END_DATE) - _water_heater.add_vacation(start, end) - if service.service == SERVICE_DELETE_VACATION: - for vacation in _water_heater.water_heater.vacations: - vacation.delete() - - _water_heater.schedule_update_ha_state(True) - - hass.services.register( - DOMAIN, SERVICE_ADD_VACATION, service_handle, schema=ADD_VACATION_SCHEMA - ) - - hass.services.register( - DOMAIN, SERVICE_DELETE_VACATION, service_handle, schema=DELETE_VACATION_SCHEMA +async def async_setup_entry(hass, entry, async_add_entities): + """Set up EcoNet water heater based on a config entry.""" + equipment = hass.data[DOMAIN][EQUIPMENT][entry.entry_id] + async_add_entities( + [ + EcoNetWaterHeater(water_heater) + for water_heater in equipment[EquipmentType.WATER_HEATER] + ], ) -class EcoNetWaterHeater(WaterHeaterEntity): - """Representation of an EcoNet water heater.""" +class EcoNetWaterHeater(EcoNetEntity, WaterHeaterEntity): + """Define a Econet water heater.""" def __init__(self, water_heater): - """Initialize the water heater.""" + """Initialize.""" + super().__init__(water_heater) + self._running = water_heater.running + self._poll = True self.water_heater = water_heater - self.supported_modes = self.water_heater.supported_modes self.econet_state_to_ha = {} self.ha_state_to_econet = {} - for mode in ECONET_STATE_TO_HA: - if mode in self.supported_modes: - self.econet_state_to_ha[mode] = ECONET_STATE_TO_HA.get(mode) - for key, value in self.econet_state_to_ha.items(): - self.ha_state_to_econet[value] = key - for mode in self.supported_modes: - if mode not in ECONET_STATE_TO_HA: - error = f"Invalid operation mode mapping. {mode} doesn't map. Please report this." - _LOGGER.error(error) + + @callback + def on_update_received(self): + """Update was pushed from the ecoent API.""" + if self._running != self.water_heater.running: + # Water heater running state has changed so check usage on next update + self._poll = True + self._running = self.water_heater.running + self.async_write_ha_state() @property - def name(self): - """Return the device name.""" - return self.water_heater.name - - @property - def available(self): - """Return if the the device is online or not.""" - return self.water_heater.is_connected + def is_away_mode_on(self): + """Return true if away mode is on.""" + return self._econet.away @property def temperature_unit(self): """Return the unit of measurement.""" return TEMP_FAHRENHEIT - @property - def device_state_attributes(self): - """Return the optional device state attributes.""" - data = {} - vacations = self.water_heater.get_vacations() - if vacations: - data[ATTR_VACATION_START] = vacations[0].start_date - data[ATTR_VACATION_END] = vacations[0].end_date - data[ATTR_ON_VACATION] = self.water_heater.is_on_vacation - todays_usage = self.water_heater.total_usage_for_today - if todays_usage: - data[ATTR_TODAYS_ENERGY_USAGE] = todays_usage - data[ATTR_IN_USE] = self.water_heater.in_use - - if self.water_heater.lower_temp is not None: - data[ATTR_LOWER_TEMP] = round(self.water_heater.lower_temp, 2) - if self.water_heater.upper_temp is not None: - data[ATTR_UPPER_TEMP] = round(self.water_heater.upper_temp, 2) - if self.water_heater.is_enabled is not None: - data[ATTR_IS_ENABLED] = self.water_heater.is_enabled - - return data - @property def current_operation(self): - """ - Return current operation as one of the following. + """Return current operation.""" + econet_mode = self.water_heater.mode + _current_op = STATE_OFF + if econet_mode is not None: + _current_op = ECONET_STATE_TO_HA[econet_mode] - ["eco", "heat_pump", "high_demand", "electric_only"] - """ - current_op = self.econet_state_to_ha.get(self.water_heater.mode) - return current_op + return _current_op @property def operation_list(self): """List of available operation modes.""" + econet_modes = self.water_heater.modes op_list = [] - for mode in self.supported_modes: - ha_mode = self.econet_state_to_ha.get(mode) - if ha_mode is not None: + for mode in econet_modes: + if ( + mode is not WaterHeaterOperationMode.UNKNOWN + and mode is not WaterHeaterOperationMode.VACATION + ): + ha_mode = ECONET_STATE_TO_HA[mode] op_list.append(ha_mode) return op_list @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS_HEATER + if self.water_heater.modes: + if self.water_heater.supports_away: + return SUPPORT_FLAGS_HEATER | SUPPORT_AWAY_MODE + return SUPPORT_FLAGS_HEATER + if self.water_heater.supports_away: + return SUPPORT_TARGET_TEMPERATURE | SUPPORT_AWAY_MODE + return SUPPORT_TARGET_TEMPERATURE def set_temperature(self, **kwargs): """Set new target temperature.""" target_temp = kwargs.get(ATTR_TEMPERATURE) if target_temp is not None: - self.water_heater.set_target_set_point(target_temp) + self.water_heater.set_set_point(target_temp) else: _LOGGER.error("A target temperature must be provided") def set_operation_mode(self, operation_mode): """Set operation mode.""" - op_mode_to_set = self.ha_state_to_econet.get(operation_mode) + op_mode_to_set = HA_STATE_TO_ECONET.get(operation_mode) if op_mode_to_set is not None: self.water_heater.set_mode(op_mode_to_set) else: - _LOGGER.error("An operation mode must be provided") - - def add_vacation(self, start, end): - """Add a vacation to this water heater.""" - if not start: - start = datetime.datetime.now() - else: - start = datetime.datetime.fromtimestamp(start) - end = datetime.datetime.fromtimestamp(end) - self.water_heater.set_vacation_mode(start, end) - - def update(self): - """Get the latest date.""" - self.water_heater.update_state() + _LOGGER.error("Invalid operation mode: %s", operation_mode) @property def target_temperature(self): @@ -239,9 +141,31 @@ class EcoNetWaterHeater(WaterHeaterEntity): @property def min_temp(self): """Return the minimum temperature.""" - return self.water_heater.min_set_point + return self.water_heater.set_point_limits[0] @property def max_temp(self): """Return the maximum temperature.""" - return self.water_heater.max_set_point + return self.water_heater.set_point_limits[1] + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state. + + False if entity pushes its state to HA. + """ + return self._poll + + async def async_update(self): + """Get the latest energy usage.""" + await self.water_heater.get_energy_usage() + await self.water_heater.get_water_usage() + self._poll = False + + def turn_away_mode_on(self): + """Turn away mode on.""" + self.water_heater.set_away_mode(True) + + def turn_away_mode_off(self): + """Turn away mode off.""" + self.water_heater.set_away_mode(False) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 2d5ab9d926b..7e273befb16 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -54,6 +54,7 @@ FLOWS = [ "dynalite", "eafm", "ecobee", + "econet", "elgato", "elkm1", "emulated_roku", diff --git a/requirements_all.txt b/requirements_all.txt index 6ca8f98331f..2de707415e5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1355,7 +1355,7 @@ pydroid-ipcam==0.8 pyebox==1.1.4 # homeassistant.components.econet -pyeconet==0.0.11 +pyeconet==0.1.12 # homeassistant.components.edimax pyedimax==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5c14d798f66..c2be2492200 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -684,6 +684,9 @@ pydexcom==0.2.0 # homeassistant.components.zwave pydispatcher==2.0.5 +# homeassistant.components.econet +pyeconet==0.1.12 + # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/econet/__init__.py b/tests/components/econet/__init__.py new file mode 100644 index 00000000000..c0f921f65d0 --- /dev/null +++ b/tests/components/econet/__init__.py @@ -0,0 +1 @@ +"""Tests for the Econet component.""" diff --git a/tests/components/econet/test_config_flow.py b/tests/components/econet/test_config_flow.py new file mode 100644 index 00000000000..68eb18a931e --- /dev/null +++ b/tests/components/econet/test_config_flow.py @@ -0,0 +1,140 @@ +"""Tests for the Econet component.""" +from unittest.mock import patch + +from pyeconet.api import EcoNetApiInterface +from pyeconet.errors import InvalidCredentialsError, PyeconetError + +from homeassistant.components.econet import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_EMAIL, CONF_PASSWORD +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_bad_credentials(hass): + """Test when provided credentials are rejected.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + with patch( + "pyeconet.EcoNetApiInterface.login", + side_effect=InvalidCredentialsError(), + ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( + "homeassistant.components.econet.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "admin@localhost.com", + CONF_PASSWORD: "password0", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == { + "base": "invalid_auth", + } + + +async def test_generic_error_from_library(hass): + """Test when connection fails.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + with patch( + "pyeconet.EcoNetApiInterface.login", + side_effect=PyeconetError(), + ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( + "homeassistant.components.econet.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "admin@localhost.com", + CONF_PASSWORD: "password0", + }, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == { + "base": "cannot_connect", + } + + +async def test_auth_worked(hass): + """Test when provided credentials are accepted.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + with patch( + "pyeconet.EcoNetApiInterface.login", + return_value=EcoNetApiInterface, + ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( + "homeassistant.components.econet.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "admin@localhost.com", + CONF_PASSWORD: "password0", + }, + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + CONF_EMAIL: "admin@localhost.com", + CONF_PASSWORD: "password0", + } + + +async def test_already_configured(hass): + """Test when provided credentials are already configured.""" + config = { + CONF_EMAIL: "admin@localhost.com", + CONF_PASSWORD: "password0", + } + MockConfigEntry( + domain=DOMAIN, data=config, unique_id="admin@localhost.com" + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + with patch( + "pyeconet.EcoNetApiInterface.login", + return_value=EcoNetApiInterface, + ), patch("homeassistant.components.econet.async_setup", return_value=True), patch( + "homeassistant.components.econet.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_EMAIL: "admin@localhost.com", + CONF_PASSWORD: "password0", + }, + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured"