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:
parent
0cf1ca7736
commit
e2b622fb78
14 changed files with 464 additions and 0 deletions
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
102
homeassistant/components/flick_electric/__init__.py
Normal file
102
homeassistant/components/flick_electric/__init__.py
Normal 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]
|
92
homeassistant/components/flick_electric/config_flow.py
Normal file
92
homeassistant/components/flick_electric/config_flow.py
Normal 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."""
|
11
homeassistant/components/flick_electric/const.py
Normal file
11
homeassistant/components/flick_electric/const.py
Normal 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"]
|
12
homeassistant/components/flick_electric/manifest.json
Normal file
12
homeassistant/components/flick_electric/manifest.json
Normal 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"
|
||||
]
|
||||
}
|
83
homeassistant/components/flick_electric/sensor.py
Normal file
83
homeassistant/components/flick_electric/sensor.py
Normal 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)
|
24
homeassistant/components/flick_electric/strings.json
Normal file
24
homeassistant/components/flick_electric/strings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
24
homeassistant/components/flick_electric/translations/en.json
Normal file
24
homeassistant/components/flick_electric/translations/en.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -36,6 +36,7 @@ FLOWS = [
|
|||
"elkm1",
|
||||
"emulated_roku",
|
||||
"esphome",
|
||||
"flick_electric",
|
||||
"flume",
|
||||
"flunearyou",
|
||||
"freebox",
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
1
tests/components/flick_electric/__init__.py
Normal file
1
tests/components/flick_electric/__init__.py
Normal file
|
@ -0,0 +1 @@
|
|||
"""Tests for the Flick Electric integration."""
|
104
tests/components/flick_electric/test_config_flow.py
Normal file
104
tests/components/flick_electric/test_config_flow.py
Normal 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"}
|
Loading…
Add table
Reference in a new issue