diff --git a/CODEOWNERS b/CODEOWNERS index 7465c42a272..0cf31095b52 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -424,6 +424,7 @@ homeassistant/components/splunk/* @Bre77 homeassistant/components/spotify/* @frenck homeassistant/components/sql/* @dgomes homeassistant/components/squeezebox/* @rajlaud +homeassistant/components/srp_energy/* @briglx homeassistant/components/starline/* @anonym-tsk homeassistant/components/statistics/* @fabaff homeassistant/components/stiebel_eltron/* @fucm diff --git a/homeassistant/components/srp_energy/__init__.py b/homeassistant/components/srp_energy/__init__.py new file mode 100644 index 00000000000..f7cc1ff8c16 --- /dev/null +++ b/homeassistant/components/srp_energy/__init__.py @@ -0,0 +1,52 @@ +"""The SRP Energy integration.""" +import logging + +from srpenergy.client import SrpEnergyClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID, CONF_PASSWORD, CONF_USERNAME +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady + +from .const import SRP_ENERGY_DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +PLATFORMS = ["sensor"] + + +async def async_setup(hass, config): + """Old way of setting up the srp_energy component.""" + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up the SRP Energy component from a config entry.""" + # Store an SrpEnergyClient object for your srp_energy to access + try: + srp_energy_client = SrpEnergyClient( + entry.data.get(CONF_ID), + entry.data.get(CONF_USERNAME), + entry.data.get(CONF_PASSWORD), + ) + hass.data[SRP_ENERGY_DOMAIN] = srp_energy_client + except (Exception) as ex: + _LOGGER.error("Unable to connect to Srp Energy: %s", str(ex)) + raise ConfigEntryNotReady from ex + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry): + """Unload a config entry.""" + # unload srp client + hass.data[SRP_ENERGY_DOMAIN] = None + # Remove config entry + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + + return True diff --git a/homeassistant/components/srp_energy/config_flow.py b/homeassistant/components/srp_energy/config_flow.py new file mode 100644 index 00000000000..b65b93e0108 --- /dev/null +++ b/homeassistant/components/srp_energy/config_flow.py @@ -0,0 +1,71 @@ +"""Config flow for SRP Energy.""" +import logging + +from srpenergy.client import SrpEnergyClient +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from .const import ( # pylint:disable=unused-import + CONF_IS_TOU, + DEFAULT_NAME, + SRP_ENERGY_DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +class ConfigFlow(config_entries.ConfigFlow, domain=SRP_ENERGY_DOMAIN): + """Handle a config flow for SRP Energy.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + config = { + vol.Required(CONF_ID): str, + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): str, + vol.Optional(CONF_IS_TOU, default=False): bool, + } + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + errors = {} + + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + try: + + srp_client = SrpEnergyClient( + user_input[CONF_ID], + user_input[CONF_USERNAME], + user_input[CONF_PASSWORD], + ) + + is_valid = await self.hass.async_add_executor_job(srp_client.validate) + + if is_valid: + return self.async_create_entry( + title=user_input[CONF_NAME], data=user_input + ) + + errors["base"] = "invalid_auth" + + except ValueError: + errors["base"] = "invalid_account" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return self.async_show_form( + step_id="user", data_schema=vol.Schema(self.config), errors=errors + ) + + async def async_step_import(self, import_config): + """Import from config.""" + # Validate config values + return await self.async_step_user(user_input=import_config) diff --git a/homeassistant/components/srp_energy/const.py b/homeassistant/components/srp_energy/const.py new file mode 100644 index 00000000000..527a1ed78b1 --- /dev/null +++ b/homeassistant/components/srp_energy/const.py @@ -0,0 +1,15 @@ +"""Constants for the SRP Energy integration.""" +from datetime import timedelta + +SRP_ENERGY_DOMAIN = "srp_energy" +DEFAULT_NAME = "SRP Energy" + +CONF_IS_TOU = "is_tou" + +ATTRIBUTION = "Powered by SRP Energy" +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) + +SENSOR_NAME = "Usage" +SENSOR_TYPE = "usage" + +ICON = "mdi:flash" diff --git a/homeassistant/components/srp_energy/manifest.json b/homeassistant/components/srp_energy/manifest.json new file mode 100644 index 00000000000..fb051fc7b2f --- /dev/null +++ b/homeassistant/components/srp_energy/manifest.json @@ -0,0 +1,16 @@ +{ + "domain": "srp_energy", + "name": "SRP Energy", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/srp_energy", + "requirements": [ + "srpenergy==1.3.2" + ], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@briglx" + ] +} \ No newline at end of file diff --git a/homeassistant/components/srp_energy/sensor.py b/homeassistant/components/srp_energy/sensor.py new file mode 100644 index 00000000000..36a8798b05b --- /dev/null +++ b/homeassistant/components/srp_energy/sensor.py @@ -0,0 +1,153 @@ +"""Support for SRP Energy Sensor.""" +from datetime import datetime, timedelta +import logging + +import async_timeout +from requests.exceptions import ConnectionError as ConnectError, HTTPError, Timeout + +from homeassistant.const import ATTR_ATTRIBUTION, ENERGY_KILO_WATT_HOUR +from homeassistant.helpers import entity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import ( + ATTRIBUTION, + DEFAULT_NAME, + ICON, + MIN_TIME_BETWEEN_UPDATES, + SENSOR_NAME, + SENSOR_TYPE, + SRP_ENERGY_DOMAIN, +) + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up the SRP Energy Usage sensor.""" + # API object stored here by __init__.py + is_time_of_use = False + api = hass.data[SRP_ENERGY_DOMAIN] + if entry and entry.data: + is_time_of_use = entry.data["is_tou"] + + async def async_update_data(): + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + try: + # Fetch srp_energy data + start_date = datetime.now() + timedelta(days=-1) + end_date = datetime.now() + with async_timeout.timeout(10): + hourly_usage = await hass.async_add_executor_job( + api.usage, + start_date, + end_date, + is_time_of_use, + ) + + previous_daily_usage = 0.0 + for _, _, _, kwh, _ in hourly_usage: + previous_daily_usage += float(kwh) + return previous_daily_usage + except (TimeoutError) as timeout_err: + raise UpdateFailed("Timeout communicating with API") from timeout_err + except (ConnectError, HTTPError, Timeout, ValueError, TypeError) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name="sensor", + update_method=async_update_data, + update_interval=MIN_TIME_BETWEEN_UPDATES, + ) + + # Fetch initial data so we have data when entities subscribe + await coordinator.async_refresh() + + async_add_entities([SrpEntity(coordinator)]) + + +class SrpEntity(entity.Entity): + """Implementation of a Srp Energy Usage sensor.""" + + def __init__(self, coordinator): + """Initialize the SrpEntity class.""" + self._name = SENSOR_NAME + self.type = SENSOR_TYPE + self.coordinator = coordinator + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return f"{DEFAULT_NAME} {self._name}" + + @property + def unique_id(self): + """Return sensor unique_id.""" + return self.type + + @property + def state(self): + """Return the state of the device.""" + if self._state: + return f"{self._state:.2f}" + return None + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return icon.""" + return ICON + + @property + def usage(self): + """Return entity state.""" + if self.coordinator.data: + return f"{self.coordinator.data:.2f}" + return None + + @property + def should_poll(self): + """No need to poll. Coordinator notifies entity of updates.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if not self.coordinator.data: + return None + attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + } + + return attributes + + @property + def available(self): + """Return if entity is available.""" + return self.coordinator.last_update_success + + async def async_added_to_hass(self): + """When entity is added to hass.""" + self.async_on_remove( + self.coordinator.async_add_listener(self.async_write_ha_state) + ) + if self.coordinator.data: + self._state = self.coordinator.data + + async def async_update(self): + """Update the entity. + + Only used by the generic entity update service. + """ + await self.coordinator.async_request_refresh() diff --git a/homeassistant/components/srp_energy/strings.json b/homeassistant/components/srp_energy/strings.json new file mode 100644 index 00000000000..8dce61229a9 --- /dev/null +++ b/homeassistant/components/srp_energy/strings.json @@ -0,0 +1,24 @@ +{ + "title": "SRP Energy", + "config": { + "step": { + "user": { + "data": { + "id": "Account Id", + "username": "[%key:common::config_flow::data::username%]", + "password": "[%key:common::config_flow::data::password%]", + "is_tou": "Is Time of Use Plan" + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_account": "Account ID should be a 9 digit number", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} diff --git a/homeassistant/components/srp_energy/translations/en.json b/homeassistant/components/srp_energy/translations/en.json new file mode 100644 index 00000000000..0d00872e43d --- /dev/null +++ b/homeassistant/components/srp_energy/translations/en.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "error": { + "cannot_connect": "Failed to connect", + "invalid_auth": "Invalid authentication", + "invalid_account": "Account ID should be a 9 digit number", + "unknown": "Unexpected error" + }, + "step": { + "user": { + "data": { + "id": "Account Id", + "username": "Username", + "password": "Password", + "is_tou": "Is Time of Use Plan" + } + } + } + }, + "title": "SRP Energy" +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 5386ff4ce60..531e575dc0a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -190,6 +190,7 @@ FLOWS = [ "spider", "spotify", "squeezebox", + "srp_energy", "starline", "syncthru", "synology_dsm", diff --git a/requirements_all.txt b/requirements_all.txt index 6a7687894ec..f4905beb679 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2098,6 +2098,9 @@ spotipy==2.16.1 # homeassistant.components.sql sqlalchemy==1.3.20 +# homeassistant.components.srp_energy +srpenergy==1.3.2 + # homeassistant.components.starline starline==0.1.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fd23400596..7160913d7af 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1017,6 +1017,9 @@ spotipy==2.16.1 # homeassistant.components.sql sqlalchemy==1.3.20 +# homeassistant.components.srp_energy +srpenergy==1.3.2 + # homeassistant.components.starline starline==0.1.3 diff --git a/tests/components/srp_energy/__init__.py b/tests/components/srp_energy/__init__.py new file mode 100644 index 00000000000..34f06e2993e --- /dev/null +++ b/tests/components/srp_energy/__init__.py @@ -0,0 +1,55 @@ +"""Tests for the SRP Energy integration.""" +from homeassistant import config_entries +from homeassistant.components import srp_energy +from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME + +from tests.async_mock import patch +from tests.common import MockConfigEntry + +ENTRY_OPTIONS = {} + +ENTRY_CONFIG = { + CONF_NAME: "Test", + CONF_ID: "123456789", + CONF_USERNAME: "abba", + CONF_PASSWORD: "ana", + srp_energy.const.CONF_IS_TOU: False, +} + + +async def init_integration( + hass, + config=None, + options=None, + entry_id="1", + source="user", + side_effect=None, + usage=None, +): + """Set up the srp_energy integration in Home Assistant.""" + if not config: + config = ENTRY_CONFIG + + if not options: + options = ENTRY_OPTIONS + + config_entry = MockConfigEntry( + domain=srp_energy.SRP_ENERGY_DOMAIN, + source=source, + data=config, + connection_class=config_entries.CONN_CLASS_CLOUD_POLL, + options=options, + entry_id=entry_id, + ) + + with patch("srpenergy.client.SrpEnergyClient"), patch( + "homeassistant.components.srp_energy.SrpEnergyClient", side_effect=side_effect + ), patch("srpenergy.client.SrpEnergyClient.usage", return_value=usage), patch( + "homeassistant.components.srp_energy.SrpEnergyClient.usage", return_value=usage + ): + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/srp_energy/test_config_flow.py b/tests/components/srp_energy/test_config_flow.py new file mode 100644 index 00000000000..acb9d28f75d --- /dev/null +++ b/tests/components/srp_energy/test_config_flow.py @@ -0,0 +1,105 @@ +"""Test the SRP Energy config flow.""" +from homeassistant import config_entries, data_entry_flow +from homeassistant.components.srp_energy.const import CONF_IS_TOU, SRP_ENERGY_DOMAIN + +from . import ENTRY_CONFIG, init_integration + +from tests.async_mock import patch + + +async def test_form(hass): + """Test user config.""" + # First get the form + result = await hass.config_entries.flow.async_init( + SRP_ENERGY_DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + # Fill submit form data for config entry + with patch("homeassistant.components.srp_energy.config_flow.SrpEnergyClient"): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["type"] == "create_entry" + assert result["title"] == "Test" + assert result["data"][CONF_IS_TOU] is False + + +async def test_form_invalid_auth(hass): + """Test user config with invalid auth.""" + result = await hass.config_entries.flow.async_init( + SRP_ENERGY_DOMAIN, context={"source": "user"} + ) + + with patch( + "homeassistant.components.srp_energy.config_flow.SrpEnergyClient.validate", + return_value=False, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["errors"]["base"] == "invalid_auth" + + +async def test_form_value_error(hass): + """Test user config that throws a value error.""" + result = await hass.config_entries.flow.async_init( + SRP_ENERGY_DOMAIN, context={"source": "user"} + ) + + with patch( + "homeassistant.components.srp_energy.config_flow.SrpEnergyClient", + side_effect=ValueError(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["errors"]["base"] == "invalid_account" + + +async def test_form_unknown_exception(hass): + """Test user config that throws an unknown exception.""" + result = await hass.config_entries.flow.async_init( + SRP_ENERGY_DOMAIN, context={"source": "user"} + ) + + with patch( + "homeassistant.components.srp_energy.config_flow.SrpEnergyClient", + side_effect=Exception(), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=ENTRY_CONFIG, + ) + + assert result["errors"]["base"] == "unknown" + + +async def test_config(hass): + """Test handling of configuration imported.""" + with patch("homeassistant.components.srp_energy.config_flow.SrpEnergyClient"): + result = await hass.config_entries.flow.async_init( + SRP_ENERGY_DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data=ENTRY_CONFIG, + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_integration_already_configured(hass): + """Test integration is already configured.""" + await init_integration(hass) + result = await hass.config_entries.flow.async_init( + SRP_ENERGY_DOMAIN, context={"source": "user"} + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "single_instance_allowed" diff --git a/tests/components/srp_energy/test_init.py b/tests/components/srp_energy/test_init.py new file mode 100644 index 00000000000..8e758d05114 --- /dev/null +++ b/tests/components/srp_energy/test_init.py @@ -0,0 +1,26 @@ +"""Tests for Srp Energy component Init.""" +from homeassistant.components import srp_energy + +from tests.components.srp_energy import init_integration + + +async def test_setup_entry(hass): + """Test setup entry fails if deCONZ is not available.""" + config_entry = await init_integration(hass) + assert config_entry.state == "loaded" + assert hass.data[srp_energy.SRP_ENERGY_DOMAIN] + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + config_entry = await init_integration(hass) + assert hass.data[srp_energy.SRP_ENERGY_DOMAIN] + + assert await srp_energy.async_unload_entry(hass, config_entry) + assert not hass.data[srp_energy.SRP_ENERGY_DOMAIN] + + +async def test_async_setup_entry_with_exception(hass): + """Test exception when SrpClient can't load.""" + await init_integration(hass, side_effect=Exception()) + assert srp_energy.SRP_ENERGY_DOMAIN not in hass.data diff --git a/tests/components/srp_energy/test_sensor.py b/tests/components/srp_energy/test_sensor.py new file mode 100644 index 00000000000..3a70a3ec09f --- /dev/null +++ b/tests/components/srp_energy/test_sensor.py @@ -0,0 +1,129 @@ +"""Tests for the srp_energy sensor platform.""" +from homeassistant.components.srp_energy.const import ( + ATTRIBUTION, + DEFAULT_NAME, + ICON, + SENSOR_NAME, + SENSOR_TYPE, + SRP_ENERGY_DOMAIN, +) +from homeassistant.components.srp_energy.sensor import SrpEntity, async_setup_entry +from homeassistant.const import ATTR_ATTRIBUTION, ENERGY_KILO_WATT_HOUR + +from tests.async_mock import MagicMock + + +async def test_async_setup_entry(hass): + """Test the sensor.""" + fake_async_add_entities = MagicMock() + fake_srp_energy_client = MagicMock() + fake_srp_energy_client.usage.return_value = [{1, 2, 3, 1.999, 4}] + fake_config = MagicMock( + data={ + "name": "SRP Energy", + "is_tou": False, + "id": "0123456789", + "username": "testuser@example.com", + "password": "mypassword", + } + ) + hass.data[SRP_ENERGY_DOMAIN] = fake_srp_energy_client + + await async_setup_entry(hass, fake_config, fake_async_add_entities) + + +async def test_async_setup_entry_timeout_error(hass): + """Test fetching usage data. Failed the first time because was too get response.""" + fake_async_add_entities = MagicMock() + fake_srp_energy_client = MagicMock() + fake_srp_energy_client.usage.return_value = [{1, 2, 3, 1.999, 4}] + fake_config = MagicMock( + data={ + "name": "SRP Energy", + "is_tou": False, + "id": "0123456789", + "username": "testuser@example.com", + "password": "mypassword", + } + ) + hass.data[SRP_ENERGY_DOMAIN] = fake_srp_energy_client + fake_srp_energy_client.usage.side_effect = TimeoutError() + + await async_setup_entry(hass, fake_config, fake_async_add_entities) + assert not fake_async_add_entities.call_args[0][0][ + 0 + ].coordinator.last_update_success + + +async def test_async_setup_entry_connect_error(hass): + """Test fetching usage data. Failed the first time because was too get response.""" + fake_async_add_entities = MagicMock() + fake_srp_energy_client = MagicMock() + fake_srp_energy_client.usage.return_value = [{1, 2, 3, 1.999, 4}] + fake_config = MagicMock( + data={ + "name": "SRP Energy", + "is_tou": False, + "id": "0123456789", + "username": "testuser@example.com", + "password": "mypassword", + } + ) + hass.data[SRP_ENERGY_DOMAIN] = fake_srp_energy_client + fake_srp_energy_client.usage.side_effect = ValueError() + + await async_setup_entry(hass, fake_config, fake_async_add_entities) + assert not fake_async_add_entities.call_args[0][0][ + 0 + ].coordinator.last_update_success + + +async def test_srp_entity(hass): + """Test the SrpEntity.""" + fake_coordinator = MagicMock(data=1.99999999999) + srp_entity = SrpEntity(fake_coordinator) + + assert srp_entity is not None + assert srp_entity.name == f"{DEFAULT_NAME} {SENSOR_NAME}" + assert srp_entity.unique_id == SENSOR_TYPE + assert srp_entity.state is None + assert srp_entity.unit_of_measurement == ENERGY_KILO_WATT_HOUR + assert srp_entity.icon == ICON + assert srp_entity.usage == "2.00" + assert srp_entity.should_poll is False + assert srp_entity.device_state_attributes[ATTR_ATTRIBUTION] == ATTRIBUTION + assert srp_entity.available is not None + + await srp_entity.async_added_to_hass() + assert srp_entity.state is not None + assert fake_coordinator.async_add_listener.called + assert not fake_coordinator.async_add_listener.data.called + + +async def test_srp_entity_no_data(hass): + """Test the SrpEntity.""" + fake_coordinator = MagicMock(data=False) + srp_entity = SrpEntity(fake_coordinator) + assert srp_entity.device_state_attributes is None + + +async def test_srp_entity_no_coord_data(hass): + """Test the SrpEntity.""" + fake_coordinator = MagicMock(data=False) + srp_entity = SrpEntity(fake_coordinator) + + assert srp_entity.usage is None + + +async def test_srp_entity_async_update(hass): + """Test the SrpEntity.""" + + async def async_magic(): + pass + + MagicMock.__await__ = lambda x: async_magic().__await__() + fake_coordinator = MagicMock(data=False) + srp_entity = SrpEntity(fake_coordinator) + + await srp_entity.async_update() + assert fake_coordinator.async_request_refresh.called