From caca76208880834164f3ff4d8755ddd961ad88c3 Mon Sep 17 00:00:00 2001 From: Aidan Timson Date: Wed, 5 Aug 2020 13:38:29 +0100 Subject: [PATCH] OVO Energy Integration (#36104) Co-authored-by: Franck Nijhof --- .coveragerc | 3 + CODEOWNERS | 1 + .../components/ovo_energy/__init__.py | 148 +++++++++++++ .../components/ovo_energy/config_flow.py | 66 ++++++ homeassistant/components/ovo_energy/const.py | 7 + .../components/ovo_energy/manifest.json | 9 + homeassistant/components/ovo_energy/sensor.py | 207 ++++++++++++++++++ .../components/ovo_energy/strings.json | 18 ++ .../ovo_energy/translations/en.json | 18 ++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/ovo_energy/__init__.py | 1 + .../components/ovo_energy/test_config_flow.py | 87 ++++++++ 14 files changed, 572 insertions(+) create mode 100644 homeassistant/components/ovo_energy/__init__.py create mode 100644 homeassistant/components/ovo_energy/config_flow.py create mode 100644 homeassistant/components/ovo_energy/const.py create mode 100644 homeassistant/components/ovo_energy/manifest.json create mode 100644 homeassistant/components/ovo_energy/sensor.py create mode 100644 homeassistant/components/ovo_energy/strings.json create mode 100644 homeassistant/components/ovo_energy/translations/en.json create mode 100644 tests/components/ovo_energy/__init__.py create mode 100644 tests/components/ovo_energy/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 0b72c81a5dd..bcd3b80b812 100644 --- a/.coveragerc +++ b/.coveragerc @@ -624,6 +624,9 @@ omit = homeassistant/components/orvibo/switch.py homeassistant/components/osramlightify/light.py homeassistant/components/otp/sensor.py + homeassistant/components/ovo_energy/__init__.py + homeassistant/components/ovo_energy/const.py + homeassistant/components/ovo_energy/sensor.py homeassistant/components/panasonic_bluray/media_player.py homeassistant/components/panasonic_viera/media_player.py homeassistant/components/pandora/media_player.py diff --git a/CODEOWNERS b/CODEOWNERS index 1d4d38fa4e1..3c4ac8dd0dd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -302,6 +302,7 @@ homeassistant/components/openweathermap/* @fabaff homeassistant/components/opnsense/* @mtreinish homeassistant/components/orangepi_gpio/* @pascallj homeassistant/components/oru/* @bvlaicu +homeassistant/components/ovo_energy/* @timmo001 homeassistant/components/ozw/* @cgarwood @marcelveldt @MartinHjelmare homeassistant/components/panasonic_viera/* @joogps homeassistant/components/panel_custom/* @home-assistant/frontend diff --git a/homeassistant/components/ovo_energy/__init__.py b/homeassistant/components/ovo_energy/__init__.py new file mode 100644 index 00000000000..3aff51fa044 --- /dev/null +++ b/homeassistant/components/ovo_energy/__init__.py @@ -0,0 +1,148 @@ +"""Support for OVO Energy.""" +from datetime import datetime, timedelta +import logging +from typing import Any, Dict + +import aiohttp +import async_timeout +from ovoenergy import OVODailyUsage +from ovoenergy.ovoenergy import OVOEnergy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.typing import ConfigType, HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: + """Set up the OVO Energy components.""" + return True + + +async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry) -> bool: + """Set up OVO Energy from a config entry.""" + + client = OVOEnergy() + + try: + await client.authenticate(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD]) + except aiohttp.ClientError as exception: + _LOGGER.warning(exception) + raise ConfigEntryNotReady from exception + + async def async_update_data() -> OVODailyUsage: + """Fetch data from OVO Energy.""" + now = datetime.utcnow() + async with async_timeout.timeout(10): + return await client.get_daily_usage(now.strftime("%Y-%m")) + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name="sensor", + update_method=async_update_data, + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta(seconds=300), + ) + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + DATA_CLIENT: client, + DATA_COORDINATOR: coordinator, + } + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + # Setup components + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistantType, entry: ConfigType) -> bool: + """Unload OVO Energy config entry.""" + # Unload sensors + await hass.config_entries.async_forward_entry_unload(entry, "sensor") + + del hass.data[DOMAIN][entry.entry_id] + + return True + + +class OVOEnergyEntity(Entity): + """Defines a base OVO Energy entity.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + client: OVOEnergy, + key: str, + name: str, + icon: str, + ) -> None: + """Initialize the OVO Energy entity.""" + self._coordinator = coordinator + self._client = client + self._key = key + self._name = name + self._icon = icon + self._available = True + + @property + def unique_id(self) -> str: + """Return the unique ID for this sensor.""" + return self._key + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name + + @property + def icon(self) -> str: + """Return the mdi icon of the entity.""" + return self._icon + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self._coordinator.last_update_success and self._available + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + async def async_update(self) -> None: + """Update OVO Energy entity.""" + await self._coordinator.async_request_refresh() + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + +class OVOEnergyDeviceEntity(OVOEnergyEntity): + """Defines a OVO Energy device entity.""" + + @property + def device_info(self) -> Dict[str, Any]: + """Return device information about this OVO Energy instance.""" + return { + "identifiers": {(DOMAIN, self._client.account_id)}, + "manufacturer": "OVO Energy", + "name": self._client.account_id, + "entry_type": "service", + } diff --git a/homeassistant/components/ovo_energy/config_flow.py b/homeassistant/components/ovo_energy/config_flow.py new file mode 100644 index 00000000000..e4d33865f57 --- /dev/null +++ b/homeassistant/components/ovo_energy/config_flow.py @@ -0,0 +1,66 @@ +"""Config flow to configure the OVO Energy integration.""" +import logging + +import aiohttp +from ovoenergy.ovoenergy import OVOEnergy +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from .const import CONF_ACCOUNT_ID, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +@config_entries.HANDLERS.register(DOMAIN) +class OVOEnergyFlowHandler(ConfigFlow): + """Handle a OVO Energy config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + def __init__(self): + """Initialize OVO Energy flow.""" + + async def _show_setup_form(self, errors=None): + """Show the setup form to the user.""" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str} + ), + errors=errors or {}, + ) + + async def async_step_user(self, user_input=None): + """Handle a flow initiated by the user.""" + if user_input is None: + return await self._show_setup_form() + + errors = {} + + client = OVOEnergy() + + try: + if ( + await client.authenticate( + user_input.get(CONF_USERNAME), user_input.get(CONF_PASSWORD) + ) + is not True + ): + errors["base"] = "authorization_error" + return await self._show_setup_form(errors) + except aiohttp.ClientError: + errors["base"] = "connection_error" + return await self._show_setup_form(errors) + + return self.async_create_entry( + title=client.account_id, + data={ + CONF_USERNAME: user_input.get(CONF_USERNAME), + CONF_PASSWORD: user_input.get(CONF_PASSWORD), + CONF_ACCOUNT_ID: client.account_id, + }, + ) diff --git a/homeassistant/components/ovo_energy/const.py b/homeassistant/components/ovo_energy/const.py new file mode 100644 index 00000000000..e836bb2ca8a --- /dev/null +++ b/homeassistant/components/ovo_energy/const.py @@ -0,0 +1,7 @@ +"""Constants for the OVO Energy integration.""" +DOMAIN = "ovo_energy" + +DATA_CLIENT = "ovo_client" +DATA_COORDINATOR = "coordinator" + +CONF_ACCOUNT_ID = "account_id" diff --git a/homeassistant/components/ovo_energy/manifest.json b/homeassistant/components/ovo_energy/manifest.json new file mode 100644 index 00000000000..27a28863405 --- /dev/null +++ b/homeassistant/components/ovo_energy/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "ovo_energy", + "name": "OVO Energy", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/ovo_energy", + "requirements": ["ovoenergy==1.1.6"], + "dependencies": [], + "codeowners": ["@timmo001"] +} diff --git a/homeassistant/components/ovo_energy/sensor.py b/homeassistant/components/ovo_energy/sensor.py new file mode 100644 index 00000000000..5fe1bb056e7 --- /dev/null +++ b/homeassistant/components/ovo_energy/sensor.py @@ -0,0 +1,207 @@ +"""Support for OVO Energy sensors.""" +from datetime import timedelta +import logging + +from ovoenergy import OVODailyUsage +from ovoenergy.ovoenergy import OVOEnergy + +from homeassistant.config_entries import ConfigEntry +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from . import OVOEnergyDeviceEntity +from .const import DATA_CLIENT, DATA_COORDINATOR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=300) +PARALLEL_UPDATES = 4 + + +async def async_setup_entry( + hass: HomeAssistantType, entry: ConfigEntry, async_add_entities +) -> None: + """Set up OVO Energy sensor based on a config entry.""" + coordinator: DataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + client: OVOEnergy = hass.data[DOMAIN][entry.entry_id][DATA_CLIENT] + + currency = coordinator.data.electricity[ + len(coordinator.data.electricity) - 1 + ].cost.currency_unit + + async_add_entities( + [ + OVOEnergyLastElectricityReading(coordinator, client), + OVOEnergyLastGasReading(coordinator, client), + OVOEnergyLastElectricityCost(coordinator, client, currency), + OVOEnergyLastGasCost(coordinator, client, currency), + ], + True, + ) + + +class OVOEnergySensor(OVOEnergyDeviceEntity): + """Defines a OVO Energy sensor.""" + + def __init__( + self, + coordinator: DataUpdateCoordinator, + client: OVOEnergy, + key: str, + name: str, + icon: str, + unit_of_measurement: str = "", + ) -> None: + """Initialize OVO Energy sensor.""" + self._unit_of_measurement = unit_of_measurement + + super().__init__(coordinator, client, key, name, icon) + + @property + def unit_of_measurement(self) -> str: + """Return the unit this state is expressed in.""" + return self._unit_of_measurement + + +class OVOEnergyLastElectricityReading(OVOEnergySensor): + """Defines a OVO Energy last reading sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy): + """Initialize OVO Energy sensor.""" + + super().__init__( + coordinator, + client, + f"{client.account_id}_last_electricity_reading", + "OVO Last Electricity Reading", + "mdi:flash", + "kWh", + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return usage.electricity[-1].consumption + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return { + "start_time": usage.electricity[-1].interval.start, + "end_time": usage.electricity[-1].interval.end, + } + + +class OVOEnergyLastGasReading(OVOEnergySensor): + """Defines a OVO Energy last reading sensor.""" + + def __init__(self, coordinator: DataUpdateCoordinator, client: OVOEnergy): + """Initialize OVO Energy sensor.""" + + super().__init__( + coordinator, + client, + f"{DOMAIN}_{client.account_id}_last_gas_reading", + "OVO Last Gas Reading", + "mdi:gas-cylinder", + "kWh", + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return usage.gas[-1].consumption + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return { + "start_time": usage.gas[-1].interval.start, + "end_time": usage.gas[-1].interval.end, + } + + +class OVOEnergyLastElectricityCost(OVOEnergySensor): + """Defines a OVO Energy last cost sensor.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str + ): + """Initialize OVO Energy sensor.""" + super().__init__( + coordinator, + client, + f"{DOMAIN}_{client.account_id}_last_electricity_cost", + "OVO Last Electricity Cost", + "mdi:cash-multiple", + currency, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return usage.electricity[-1].cost.amount + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.electricity: + return None + return { + "start_time": usage.electricity[-1].interval.start, + "end_time": usage.electricity[-1].interval.end, + } + + +class OVOEnergyLastGasCost(OVOEnergySensor): + """Defines a OVO Energy last cost sensor.""" + + def __init__( + self, coordinator: DataUpdateCoordinator, client: OVOEnergy, currency: str + ): + """Initialize OVO Energy sensor.""" + super().__init__( + coordinator, + client, + f"{DOMAIN}_{client.account_id}_last_gas_cost", + "OVO Last Gas Cost", + "mdi:cash-multiple", + currency, + ) + + @property + def state(self) -> str: + """Return the state of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return usage.gas[-1].cost.amount + + @property + def device_state_attributes(self) -> object: + """Return the attributes of the sensor.""" + usage: OVODailyUsage = self._coordinator.data + if usage is None or not usage.gas: + return None + return { + "start_time": usage.gas[-1].interval.start, + "end_time": usage.gas[-1].interval.end, + } diff --git a/homeassistant/components/ovo_energy/strings.json b/homeassistant/components/ovo_energy/strings.json new file mode 100644 index 00000000000..a98b0223644 --- /dev/null +++ b/homeassistant/components/ovo_energy/strings.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "Could not connect to OVO Energy." + }, + "step": { + "user": { + "data": { + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy" + } + } + } +} diff --git a/homeassistant/components/ovo_energy/translations/en.json b/homeassistant/components/ovo_energy/translations/en.json new file mode 100644 index 00000000000..afe1bb6e301 --- /dev/null +++ b/homeassistant/components/ovo_energy/translations/en.json @@ -0,0 +1,18 @@ +{ + "config": { + "error": { + "authorization_error": "Authorization error. Check your credentials.", + "connection_error": "Could not connect to OVO Energy." + }, + "step": { + "user": { + "data": { + "username": "Username", + "password": "Password" + }, + "description": "Set up an OVO Energy instance to access your energy usage.", + "title": "Add OVO Energy" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d1f31841a30..7aa9eac6a86 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -122,6 +122,7 @@ FLOWS = [ "onvif", "opentherm_gw", "openuv", + "ovo_energy", "owntracks", "ozw", "panasonic_viera", diff --git a/requirements_all.txt b/requirements_all.txt index e0360afcc10..fb9f631e90a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1028,6 +1028,9 @@ oru==0.1.11 # homeassistant.components.orvibo orvibo==1.1.1 +# homeassistant.components.ovo_energy +ovoenergy==1.1.6 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3c2c8d7803b..0bb523993b6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -472,6 +472,9 @@ onvif-zeep-async==0.4.0 # homeassistant.components.openerz openerz-api==0.1.0 +# homeassistant.components.ovo_energy +ovoenergy==1.1.6 + # homeassistant.components.mqtt # homeassistant.components.shiftr paho-mqtt==1.5.0 diff --git a/tests/components/ovo_energy/__init__.py b/tests/components/ovo_energy/__init__.py new file mode 100644 index 00000000000..ea9402fcb0d --- /dev/null +++ b/tests/components/ovo_energy/__init__.py @@ -0,0 +1 @@ +"""Tests for the OVO Energy integration.""" diff --git a/tests/components/ovo_energy/test_config_flow.py b/tests/components/ovo_energy/test_config_flow.py new file mode 100644 index 00000000000..73b2610cc7a --- /dev/null +++ b/tests/components/ovo_energy/test_config_flow.py @@ -0,0 +1,87 @@ +"""Test the OVO Energy config flow.""" +import aiohttp + +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.ovo_energy.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant + +from tests.async_mock import patch + +FIXTURE_USER_INPUT = {CONF_USERNAME: "example@example.com", CONF_PASSWORD: "something"} + + +async def test_show_form(hass: HomeAssistant) -> None: + """Test that the setup form is served.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_authorization_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + return_value=False, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "authorization_error"} + + +async def test_connection_error(hass: HomeAssistant) -> None: + """Test we show user form on connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + side_effect=aiohttp.ClientError, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result2["step_id"] == "user" + assert result2["errors"] == {"base": "connection_error"} + + +async def test_full_flow_implementation(hass: HomeAssistant) -> None: + """Test registering an integration and finishing flow works.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + with patch( + "homeassistant.components.ovo_energy.config_flow.OVOEnergy.authenticate", + return_value=True, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], FIXTURE_USER_INPUT, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["data"][CONF_USERNAME] == FIXTURE_USER_INPUT[CONF_USERNAME] + assert result2["data"][CONF_PASSWORD] == FIXTURE_USER_INPUT[CONF_PASSWORD]