diff --git a/.coveragerc b/.coveragerc index 0a1c515e9de..456b4fcad27 100644 --- a/.coveragerc +++ b/.coveragerc @@ -233,6 +233,9 @@ omit = homeassistant/components/fleetgo/device_tracker.py homeassistant/components/flexit/climate.py homeassistant/components/flic/binary_sensor.py + homeassistant/components/flick_electric/__init__.py + homeassistant/components/flick_electric/const.py + homeassistant/components/flick_electric/sensor.py homeassistant/components/flock/notify.py homeassistant/components/flume/* homeassistant/components/flunearyou/__init__.py diff --git a/CODEOWNERS b/CODEOWNERS index 09da1995df9..bf50495b8bd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -125,6 +125,7 @@ homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes homeassistant/components/fitbit/* @robbiet480 homeassistant/components/fixer/* @fabaff +homeassistant/components/flick_electric/* @ZephireNZ homeassistant/components/flock/* @fabaff homeassistant/components/flume/* @ChrisMandich @bdraco homeassistant/components/flunearyou/* @bachya diff --git a/homeassistant/components/flick_electric/__init__.py b/homeassistant/components/flick_electric/__init__.py new file mode 100644 index 00000000000..86af47a88bb --- /dev/null +++ b/homeassistant/components/flick_electric/__init__.py @@ -0,0 +1,102 @@ +"""The Flick Electric integration.""" + +from datetime import datetime as dt + +from pyflick import FlickAPI +from pyflick.authentication import AbstractFlickAuth +from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_ACCESS_TOKEN, + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers import aiohttp_client + +from .const import CONF_TOKEN_EXPIRES_IN, CONF_TOKEN_EXPIRY, DOMAIN + +CONF_ID_TOKEN = "id_token" + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the Flick Electric component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up Flick Electric from a config entry.""" + auth = HassFlickAuth(hass, entry) + + hass.data[DOMAIN][entry.entry_id] = FlickAPI(auth) + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, "sensor") + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + if await hass.config_entries.async_forward_entry_unload(entry, "sensor"): + hass.data[DOMAIN].pop(entry.entry_id) + return True + + return False + + +class HassFlickAuth(AbstractFlickAuth): + """Implementation of AbstractFlickAuth based on a Home Assistant entity config.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry): + """Flick authention based on a Home Assistant entity config.""" + super().__init__(aiohttp_client.async_get_clientsession(hass)) + self._entry = entry + self._hass = hass + + async def _get_entry_token(self): + # No token saved, generate one + if ( + CONF_TOKEN_EXPIRY not in self._entry.data + or CONF_ACCESS_TOKEN not in self._entry.data + ): + await self._update_token() + + # Token is expired, generate a new one + if self._entry.data[CONF_TOKEN_EXPIRY] <= dt.now().timestamp(): + await self._update_token() + + return self._entry.data[CONF_ACCESS_TOKEN] + + async def _update_token(self): + token = await self.get_new_token( + username=self._entry.data[CONF_USERNAME], + password=self._entry.data[CONF_PASSWORD], + client_id=self._entry.data.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID), + client_secret=self._entry.data.get( + CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET + ), + ) + + # Reduce expiry by an hour to avoid API being called after expiry + expiry = dt.now().timestamp() + int(token[CONF_TOKEN_EXPIRES_IN] - 3600) + + self._hass.config_entries.async_update_entry( + self._entry, + data={ + **self._entry.data, + CONF_ACCESS_TOKEN: token, + CONF_TOKEN_EXPIRY: expiry, + }, + ) + + async def async_get_access_token(self): + """Get Access Token from HASS Storage.""" + token = await self._get_entry_token() + + return token[CONF_ID_TOKEN] diff --git a/homeassistant/components/flick_electric/config_flow.py b/homeassistant/components/flick_electric/config_flow.py new file mode 100644 index 00000000000..2106a6f8d62 --- /dev/null +++ b/homeassistant/components/flick_electric/config_flow.py @@ -0,0 +1,92 @@ +"""Config Flow for Flick Electric integration.""" +import asyncio +import logging + +import async_timeout +from pyflick.authentication import AuthException, SimpleFlickAuth +from pyflick.const import DEFAULT_CLIENT_ID, DEFAULT_CLIENT_SECRET +import voluptuous as vol + +from homeassistant import config_entries, exceptions +from homeassistant.const import ( + CONF_CLIENT_ID, + CONF_CLIENT_SECRET, + CONF_PASSWORD, + CONF_USERNAME, +) +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN # pylint: disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + vol.Optional(CONF_CLIENT_ID): str, + vol.Optional(CONF_CLIENT_SECRET): str, + } +) + + +class FlickConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Flick config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def _validate_input(self, user_input): + auth = SimpleFlickAuth( + username=user_input[CONF_USERNAME], + password=user_input[CONF_PASSWORD], + websession=aiohttp_client.async_get_clientsession(self.hass), + client_id=user_input.get(CONF_CLIENT_ID, DEFAULT_CLIENT_ID), + client_secret=user_input.get(CONF_CLIENT_SECRET, DEFAULT_CLIENT_SECRET), + ) + + try: + with async_timeout.timeout(60): + token = await auth.async_get_access_token() + except asyncio.TimeoutError: + raise CannotConnect() + except AuthException: + raise InvalidAuth() + else: + return token is not None + + async def async_step_user(self, user_input): + """Handle gathering login info.""" + errors = {} + if user_input is not None: + try: + await self._validate_input(user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id( + f"flick_electric_{user_input[CONF_USERNAME]}" + ) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"Flick Electric: {user_input[CONF_USERNAME]}", + data=user_input, + ) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" diff --git a/homeassistant/components/flick_electric/const.py b/homeassistant/components/flick_electric/const.py new file mode 100644 index 00000000000..e8365f37411 --- /dev/null +++ b/homeassistant/components/flick_electric/const.py @@ -0,0 +1,11 @@ +"""Constants for the Flick Electric integration.""" + +DOMAIN = "flick_electric" + +CONF_TOKEN_EXPIRES_IN = "expires_in" +CONF_TOKEN_EXPIRY = "expires" + +ATTR_START_AT = "start_at" +ATTR_END_AT = "end_at" + +ATTR_COMPONENTS = ["retailer", "ea", "metering", "generation", "admin", "network"] diff --git a/homeassistant/components/flick_electric/manifest.json b/homeassistant/components/flick_electric/manifest.json new file mode 100644 index 00000000000..6eb5a2e58f9 --- /dev/null +++ b/homeassistant/components/flick_electric/manifest.json @@ -0,0 +1,12 @@ +{ + "domain": "flick_electric", + "name": "Flick Electric", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/flick_electric/", + "requirements": [ + "PyFlick==0.0.2" + ], + "codeowners": [ + "@ZephireNZ" + ] +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/sensor.py b/homeassistant/components/flick_electric/sensor.py new file mode 100644 index 00000000000..9d441ce7574 --- /dev/null +++ b/homeassistant/components/flick_electric/sensor.py @@ -0,0 +1,83 @@ +"""Support for Flick Electric Pricing data.""" +from datetime import timedelta +import logging + +import async_timeout +from pyflick import FlickAPI, FlickPrice + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_FRIENDLY_NAME +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import Entity +from homeassistant.util.dt import utcnow + +from .const import ATTR_COMPONENTS, ATTR_END_AT, ATTR_START_AT, DOMAIN + +_LOGGER = logging.getLogger(__name__) +_AUTH_URL = "https://api.flick.energy/identity/oauth/token" +_RESOURCE = "https://api.flick.energy/customer/mobile_provider/price" + +SCAN_INTERVAL = timedelta(minutes=5) + +ATTRIBUTION = "Data provided by Flick Electric" +FRIENDLY_NAME = "Flick Power Price" +UNIT_NAME = "cents" + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities +): + """Flick Sensor Setup.""" + api: FlickAPI = hass.data[DOMAIN][entry.entry_id] + + async_add_entities([FlickPricingSensor(api)], True) + + +class FlickPricingSensor(Entity): + """Entity object for Flick Electric sensor.""" + + def __init__(self, api: FlickAPI): + """Entity object for Flick Electric sensor.""" + self._api: FlickAPI = api + self._price: FlickPrice = None + self._attributes = { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_FRIENDLY_NAME: FRIENDLY_NAME, + } + + @property + def name(self): + """Return the name of the sensor.""" + return FRIENDLY_NAME + + @property + def state(self): + """Return the state of the sensor.""" + return self._price.price + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return UNIT_NAME + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + + async def async_update(self): + """Get the Flick Pricing data from the web service.""" + if self._price and self._price.end_at >= utcnow(): + return # Power price data is still valid + + with async_timeout.timeout(60): + self._price = await self._api.getPricing() + + self._attributes[ATTR_START_AT] = self._price.start_at + self._attributes[ATTR_END_AT] = self._price.end_at + for component in self._price.components: + if component.charge_setter not in ATTR_COMPONENTS: + _LOGGER.warning("Found unknown component: %s", component.charge_setter) + continue + + self._attributes[component.charge_setter] = float(component.value) diff --git a/homeassistant/components/flick_electric/strings.json b/homeassistant/components/flick_electric/strings.json new file mode 100644 index 00000000000..ec22d3452a9 --- /dev/null +++ b/homeassistant/components/flick_electric/strings.json @@ -0,0 +1,24 @@ +{ + "title": "Flick Electric", + "config": { + "step": { + "user": { + "title": "Flick Login Credentials", + "data": { + "username": "Username", + "password": "Password", + "client_id": "Client ID (Optional)", + "client_secret": "Client Secret (Optional)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "That account is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/flick_electric/translations/en.json b/homeassistant/components/flick_electric/translations/en.json new file mode 100644 index 00000000000..ec22d3452a9 --- /dev/null +++ b/homeassistant/components/flick_electric/translations/en.json @@ -0,0 +1,24 @@ +{ + "title": "Flick Electric", + "config": { + "step": { + "user": { + "title": "Flick Login Credentials", + "data": { + "username": "Username", + "password": "Password", + "client_id": "Client ID (Optional)", + "client_secret": "Client Secret (Optional)" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again", + "invalid_auth": "Invalid authentication", + "unknown": "Unexpected error" + }, + "abort": { + "already_configured": "That account is already configured" + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 528092a6c60..d93c37fa6e6 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -36,6 +36,7 @@ FLOWS = [ "elkm1", "emulated_roku", "esphome", + "flick_electric", "flume", "flunearyou", "freebox", diff --git a/requirements_all.txt b/requirements_all.txt index a20ff1931ef..bbfeff9b39d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -46,6 +46,9 @@ OPi.GPIO==0.4.0 # homeassistant.components.essent PyEssent==0.13 +# homeassistant.components.flick_electric +PyFlick==0.0.2 + # homeassistant.components.github PyGithub==1.43.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65e6b0e23e9..2eacfdf91e8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -6,6 +6,9 @@ # homeassistant.components.homekit HAP-python==2.8.3 +# homeassistant.components.flick_electric +PyFlick==0.0.2 + # homeassistant.components.mobile_app # homeassistant.components.owntracks PyNaCl==1.3.0 diff --git a/tests/components/flick_electric/__init__.py b/tests/components/flick_electric/__init__.py new file mode 100644 index 00000000000..7ba25e6c180 --- /dev/null +++ b/tests/components/flick_electric/__init__.py @@ -0,0 +1 @@ +"""Tests for the Flick Electric integration.""" diff --git a/tests/components/flick_electric/test_config_flow.py b/tests/components/flick_electric/test_config_flow.py new file mode 100644 index 00000000000..94bff11135a --- /dev/null +++ b/tests/components/flick_electric/test_config_flow.py @@ -0,0 +1,104 @@ +"""Test the Flick Electric config flow.""" +import asyncio + +from asynctest import patch +from pyflick.authentication import AuthException + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.flick_electric.const import DOMAIN +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME + +from tests.common import MockConfigEntry + +CONF = {CONF_USERNAME: "test-username", CONF_PASSWORD: "test-password"} + + +async def _flow_submit(hass): + return await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=CONF, + ) + + +async def test_form(hass): + """Test we get the form.""" + await setup.async_setup_component(hass, "persistent_notification", {}) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ), patch( + "homeassistant.components.flick_electric.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.flick_electric.async_setup_entry", return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], CONF, + ) + + assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result2["title"] == "Flick Electric: test-username" + assert result2["data"] == CONF + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_duplicate_login(hass): + """Test uniqueness of username.""" + entry = MockConfigEntry( + domain=DOMAIN, + data=CONF, + title="Flick Electric: test-username", + unique_id="flick_electric_test-username", + ) + entry.add_to_hass(hass) + + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + return_value="123456789abcdef", + ): + result = await _flow_submit(hass) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form_invalid_auth(hass): + """Test we handle invalid auth.""" + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=AuthException, + ): + result = await _flow_submit(hass) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "invalid_auth"} + + +async def test_form_cannot_connect(hass): + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=asyncio.TimeoutError, + ): + result = await _flow_submit(hass) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_form_generic_exception(hass): + """Test we handle cannot connect error.""" + with patch( + "homeassistant.components.flick_electric.config_flow.SimpleFlickAuth.async_get_access_token", + side_effect=Exception, + ): + result = await _flow_submit(hass) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["errors"] == {"base": "unknown"}