diff --git a/homeassistant/components/efergy/__init__.py b/homeassistant/components/efergy/__init__.py index 8ceeb1585a4..74bcf6ff7b0 100644 --- a/homeassistant/components/efergy/__init__.py +++ b/homeassistant/components/efergy/__init__.py @@ -1 +1,83 @@ -"""The efergy component.""" +"""The Efergy integration.""" +from __future__ import annotations + +from pyefergy import Efergy, exceptions + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + ATTR_ATTRIBUTION, + ATTR_IDENTIFIERS, + ATTR_MANUFACTURER, + ATTR_MODEL, + ATTR_NAME, + ATTR_SW_VERSION, + CONF_API_KEY, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.entity import DeviceInfo, Entity + +from .const import ATTRIBUTION, DATA_KEY_API, DEFAULT_NAME, DOMAIN + +PLATFORMS = [SENSOR_DOMAIN] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Efergy from a config entry.""" + api = Efergy( + entry.data[CONF_API_KEY], + session=async_get_clientsession(hass), + utc_offset=hass.config.time_zone, + currency=hass.config.currency, + ) + + try: + await api.async_status(get_sids=True) + except (exceptions.ConnectError, exceptions.DataError) as ex: + raise ConfigEntryNotReady(f"Failed to connect to device: {ex}") from ex + except exceptions.InvalidAuth as ex: + raise ConfigEntryAuthFailed( + "API Key is no longer valid. Please reauthenticate" + ) from ex + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {DATA_KEY_API: api} + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + return unload_ok + + +class EfergyEntity(Entity): + """Representation of a Efergy entity.""" + + def __init__( + self, + api: Efergy, + server_unique_id: str, + ) -> None: + """Initialize an Efergy entity.""" + self.api = api + self._server_unique_id = server_unique_id + self._attr_extra_state_attributes = {ATTR_ATTRIBUTION: ATTRIBUTION} + + @property + def device_info(self) -> DeviceInfo: + """Return the device information of the entity.""" + return { + "connections": {(dr.CONNECTION_NETWORK_MAC, self.api.info["mac"])}, + ATTR_IDENTIFIERS: {(DOMAIN, self._server_unique_id)}, + ATTR_MANUFACTURER: DEFAULT_NAME, + ATTR_NAME: DEFAULT_NAME, + ATTR_MODEL: self.api.info["type"], + ATTR_SW_VERSION: self.api.info["version"], + } diff --git a/homeassistant/components/efergy/config_flow.py b/homeassistant/components/efergy/config_flow.py new file mode 100644 index 00000000000..3fb5fbec4a6 --- /dev/null +++ b/homeassistant/components/efergy/config_flow.py @@ -0,0 +1,85 @@ +"""Config flow for Efergy integration.""" +from __future__ import annotations + +import logging +from typing import Any + +from pyefergy import Efergy, exceptions +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import ConfigType + +from .const import CONF_APPTOKEN, DEFAULT_NAME, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class EfergyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for Efergy.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initiated by the user.""" + errors = {} + if user_input is not None: + api_key = user_input[CONF_API_KEY] + + self._async_abort_entries_match({CONF_API_KEY: api_key}) + hid, error = await self._async_try_connect(api_key) + if error is None: + entry = await self.async_set_unique_id(hid) + if entry: + self.hass.config_entries.async_update_entry(entry, data=user_input) + await self.hass.config_entries.async_reload(entry.entry_id) + return self.async_abort(reason="reauth_successful") + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=DEFAULT_NAME, + data={CONF_API_KEY: api_key}, + ) + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_KEY): str, + } + ), + errors=errors, + ) + + async def async_step_import(self, import_config: ConfigType): + """Import a config entry from configuration.yaml.""" + for entry in self._async_current_entries(): + if entry.data[CONF_API_KEY] == import_config[CONF_APPTOKEN]: + _part = import_config[CONF_APPTOKEN][0:4] + _msg = f"Efergy yaml config with partial key {_part} has been imported. Please remove it" + _LOGGER.warning(_msg) + return self.async_abort(reason="already_configured") + return await self.async_step_user({CONF_API_KEY: import_config[CONF_APPTOKEN]}) + + async def async_step_reauth(self, config: dict[str, Any]) -> FlowResult: + """Handle a reauthorization flow request.""" + return await self.async_step_user() + + async def _async_try_connect(self, api_key: str) -> tuple[str | None, str | None]: + """Try connecting to Efergy servers.""" + api = Efergy(api_key, session=async_get_clientsession(self.hass)) + try: + await api.async_status() + except exceptions.ConnectError: + return None, "cannot_connect" + except exceptions.InvalidAuth: + return None, "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + return None, "unknown" + return api.info["hid"], None diff --git a/homeassistant/components/efergy/const.py b/homeassistant/components/efergy/const.py new file mode 100644 index 00000000000..b141c3ebdb8 --- /dev/null +++ b/homeassistant/components/efergy/const.py @@ -0,0 +1,13 @@ +"""Constants for the Efergy integration.""" +from datetime import timedelta + +ATTRIBUTION = "Data provided by Efergy" + +CONF_APPTOKEN = "app_token" +CONF_CURRENT_VALUES = "current_values" + +DATA_KEY_API = "api" +DEFAULT_NAME = "Efergy" +DOMAIN = "efergy" + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) diff --git a/homeassistant/components/efergy/manifest.json b/homeassistant/components/efergy/manifest.json index 3b84d243d46..d95c0b69415 100644 --- a/homeassistant/components/efergy/manifest.json +++ b/homeassistant/components/efergy/manifest.json @@ -1,8 +1,9 @@ { "domain": "efergy", "name": "Efergy", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/efergy", - "requirements": ["pyefergy==0.0.3"], + "requirements": ["pyefergy==0.1.2"], "codeowners": ["@tkdrob"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/efergy/sensor.py b/homeassistant/components/efergy/sensor.py index a11fe5f3ac6..6b5c5ec44ff 100644 --- a/homeassistant/components/efergy/sensor.py +++ b/homeassistant/components/efergy/sensor.py @@ -2,15 +2,18 @@ from __future__ import annotations import logging +from re import sub from pyefergy import Efergy, exceptions import voluptuous as vol +from homeassistant.components.efergy import EfergyEntity from homeassistant.components.sensor import ( PLATFORM_SCHEMA, SensorEntity, SensorEntityDescription, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( CONF_CURRENCY, CONF_MONITORED_VARIABLES, @@ -22,72 +25,103 @@ from homeassistant.const import ( POWER_WATT, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import entity_platform import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -CONF_APPTOKEN = "app_token" -CONF_UTC_OFFSET = "utc_offset" - -CONF_PERIOD = "period" - -CONF_INSTANT = "instant_readings" -CONF_AMOUNT = "amount" -CONF_BUDGET = "budget" -CONF_COST = "cost" -CONF_CURRENT_VALUES = "current_values" - -DEFAULT_PERIOD = "year" -DEFAULT_UTC_OFFSET = "0" +from .const import CONF_APPTOKEN, CONF_CURRENT_VALUES, DATA_KEY_API, DOMAIN _LOGGER = logging.getLogger(__name__) -SENSOR_TYPES: dict[str, SensorEntityDescription] = { - CONF_INSTANT: SensorEntityDescription( - key=CONF_INSTANT, - name="Energy Usage", +SENSOR_TYPES: tuple[SensorEntityDescription, ...] = ( + SensorEntityDescription( + key="instant_readings", + name="Power Usage", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), - CONF_AMOUNT: SensorEntityDescription( - key=CONF_AMOUNT, - name="Energy Consumed", + SensorEntityDescription( + key="energy_day", + name="Daily Consumption", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_week", + name="Weekly Consumption", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="energy_month", + name="Monthly Consumption", device_class=DEVICE_CLASS_ENERGY, native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, ), - CONF_BUDGET: SensorEntityDescription( - key=CONF_BUDGET, - name="Energy Budget", + SensorEntityDescription( + key="energy_year", + name="Yearly Consumption", + device_class=DEVICE_CLASS_ENERGY, + native_unit_of_measurement=ENERGY_KILO_WATT_HOUR, + entity_registry_enabled_default=False, ), - CONF_COST: SensorEntityDescription( - key=CONF_COST, - name="Energy Cost", + SensorEntityDescription( + key="budget", + name="Energy Budget", + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="cost_day", + name="Daily Energy Cost", + device_class=DEVICE_CLASS_MONETARY, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="cost_week", + name="Weekly Energy Cost", + device_class=DEVICE_CLASS_MONETARY, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( + key="cost_month", + name="Monthly Energy Cost", device_class=DEVICE_CLASS_MONETARY, ), - CONF_CURRENT_VALUES: SensorEntityDescription( + SensorEntityDescription( + key="cost_year", + name="Yearly Energy Cost", + device_class=DEVICE_CLASS_MONETARY, + entity_registry_enabled_default=False, + ), + SensorEntityDescription( key=CONF_CURRENT_VALUES, - name="Per-Device Usage", + name="Power Usage", device_class=DEVICE_CLASS_POWER, native_unit_of_measurement=POWER_WATT, ), -} +) + +TYPES_SCHEMA = vol.In( + ["current_values", "instant_readings", "amount", "budget", "cost"] +) -TYPES_SCHEMA = vol.In(SENSOR_TYPES) SENSORS_SCHEMA = vol.Schema( { vol.Required(CONF_TYPE): TYPES_SCHEMA, vol.Optional(CONF_CURRENCY, default=""): cv.string, - vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.string, + vol.Optional("period", default="year"): cv.string, } ) +# Deprecated in Home Assistant 2021.11 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_APPTOKEN): cv.string, - vol.Optional(CONF_UTC_OFFSET, default=DEFAULT_UTC_OFFSET): cv.string, + vol.Optional("utc_offset", default="0"): cv.string, vol.Required(CONF_MONITORED_VARIABLES): [SENSORS_SCHEMA], } ) @@ -99,62 +133,69 @@ async def async_setup_platform( add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType = None, ) -> None: - """Set up the Efergy sensor.""" - api = Efergy( - config[CONF_APPTOKEN], - async_get_clientsession(hass), - utc_offset=config[CONF_UTC_OFFSET], + """Set up the Efergy sensor from yaml.""" + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) ) - dev = [] - try: - sensors = await api.get_sids() - except (exceptions.DataError, exceptions.ConnectTimeout) as ex: - raise PlatformNotReady("Error getting data from Efergy:") from ex - for variable in config[CONF_MONITORED_VARIABLES]: - if variable[CONF_TYPE] == CONF_CURRENT_VALUES: - for sensor in sensors: - dev.append( + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: entity_platform.AddEntitiesCallback, +) -> None: + """Set up Efergy sensors.""" + api: Efergy = hass.data[DOMAIN][entry.entry_id][DATA_KEY_API] + sensors = [] + for description in SENSOR_TYPES: + if description.key != CONF_CURRENT_VALUES: + sensors.append( + EfergySensor( + api, + description, + entry.entry_id, + period=sub("^energy_|^cost_", "", description.key), + currency=hass.config.currency, + ) + ) + else: + description.entity_registry_enabled_default = len(api.info["sids"]) > 1 + for sid in api.info["sids"]: + sensors.append( EfergySensor( api, - variable[CONF_PERIOD], - variable[CONF_CURRENCY], - SENSOR_TYPES[variable[CONF_TYPE]], - sid=sensor["sid"], + description, + entry.entry_id, + sid=sid, ) ) - dev.append( - EfergySensor( - api, - variable[CONF_PERIOD], - variable[CONF_CURRENCY], - SENSOR_TYPES[variable[CONF_TYPE]], - ) - ) - - add_entities(dev, True) + async_add_entities(sensors, True) -class EfergySensor(SensorEntity): +class EfergySensor(EfergyEntity, SensorEntity): """Implementation of an Efergy sensor.""" def __init__( self, api: Efergy, - period: str, - currency: str, description: SensorEntityDescription, - sid: str = None, + server_unique_id: str, + period: str = None, + currency: str = None, + sid: str = "", ) -> None: """Initialize the sensor.""" + super().__init__(api, server_unique_id) self.entity_description = description + if description.key == CONF_CURRENT_VALUES: + self._attr_name = f"{description.name}_{sid}" + self._attr_unique_id = f"{server_unique_id}/{description.key}_{sid}" + if "cost" in description.key: + self._attr_native_unit_of_measurement = currency self.sid = sid - self.api = api self.period = period - if sid: - self._attr_name = f"efergy_{sid}" - if description.key == CONF_COST: - self._attr_native_unit_of_measurement = f"{currency}/{period}" async def async_update(self) -> None: """Get the Efergy monitor data from the web service.""" @@ -162,11 +203,11 @@ class EfergySensor(SensorEntity): self._attr_native_value = await self.api.async_get_reading( self.entity_description.key, period=self.period, sid=self.sid ) - except (exceptions.DataError, exceptions.ConnectTimeout) as ex: + except (exceptions.DataError, exceptions.ConnectError) as ex: if self._attr_available: self._attr_available = False - _LOGGER.error("Error getting data from Efergy: %s", ex) + _LOGGER.error("Error getting data: %s", ex) return if not self._attr_available: self._attr_available = True - _LOGGER.info("Connection to Efergy has resumed") + _LOGGER.info("Connection has resumed") diff --git a/homeassistant/components/efergy/strings.json b/homeassistant/components/efergy/strings.json new file mode 100644 index 00000000000..dc625c92840 --- /dev/null +++ b/homeassistant/components/efergy/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "Efergy", + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + } + } +} diff --git a/homeassistant/components/efergy/translations/en.json b/homeassistant/components/efergy/translations/en.json new file mode 100644 index 00000000000..aa76f9c0636 --- /dev/null +++ b/homeassistant/components/efergy/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured", + "reauth_successful": "Re-authentication was successful" + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "api_key": "API Key" + }, + "title": "Efergy" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 30617373dbf..ed698fde8dc 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -70,6 +70,7 @@ FLOWS = [ "eafm", "ecobee", "econet", + "efergy", "elgato", "elkm1", "emonitor", diff --git a/requirements_all.txt b/requirements_all.txt index e790f7e4e09..4ecbd61c0dc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1447,7 +1447,7 @@ pyeconet==0.1.14 pyedimax==0.2.1 # homeassistant.components.efergy -pyefergy==0.0.3 +pyefergy==0.1.2 # homeassistant.components.eight_sleep pyeight==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6c608b3e135..8f9f6992d9f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -844,7 +844,7 @@ pydispatcher==2.0.5 pyeconet==0.1.14 # homeassistant.components.efergy -pyefergy==0.0.3 +pyefergy==0.1.2 # homeassistant.components.everlights pyeverlights==0.1.0 diff --git a/tests/components/efergy/__init__.py b/tests/components/efergy/__init__.py index 242d36fb932..c4f099df822 100644 --- a/tests/components/efergy/__init__.py +++ b/tests/components/efergy/__init__.py @@ -1 +1,157 @@ -"""Tests for the efergy component.""" +"""Tests for Efergy integration.""" +from unittest.mock import AsyncMock, patch + +from pyefergy import Efergy, exceptions + +from homeassistant.components.efergy import DOMAIN +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture +from tests.test_util.aiohttp import AiohttpClientMocker + +TOKEN = "9p6QGJ7dpZfO3fqPTBk1fyEmjV1cGoLT" +MULTI_SENSOR_TOKEN = "9r6QGF7dpZfO3fqPTBl1fyRmjV1cGoLT" + +CONF_DATA = {CONF_API_KEY: TOKEN} +HID = "12345678901234567890123456789012" +IMPORT_DATA = {"platform": "efergy", "app_token": TOKEN} + +BASE_URL = "https://engage.efergy.com/mobile_proxy/" + + +def create_entry(hass: HomeAssistant, token: str = TOKEN) -> MockConfigEntry: + """Create Efergy entry in Home Assistant.""" + entry = MockConfigEntry( + domain=DOMAIN, + unique_id=HID, + data={CONF_API_KEY: token}, + ) + entry.add_to_hass(hass) + return entry + + +async def init_integration( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + token: str = TOKEN, + error: bool = False, +) -> MockConfigEntry: + """Set up the Efergy integration in Home Assistant.""" + entry = create_entry(hass, token=token) + await mock_responses(hass, aioclient_mock, token=token, error=error) + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + return entry + + +async def mock_responses( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + token: str = TOKEN, + error: bool = False, +): + """Mock responses from Efergy.""" + base_url = "https://engage.efergy.com/mobile_proxy/" + api = Efergy( + token, session=async_get_clientsession(hass), utc_offset=hass.config.time_zone + ) + offset = api._utc_offset # pylint: disable=protected-access + if error: + aioclient_mock.get( + f"{base_url}getInstant?token={token}", + exc=exceptions.ConnectError, + ) + return + aioclient_mock.get( + f"{base_url}getStatus?token={token}", + text=load_fixture("efergy/status.json"), + ) + aioclient_mock.get( + f"{base_url}getInstant?token={token}", + text=load_fixture("efergy/instant.json"), + ) + aioclient_mock.get( + f"{base_url}getEnergy?token={token}&offset={offset}&period=day", + text=load_fixture("efergy/daily_energy.json"), + ) + aioclient_mock.get( + f"{base_url}getEnergy?token={token}&offset={offset}&period=week", + text=load_fixture("efergy/weekly_energy.json"), + ) + aioclient_mock.get( + f"{base_url}getEnergy?token={token}&offset={offset}&period=month", + text=load_fixture("efergy/monthly_energy.json"), + ) + aioclient_mock.get( + f"{base_url}getEnergy?token={token}&offset={offset}&period=year", + text=load_fixture("efergy/yearly_energy.json"), + ) + aioclient_mock.get( + f"{base_url}getBudget?token={token}", + text=load_fixture("efergy/budget.json"), + ) + aioclient_mock.get( + f"{base_url}getCost?token={token}&offset={offset}&period=day", + text=load_fixture("efergy/daily_cost.json"), + ) + aioclient_mock.get( + f"{base_url}getCost?token={token}&offset={offset}&period=week", + text=load_fixture("efergy/weekly_cost.json"), + ) + aioclient_mock.get( + f"{base_url}getCost?token={token}&offset={offset}&period=month", + text=load_fixture("efergy/monthly_cost.json"), + ) + aioclient_mock.get( + f"{base_url}getCost?token={token}&offset={offset}&period=year", + text=load_fixture("efergy/yearly_cost.json"), + ) + if token == TOKEN: + aioclient_mock.get( + f"{base_url}getCurrentValuesSummary?token={token}", + text=load_fixture("efergy/current_values_single.json"), + ) + else: + aioclient_mock.get( + f"{base_url}getCurrentValuesSummary?token={token}", + text=load_fixture("efergy/current_values_multi.json"), + ) + + +def _patch_efergy(): + mocked_efergy = AsyncMock() + mocked_efergy.info = {} + mocked_efergy.info["hid"] = HID + mocked_efergy.info["mac"] = "AA:BB:CC:DD:EE:FF" + mocked_efergy.info["status"] = "on" + mocked_efergy.info["type"] = "EEEHub" + mocked_efergy.info["version"] = "2.3.7" + return patch( + "homeassistant.components.efergy.config_flow.Efergy", + return_value=mocked_efergy, + ) + + +def _patch_efergy_status(): + return patch("homeassistant.components.efergy.config_flow.Efergy.async_status") + + +async def setup_platform( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + platform: str, + token: str = TOKEN, + error: bool = False, +): + """Set up the platform.""" + entry = await init_integration(hass, aioclient_mock, token=token, error=error) + + with patch("homeassistant.components.efergy.PLATFORMS", [platform]): + assert await async_setup_component(hass, DOMAIN, {}) + + return entry diff --git a/tests/components/efergy/test_config_flow.py b/tests/components/efergy/test_config_flow.py new file mode 100644 index 00000000000..d49b89984e1 --- /dev/null +++ b/tests/components/efergy/test_config_flow.py @@ -0,0 +1,134 @@ +"""Test Efergy config flow.""" +from unittest.mock import patch + +from pyefergy import exceptions + +from homeassistant.components.efergy.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_SOURCE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from . import ( + CONF_DATA, + HID, + IMPORT_DATA, + _patch_efergy, + _patch_efergy_status, + create_entry, +) + + +def _patch_setup(): + return patch("homeassistant.components.efergy.async_setup_entry") + + +async def test_flow_user(hass: HomeAssistant): + """Test user initialized flow.""" + with _patch_efergy(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={CONF_SOURCE: SOURCE_USER}, + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=CONF_DATA, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + assert result["result"].unique_id == HID + + +async def test_flow_user_cannot_connect(hass: HomeAssistant): + """Test user initialized flow with unreachable service.""" + with _patch_efergy_status() as efergymock: + efergymock.side_effect = exceptions.ConnectError + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "cannot_connect" + + +async def test_flow_user_invalid_auth(hass: HomeAssistant): + """Test user initialized flow with invalid authentication.""" + with _patch_efergy_status() as efergymock: + efergymock.side_effect = exceptions.InvalidAuth + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "invalid_auth" + + +async def test_flow_user_unknown(hass: HomeAssistant): + """Test user initialized flow with unknown error.""" + with _patch_efergy_status() as efergymock: + efergymock.side_effect = Exception + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_USER}, data=CONF_DATA + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"]["base"] == "unknown" + + +async def test_flow_import(hass: HomeAssistant): + """Test import step.""" + with _patch_efergy(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == DEFAULT_NAME + assert result["data"] == CONF_DATA + assert result["result"].unique_id == HID + + +async def test_flow_import_already_configured(hass: HomeAssistant): + """Test import step already configured.""" + create_entry(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={CONF_SOURCE: SOURCE_IMPORT}, data=IMPORT_DATA + ) + + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_flow_reauth(hass: HomeAssistant): + """Test reauth step.""" + entry = create_entry(hass) + with _patch_efergy(), _patch_setup(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + CONF_SOURCE: SOURCE_REAUTH, + "entry_id": entry.entry_id, + "unique_id": entry.unique_id, + }, + data=CONF_DATA, + ) + + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "user" + + new_conf = {CONF_API_KEY: "1234567890"} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=new_conf, + ) + assert result["type"] == RESULT_TYPE_ABORT + assert result["reason"] == "reauth_successful" + assert entry.data == new_conf diff --git a/tests/components/efergy/test_init.py b/tests/components/efergy/test_init.py new file mode 100644 index 00000000000..f32551a4e9b --- /dev/null +++ b/tests/components/efergy/test_init.py @@ -0,0 +1,61 @@ +"""Test Efergy integration.""" +from pyefergy import exceptions + +from homeassistant.components.efergy.const import DEFAULT_NAME, DOMAIN +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr + +from . import _patch_efergy_status, create_entry, init_integration, setup_platform + +from tests.test_util.aiohttp import AiohttpClientMocker + + +async def test_setup(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test unload.""" + entry = await init_integration(hass, aioclient_mock) + assert entry.state == ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() + + assert entry.state is ConfigEntryState.NOT_LOADED + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_not_ready(hass: HomeAssistant): + """Test that it throws ConfigEntryNotReady when exception occurs during setup.""" + entry = create_entry(hass) + with _patch_efergy_status() as efergymock: + efergymock.side_effect = (exceptions.ConnectError, exceptions.DataError) + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_RETRY + assert not hass.data.get(DOMAIN) + + +async def test_async_setup_entry_auth_failed(hass: HomeAssistant): + """Test that it throws ConfigEntryAuthFailed when authentication fails.""" + entry = create_entry(hass) + with _patch_efergy_status() as efergymock: + efergymock.side_effect = exceptions.InvalidAuth + await hass.config_entries.async_setup(entry.entry_id) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert entry.state == ConfigEntryState.SETUP_ERROR + assert not hass.data.get(DOMAIN) + + +async def test_device_info(hass: HomeAssistant, aioclient_mock: AiohttpClientMocker): + """Test device info.""" + entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) + device_registry = await dr.async_get_registry(hass) + + device = device_registry.async_get_device({(DOMAIN, entry.entry_id)}) + + assert device.connections == {("mac", "ff:ff:ff:ff:ff:ff")} + assert device.identifiers == {(DOMAIN, entry.entry_id)} + assert device.manufacturer == DEFAULT_NAME + assert device.model == "EEEHub" + assert device.name == DEFAULT_NAME + assert device.sw_version == "2.3.7" diff --git a/tests/components/efergy/test_sensor.py b/tests/components/efergy/test_sensor.py index 94a381b9048..940d178e972 100644 --- a/tests/components/efergy/test_sensor.py +++ b/tests/components/efergy/test_sensor.py @@ -1,135 +1,125 @@ """The tests for Efergy sensor platform.""" - -import asyncio from datetime import timedelta +from homeassistant.components.efergy.sensor import SENSOR_TYPES from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.const import STATE_UNAVAILABLE +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + ATTR_UNIT_OF_MEASUREMENT, + DEVICE_CLASS_ENERGY, + DEVICE_CLASS_MONETARY, + DEVICE_CLASS_POWER, + ENERGY_KILO_WATT_HOUR, + POWER_WATT, + STATE_UNAVAILABLE, +) from homeassistant.core import HomeAssistant -from homeassistant.setup import async_setup_component +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_registry import EntityRegistry import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed, load_fixture +from . import MULTI_SENSOR_TOKEN, mock_responses, setup_platform + +from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker -token = "9p6QGJ7dpZfO3fqPTBk1fyEmjV1cGoLT" -multi_sensor_token = "9r6QGF7dpZfO3fqPTBl1fyRmjV1cGoLT" -ONE_SENSOR_CONFIG = { - "platform": "efergy", - "app_token": token, - "utc_offset": "300", - "monitored_variables": [ - {"type": "amount", "period": "day"}, - {"type": "instant_readings"}, - {"type": "budget"}, - {"type": "cost", "period": "day", "currency": "$"}, - {"type": "current_values"}, - ], -} - -MULTI_SENSOR_CONFIG = { - "platform": "efergy", - "app_token": multi_sensor_token, - "utc_offset": "300", - "monitored_variables": [{"type": "current_values"}], -} - - -def mock_responses(aioclient_mock: AiohttpClientMocker, error: bool = False): - """Mock responses for Efergy.""" - base_url = "https://engage.efergy.com/mobile_proxy/" - if error: - aioclient_mock.get( - f"{base_url}getCurrentValuesSummary?token={token}", exc=asyncio.TimeoutError - ) - return - aioclient_mock.get( - f"{base_url}getInstant?token={token}", - text=load_fixture("efergy/efergy_instant.json"), - ) - aioclient_mock.get( - f"{base_url}getEnergy?token={token}&offset=300&period=day", - text=load_fixture("efergy/efergy_energy.json"), - ) - aioclient_mock.get( - f"{base_url}getBudget?token={token}", - text=load_fixture("efergy/efergy_budget.json"), - ) - aioclient_mock.get( - f"{base_url}getCost?token={token}&offset=300&period=day", - text=load_fixture("efergy/efergy_cost.json"), - ) - aioclient_mock.get( - f"{base_url}getCurrentValuesSummary?token={token}", - text=load_fixture("efergy/efergy_current_values_single.json"), - ) - aioclient_mock.get( - f"{base_url}getCurrentValuesSummary?token={multi_sensor_token}", - text=load_fixture("efergy/efergy_current_values_multi.json"), - ) - - -async def test_single_sensor_readings( +async def test_sensor_readings( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ): """Test for successfully setting up the Efergy platform.""" - mock_responses(aioclient_mock) - assert await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: ONE_SENSOR_CONFIG} - ) - await hass.async_block_till_done() + for description in SENSOR_TYPES: + description.entity_registry_enabled_default = True + entry = await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) + ent_reg: EntityRegistry = er.async_get(hass) - assert hass.states.get("sensor.energy_consumed").state == "38.21" - assert hass.states.get("sensor.energy_usage").state == "1580" - assert hass.states.get("sensor.energy_budget").state == "ok" - assert hass.states.get("sensor.energy_cost").state == "5.27" - assert hass.states.get("sensor.efergy_728386").state == "1628" + state = hass.states.get("sensor.power_usage") + assert state.state == "1580" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + state = hass.states.get("sensor.energy_budget") + assert state.state == "ok" + assert state.attributes.get(ATTR_DEVICE_CLASS) is None + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) is None + state = hass.states.get("sensor.daily_consumption") + assert state.state == "38.21" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + state = hass.states.get("sensor.weekly_consumption") + assert state.state == "267.47" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + state = hass.states.get("sensor.monthly_consumption") + assert state.state == "1069.88" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + state = hass.states.get("sensor.yearly_consumption") + assert state.state == "13373.50" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_ENERGY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == ENERGY_KILO_WATT_HOUR + state = hass.states.get("sensor.daily_energy_cost") + assert state.state == "5.27" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" + state = hass.states.get("sensor.weekly_energy_cost") + assert state.state == "36.89" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" + state = hass.states.get("sensor.monthly_energy_cost") + assert state.state == "147.56" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" + state = hass.states.get("sensor.yearly_energy_cost") + assert state.state == "1844.50" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_MONETARY + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == "EUR" + entity = ent_reg.async_get("sensor.power_usage_728386") + assert entity.disabled_by == er.DISABLED_INTEGRATION + ent_reg.async_update_entity(entity.entity_id, **{"disabled_by": None}) + await hass.config_entries.async_reload(entry.entry_id) + await hass.async_block_till_done() + state = hass.states.get("sensor.power_usage_728386") + assert state.state == "1628" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT async def test_multi_sensor_readings( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ): """Test for multiple sensors in one household.""" - mock_responses(aioclient_mock) - assert await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: MULTI_SENSOR_CONFIG} - ) - await hass.async_block_till_done() - - assert hass.states.get("sensor.efergy_728386").state == "218" - assert hass.states.get("sensor.efergy_0").state == "1808" - assert hass.states.get("sensor.efergy_728387").state == "312" - - -async def test_failed_getting_sids( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -): - """Test failed gettings sids.""" - mock_responses(aioclient_mock, error=True) - assert await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: ONE_SENSOR_CONFIG} - ) - assert not hass.states.async_all("sensor") + for description in SENSOR_TYPES: + description.entity_registry_enabled_default = True + await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN, MULTI_SENSOR_TOKEN) + state = hass.states.get("sensor.power_usage_728386") + assert state.state == "218" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + state = hass.states.get("sensor.power_usage_0") + assert state.state == "1808" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT + state = hass.states.get("sensor.power_usage_728387") + assert state.state == "312" + assert state.attributes.get(ATTR_DEVICE_CLASS) == DEVICE_CLASS_POWER + assert state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) == POWER_WATT async def test_failed_update_and_reconnection( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker ): """Test failed update and reconnection.""" - mock_responses(aioclient_mock) - assert await async_setup_component( - hass, SENSOR_DOMAIN, {SENSOR_DOMAIN: ONE_SENSOR_CONFIG} - ) + await setup_platform(hass, aioclient_mock, SENSOR_DOMAIN) + assert hass.states.get("sensor.power_usage").state == "1580" aioclient_mock.clear_requests() - mock_responses(aioclient_mock, error=True) - next_update = dt_util.utcnow() + timedelta(seconds=3) - async_fire_time_changed(hass, next_update) - await hass.async_block_till_done() - assert hass.states.get("sensor.efergy_728386").state == STATE_UNAVAILABLE - aioclient_mock.clear_requests() - mock_responses(aioclient_mock) + await mock_responses(hass, aioclient_mock, error=True) next_update = dt_util.utcnow() + timedelta(seconds=30) async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - assert hass.states.get("sensor.efergy_728386").state == "1628" + assert hass.states.get("sensor.power_usage").state == STATE_UNAVAILABLE + aioclient_mock.clear_requests() + await mock_responses(hass, aioclient_mock) + next_update = dt_util.utcnow() + timedelta(seconds=30) + async_fire_time_changed(hass, next_update) + await hass.async_block_till_done() + assert hass.states.get("sensor.power_usage").state == "1580" diff --git a/tests/fixtures/efergy/efergy_budget.json b/tests/fixtures/efergy/budget.json similarity index 100% rename from tests/fixtures/efergy/efergy_budget.json rename to tests/fixtures/efergy/budget.json diff --git a/tests/fixtures/efergy/efergy_current_values_multi.json b/tests/fixtures/efergy/current_values_multi.json similarity index 100% rename from tests/fixtures/efergy/efergy_current_values_multi.json rename to tests/fixtures/efergy/current_values_multi.json diff --git a/tests/fixtures/efergy/efergy_current_values_single.json b/tests/fixtures/efergy/current_values_single.json similarity index 100% rename from tests/fixtures/efergy/efergy_current_values_single.json rename to tests/fixtures/efergy/current_values_single.json diff --git a/tests/fixtures/efergy/efergy_cost.json b/tests/fixtures/efergy/daily_cost.json similarity index 100% rename from tests/fixtures/efergy/efergy_cost.json rename to tests/fixtures/efergy/daily_cost.json diff --git a/tests/fixtures/efergy/efergy_energy.json b/tests/fixtures/efergy/daily_energy.json similarity index 100% rename from tests/fixtures/efergy/efergy_energy.json rename to tests/fixtures/efergy/daily_energy.json diff --git a/tests/fixtures/efergy/efergy_instant.json b/tests/fixtures/efergy/instant.json similarity index 100% rename from tests/fixtures/efergy/efergy_instant.json rename to tests/fixtures/efergy/instant.json diff --git a/tests/fixtures/efergy/monthly_cost.json b/tests/fixtures/efergy/monthly_cost.json new file mode 100644 index 00000000000..a3b499cd181 --- /dev/null +++ b/tests/fixtures/efergy/monthly_cost.json @@ -0,0 +1,5 @@ +{ + "sum": "147.56", + "duration": 2537340, + "units": "GBP" +} \ No newline at end of file diff --git a/tests/fixtures/efergy/monthly_energy.json b/tests/fixtures/efergy/monthly_energy.json new file mode 100644 index 00000000000..ab4603e8959 --- /dev/null +++ b/tests/fixtures/efergy/monthly_energy.json @@ -0,0 +1,5 @@ +{ + "sum": "1069.88", + "duration": 2537340, + "units": "kWh" +} \ No newline at end of file diff --git a/tests/fixtures/efergy/status.json b/tests/fixtures/efergy/status.json new file mode 100644 index 00000000000..2e38374831a --- /dev/null +++ b/tests/fixtures/efergy/status.json @@ -0,0 +1,31 @@ +{ + "hid":"1234567890abcdef1234567890abcdef", + "listOfMacs":[ + { + "listofchannels":[ + { + "assoc":1, + "cid":"cid.ffffffffffff", + "reading":null, + "ts":1632961265, + "tsDelta":1, + "tsHuman":"Thu Sep 30 00:00:00 2021", + "type":{ + "battery":5, + "falseBattery":0, + "id":null, + "name":"EFCT" + } + } + ], + "mac":"ffffffffffff", + "personality":"E1", + "status":"on", + "ts":1632961265, + "tsDelta":1, + "tsHuman":"Thu Sep 30 00:00:00 2021", + "type":"EEEHub", + "version":"2.3.7" + } + ] +} \ No newline at end of file diff --git a/tests/fixtures/efergy/weekly_cost.json b/tests/fixtures/efergy/weekly_cost.json new file mode 100644 index 00000000000..f5267f70d2d --- /dev/null +++ b/tests/fixtures/efergy/weekly_cost.json @@ -0,0 +1,5 @@ +{ + "sum": "36.89", + "duration": 377280, + "units": "GBP" +} \ No newline at end of file diff --git a/tests/fixtures/efergy/weekly_energy.json b/tests/fixtures/efergy/weekly_energy.json new file mode 100644 index 00000000000..f4ae92c0af2 --- /dev/null +++ b/tests/fixtures/efergy/weekly_energy.json @@ -0,0 +1,5 @@ +{ + "sum": "267.47", + "duration": 377280, + "units": "kWh" +} \ No newline at end of file diff --git a/tests/fixtures/efergy/yearly_cost.json b/tests/fixtures/efergy/yearly_cost.json new file mode 100644 index 00000000000..375dbde542e --- /dev/null +++ b/tests/fixtures/efergy/yearly_cost.json @@ -0,0 +1,5 @@ +{ + "sum": "1844.50", + "duration": 23532540, + "units": "GBP" +} \ No newline at end of file diff --git a/tests/fixtures/efergy/yearly_energy.json b/tests/fixtures/efergy/yearly_energy.json new file mode 100644 index 00000000000..b6026d8ea2d --- /dev/null +++ b/tests/fixtures/efergy/yearly_energy.json @@ -0,0 +1,5 @@ +{ + "sum": "13373.50", + "duration": 23532540, + "units": "kWh" +} \ No newline at end of file