Add Flick Electric NZ integration (#30696)

* Add integration for Flick Electric NZ

* Start adding Config Flow and external API

* Second Wave of Config Flow and API implementation

* Fix test (errors is None instead of blank array?)

* Don't update sensor if price is still valid

* Add input validation

* Fix linting for DOMAIN

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* Remove platform schema (config is by entries only)

* Don't catch AbortFlow exception

Co-Authored-By: Martin Hjelmare <marhje52@gmail.com>

* Update test

* Re-arrange try-catch in config flow

* Fix linting in sensor.py

* Staticly define list of components

* Fix test exceptions

* Fix _validate_input not being awaited

* Fix tests

* Fix pylint logger

* Rename test and remove print debug

* Add test for duplicate entry

* Don't format string in log function

* Add tests __init__ file

* Remove duplicate result assignment

* Add test for generic exception handling

* Move translations folder

* Simplify testing

* Fix strings/translation

* Move to "flick_electric" as domain

Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
This commit is contained in:
Brynley McDonald 2020-05-10 14:13:06 +12:00 committed by GitHub
parent 0cf1ca7736
commit e2b622fb78
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 464 additions and 0 deletions

View file

@ -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

View file

@ -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

View file

@ -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]

View file

@ -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."""

View file

@ -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"]

View file

@ -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"
]
}

View file

@ -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)

View file

@ -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"
}
}
}

View file

@ -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"
}
}
}

View file

@ -36,6 +36,7 @@ FLOWS = [
"elkm1",
"emulated_roku",
"esphome",
"flick_electric",
"flume",
"flunearyou",
"freebox",

View file

@ -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

View file

@ -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

View file

@ -0,0 +1 @@
"""Tests for the Flick Electric integration."""

View file

@ -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"}