Add config flow to linky (#26076)

* Linky: setup ConfigFlow

* async_track_time_interval

* Review from @MartinHjelmare 1

* Review from @MartinHjelmare 2

* Review from @MartinHjelmare 3

* Review from @MartinHjelmare 4

* black --fast homeassistant tests

* Bump pylinky to 0.4.0 and add error user feedback

* Fix .coveragerc

* Linky platform moved to integration in config.yml and with multiple accounts

* Remove useless logs

* Review from @MartinHjelmare 5

* Add config flow tests

* Add config flow tests : login + fetch on failed
This commit is contained in:
Quentame 2019-09-04 07:04:26 +02:00 committed by Martin Hjelmare
parent ca97bba4b4
commit b4058b5c7f
15 changed files with 452 additions and 58 deletions

View File

@ -339,6 +339,7 @@ omit =
homeassistant/components/limitlessled/light.py homeassistant/components/limitlessled/light.py
homeassistant/components/linksys_ap/device_tracker.py homeassistant/components/linksys_ap/device_tracker.py
homeassistant/components/linksys_smart/device_tracker.py homeassistant/components/linksys_smart/device_tracker.py
homeassistant/components/linky/__init__.py
homeassistant/components/linky/sensor.py homeassistant/components/linky/sensor.py
homeassistant/components/linode/* homeassistant/components/linode/*
homeassistant/components/linux_battery/sensor.py homeassistant/components/linux_battery/sensor.py

View File

@ -153,7 +153,7 @@ homeassistant/components/life360/* @pnbruckner
homeassistant/components/lifx/* @amelchio homeassistant/components/lifx/* @amelchio
homeassistant/components/lifx_cloud/* @amelchio homeassistant/components/lifx_cloud/* @amelchio
homeassistant/components/lifx_legacy/* @amelchio homeassistant/components/lifx_legacy/* @amelchio
homeassistant/components/linky/* @tiste @Quentame homeassistant/components/linky/* @Quentame
homeassistant/components/linux_battery/* @fabaff homeassistant/components/linux_battery/* @fabaff
homeassistant/components/liveboxplaytv/* @pschmitt homeassistant/components/liveboxplaytv/* @pschmitt
homeassistant/components/logger/* @home-assistant/core homeassistant/components/logger/* @home-assistant/core

View File

@ -0,0 +1,25 @@
{
"config": {
"abort": {
"username_exists": "Account already configured"
},
"error": {
"access": "Could not access to Enedis.fr, please check your internet connection",
"enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)",
"unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)",
"username_exists": "Account already configured",
"wrong_login": "Login error: please check your email & password"
},
"step": {
"user": {
"data": {
"password": "Password",
"username": "Email"
},
"description": "Enter your credentials",
"title": "Linky"
}
},
"title": "Linky"
}
}

View File

@ -1 +1,55 @@
"""The linky component.""" """The linky component."""
import logging
import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.typing import HomeAssistantType
from .const import DEFAULT_TIMEOUT, DOMAIN
_LOGGER = logging.getLogger(__name__)
ACCOUNT_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): cv.string,
vol.Required(CONF_PASSWORD): cv.string,
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}
)
CONFIG_SCHEMA = vol.Schema(
{DOMAIN: vol.Schema(vol.All(cv.ensure_list, [ACCOUNT_SCHEMA]))},
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass, config):
"""Set up Linky sensors from legacy config file."""
conf = config.get(DOMAIN)
if conf is None:
return True
for linky_account_conf in conf:
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data=linky_account_conf.copy(),
)
)
return True
async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry):
"""Set up Linky sensors."""
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, "sensor")
)
return True

View File

@ -0,0 +1,118 @@
"""Config flow to configure the Linky integration."""
import logging
import voluptuous as vol
from pylinky.client import LinkyClient
from pylinky.exceptions import (
PyLinkyAccessException,
PyLinkyEnedisException,
PyLinkyException,
PyLinkyWrongLoginException,
)
from homeassistant import config_entries
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from homeassistant.core import callback
from .const import DEFAULT_TIMEOUT, DOMAIN
_LOGGER = logging.getLogger(__name__)
class LinkyFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
def __init__(self):
"""Initialize Linky config flow."""
self._username = None
self._password = None
self._timeout = None
def _configuration_exists(self, username: str) -> bool:
"""Return True if username exists in configuration."""
for entry in self.hass.config_entries.async_entries(DOMAIN):
if entry.data[CONF_USERNAME] == username:
return True
return False
@callback
def _show_setup_form(self, user_input=None, errors=None):
"""Show the setup form to the user."""
if user_input is None:
user_input = {}
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_USERNAME, default=user_input.get(CONF_USERNAME, "")
): str,
vol.Required(
CONF_PASSWORD, default=user_input.get(CONF_PASSWORD, "")
): str,
}
),
errors=errors or {},
)
async def async_step_user(self, user_input=None):
"""Handle a flow initiated by the user."""
errors = {}
if user_input is None:
return self._show_setup_form(user_input, None)
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
self._timeout = user_input.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
if self._configuration_exists(self._username):
errors[CONF_USERNAME] = "username_exists"
return self._show_setup_form(user_input, errors)
client = LinkyClient(self._username, self._password, None, self._timeout)
try:
await self.hass.async_add_executor_job(client.login)
await self.hass.async_add_executor_job(client.fetch_data)
except PyLinkyAccessException as exp:
_LOGGER.error(exp)
errors["base"] = "access"
return self._show_setup_form(user_input, errors)
except PyLinkyEnedisException as exp:
_LOGGER.error(exp)
errors["base"] = "enedis"
return self._show_setup_form(user_input, errors)
except PyLinkyWrongLoginException as exp:
_LOGGER.error(exp)
errors["base"] = "wrong_login"
return self._show_setup_form(user_input, errors)
except PyLinkyException as exp:
_LOGGER.error(exp)
errors["base"] = "unknown"
return self._show_setup_form(user_input, errors)
finally:
client.close_session()
return self.async_create_entry(
title=self._username,
data={
CONF_USERNAME: self._username,
CONF_PASSWORD: self._password,
CONF_TIMEOUT: self._timeout,
},
)
async def async_step_import(self, user_input=None):
"""Import a config entry.
Only host was required in the yaml file all other fields are optional
"""
if self._configuration_exists(user_input[CONF_USERNAME]):
return self.async_abort(reason="username_exists")
return await self.async_step_user(user_input)

View File

@ -0,0 +1,5 @@
"""Linky component constants."""
DOMAIN = "linky"
DEFAULT_TIMEOUT = 10

View File

@ -1,13 +1,13 @@
{ {
"domain": "linky", "domain": "linky",
"name": "Linky", "name": "Linky",
"config_flow": true,
"documentation": "https://www.home-assistant.io/components/linky", "documentation": "https://www.home-assistant.io/components/linky",
"requirements": [ "requirements": [
"pylinky==0.3.3" "pylinky==0.4.0"
], ],
"dependencies": [], "dependencies": [],
"codeowners": [ "codeowners": [
"@tiste",
"@Quentame" "@Quentame"
] ]
} }

View File

@ -1,12 +1,12 @@
"""Support for Linky.""" """Support for Linky."""
from datetime import timedelta
import json import json
import logging import logging
from datetime import timedelta
from pylinky.client import DAILY, MONTHLY, YEARLY, LinkyClient, PyLinkyError from pylinky.client import DAILY, MONTHLY, YEARLY, LinkyClient
import voluptuous as vol from pylinky.client import PyLinkyException
from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_ATTRIBUTION,
CONF_PASSWORD, CONF_PASSWORD,
@ -14,10 +14,9 @@ from homeassistant.const import (
CONF_USERNAME, CONF_USERNAME,
ENERGY_KILO_WATT_HOUR, ENERGY_KILO_WATT_HOUR,
) )
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.event import async_track_time_interval
import homeassistant.util.dt as dt_util from homeassistant.helpers.typing import HomeAssistantType
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -29,7 +28,6 @@ INDEX_CURRENT = -1
INDEX_LAST = -2 INDEX_LAST = -2
ATTRIBUTION = "Data provided by Enedis" ATTRIBUTION = "Data provided by Enedis"
DEFAULT_TIMEOUT = 10
SENSORS = { SENSORS = {
"yesterday": ("Linky yesterday", DAILY, INDEX_LAST), "yesterday": ("Linky yesterday", DAILY, INDEX_LAST),
"current_month": ("Linky current month", MONTHLY, INDEX_CURRENT), "current_month": ("Linky current month", MONTHLY, INDEX_CURRENT),
@ -41,59 +39,54 @@ SENSORS_INDEX_LABEL = 0
SENSORS_INDEX_SCALE = 1 SENSORS_INDEX_SCALE = 1
SENSORS_INDEX_WHEN = 2 SENSORS_INDEX_WHEN = 2
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None):
vol.Required(CONF_USERNAME): cv.string, """Old way of setting up the Linky platform."""
vol.Required(CONF_PASSWORD): cv.string, pass
vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int,
}
async def async_setup_entry(
hass: HomeAssistantType, entry: ConfigEntry, async_add_entities
) -> None:
"""Add Linky entries."""
account = LinkyAccount(
entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], entry.data[CONF_TIMEOUT]
) )
await hass.async_add_executor_job(account.update_linky_data)
def setup_platform(hass, config, add_entities, discovery_info=None): sensors = [
"""Configure the platform and add the Linky sensor.""" LinkySensor("Linky yesterday", account, DAILY, INDEX_LAST),
username = config[CONF_USERNAME] LinkySensor("Linky current month", account, MONTHLY, INDEX_CURRENT),
password = config[CONF_PASSWORD] LinkySensor("Linky last month", account, MONTHLY, INDEX_LAST),
timeout = config[CONF_TIMEOUT] LinkySensor("Linky current year", account, YEARLY, INDEX_CURRENT),
LinkySensor("Linky last year", account, YEARLY, INDEX_LAST),
]
account = LinkyAccount(hass, add_entities, username, password, timeout) async_track_time_interval(hass, account.update_linky_data, SCAN_INTERVAL)
add_entities(account.sensors, True)
async_add_entities(sensors, True)
class LinkyAccount: class LinkyAccount:
"""Representation of a Linky account.""" """Representation of a Linky account."""
def __init__(self, hass, add_entities, username, password, timeout): def __init__(self, username, password, timeout):
"""Initialise the Linky account.""" """Initialise the Linky account."""
self._username = username self._username = username
self.__password = password self._password = password
self._timeout = timeout self._timeout = timeout
self._data = None self._data = None
self.sensors = []
self.update_linky_data(dt_util.utcnow()) def update_linky_data(self, event_time=None):
self.sensors.append(LinkySensor("Linky yesterday", self, DAILY, INDEX_LAST))
self.sensors.append(
LinkySensor("Linky current month", self, MONTHLY, INDEX_CURRENT)
)
self.sensors.append(LinkySensor("Linky last month", self, MONTHLY, INDEX_LAST))
self.sensors.append(
LinkySensor("Linky current year", self, YEARLY, INDEX_CURRENT)
)
self.sensors.append(LinkySensor("Linky last year", self, YEARLY, INDEX_LAST))
track_time_interval(hass, self.update_linky_data, SCAN_INTERVAL)
def update_linky_data(self, event_time):
"""Fetch new state data for the sensor.""" """Fetch new state data for the sensor."""
client = LinkyClient(self._username, self.__password, None, self._timeout) client = LinkyClient(self._username, self._password, None, self._timeout)
try: try:
client.login() client.login()
client.fetch_data() client.fetch_data()
self._data = client.get_data() self._data = client.get_data()
_LOGGER.debug(json.dumps(self._data, indent=2)) _LOGGER.debug(json.dumps(self._data, indent=2))
except PyLinkyError as exp: except PyLinkyException as exp:
_LOGGER.error(exp) _LOGGER.error(exp)
finally: finally:
client.close_session() client.close_session()
@ -115,12 +108,12 @@ class LinkySensor(Entity):
def __init__(self, name, account: LinkyAccount, scale, when): def __init__(self, name, account: LinkyAccount, scale, when):
"""Initialize the sensor.""" """Initialize the sensor."""
self._name = name self._name = name
self.__account = account self._account = account
self._scale = scale self._scale = scale
self.__when = when self._when = when
self._username = account.username self._username = account.username
self.__time = None self._time = None
self.__consumption = None self._consumption = None
@property @property
def name(self): def name(self):
@ -130,7 +123,7 @@ class LinkySensor(Entity):
@property @property
def state(self): def state(self):
"""Return the state of the sensor.""" """Return the state of the sensor."""
return self.__consumption return self._consumption
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
@ -147,18 +140,18 @@ class LinkySensor(Entity):
"""Return the state attributes of the sensor.""" """Return the state attributes of the sensor."""
return { return {
ATTR_ATTRIBUTION: ATTRIBUTION, ATTR_ATTRIBUTION: ATTRIBUTION,
"time": self.__time, "time": self._time,
CONF_USERNAME: self._username, CONF_USERNAME: self._username,
} }
def update(self): async def async_update(self) -> None:
"""Retrieve the new data for the sensor.""" """Retrieve the new data for the sensor."""
data = self.__account.data[self._scale][self.__when] data = self._account.data[self._scale][self._when]
self.__consumption = data[CONSUMPTION] self._consumption = data[CONSUMPTION]
self.__time = data[TIME] self._time = data[TIME]
if self._scale is not YEARLY: if self._scale is not YEARLY:
year_index = INDEX_CURRENT year_index = INDEX_CURRENT
if self.__time.endswith("Dec"): if self._time.endswith("Dec"):
year_index = INDEX_LAST year_index = INDEX_LAST
self.__time += " " + self.__account.data[YEARLY][year_index][TIME] self._time += " " + self._account.data[YEARLY][year_index][TIME]

View File

@ -0,0 +1,25 @@
{
"config": {
"title": "Linky",
"step": {
"user": {
"title": "Linky",
"description": "Enter your credentials",
"data": {
"username": "Email",
"password": "Password"
}
}
},
"error":{
"username_exists": "Account already configured",
"access": "Could not access to Enedis.fr, please check your internet connection",
"enedis": "Enedis.fr answered with an error: please retry later (usually not between 11PM and 2AM)",
"wrong_login": "Login error: please check your email & password",
"unknown": "Unknown error: please retry later (usually not between 11PM and 2AM)"
},
"abort":{
"username_exists": "Account already configured"
}
}
}

View File

@ -30,6 +30,7 @@ FLOWS = [
"iqvia", "iqvia",
"life360", "life360",
"lifx", "lifx",
"linky",
"locative", "locative",
"logi_circle", "logi_circle",
"luftdaten", "luftdaten",

View File

@ -1259,7 +1259,7 @@ pylgnetcast-homeassistant==0.2.0.dev0
pylgtv==0.1.9 pylgtv==0.1.9
# homeassistant.components.linky # homeassistant.components.linky
pylinky==0.3.3 pylinky==0.4.0
# homeassistant.components.litejet # homeassistant.components.litejet
pylitejet==0.1 pylitejet==0.1

View File

@ -291,6 +291,9 @@ pyhomematic==0.1.60
# homeassistant.components.iqvia # homeassistant.components.iqvia
pyiqvia==0.2.1 pyiqvia==0.2.1
# homeassistant.components.linky
pylinky==0.4.0
# homeassistant.components.litejet # homeassistant.components.litejet
pylitejet==0.1 pylitejet==0.1

View File

@ -120,6 +120,7 @@ TEST_REQUIREMENTS = (
"pyheos", "pyheos",
"pyhomematic", "pyhomematic",
"pyiqvia", "pyiqvia",
"pylinky",
"pylitejet", "pylitejet",
"pymfy", "pymfy",
"pymonoprice", "pymonoprice",

View File

@ -0,0 +1 @@
"""Tests for the Linky component."""

View File

@ -0,0 +1,167 @@
"""Tests for the Linky config flow."""
import pytest
from unittest.mock import patch
from pylinky.exceptions import (
PyLinkyAccessException,
PyLinkyEnedisException,
PyLinkyException,
PyLinkyWrongLoginException,
)
from homeassistant import data_entry_flow
from homeassistant.components.linky import config_flow
from homeassistant.components.linky.const import DOMAIN, DEFAULT_TIMEOUT
from homeassistant.const import CONF_PASSWORD, CONF_TIMEOUT, CONF_USERNAME
from tests.common import MockConfigEntry
USERNAME = "username"
PASSWORD = "password"
TIMEOUT = 20
@pytest.fixture(name="login")
def mock_controller_login():
"""Mock a successful login."""
with patch("pylinky.client.LinkyClient.login", return_value=True):
yield
@pytest.fixture(name="fetch_data")
def mock_controller_fetch_data():
"""Mock a successful get data."""
with patch("pylinky.client.LinkyClient.fetch_data", return_value={}):
yield
@pytest.fixture(name="close_session")
def mock_controller_close_session():
"""Mock a successful closing session."""
with patch("pylinky.client.LinkyClient.close_session", return_value=None):
yield
def init_config_flow(hass):
"""Init a configuration flow."""
flow = config_flow.LinkyFlowHandler()
flow.hass = hass
return flow
async def test_user(hass, login, fetch_data, close_session):
"""Test user config."""
flow = init_config_flow(hass)
result = await flow.async_step_user()
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "user"
# test with all provided
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT
async def test_import(hass, login, fetch_data, close_session):
"""Test import step."""
flow = init_config_flow(hass)
# import with username and password
result = await flow.async_step_import(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_TIMEOUT] == DEFAULT_TIMEOUT
# import with all
result = await flow.async_step_import(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD, CONF_TIMEOUT: TIMEOUT}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == USERNAME
assert result["data"][CONF_USERNAME] == USERNAME
assert result["data"][CONF_PASSWORD] == PASSWORD
assert result["data"][CONF_TIMEOUT] == TIMEOUT
async def test_abort_if_already_setup(hass, login, fetch_data, close_session):
"""Test we abort if Linky is already setup."""
flow = init_config_flow(hass)
MockConfigEntry(
domain=DOMAIN, data={CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
).add_to_hass(hass)
# Should fail, same USERNAME (import)
result = await flow.async_step_import(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "username_exists"
# Should fail, same USERNAME (flow)
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_USERNAME: "username_exists"}
async def test_abort_on_login_failed(hass, close_session):
"""Test when we have errors during login."""
flow = init_config_flow(hass)
with patch(
"pylinky.client.LinkyClient.login", side_effect=PyLinkyAccessException()
):
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "access"}
with patch(
"pylinky.client.LinkyClient.login", side_effect=PyLinkyWrongLoginException()
):
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "wrong_login"}
async def test_abort_on_fetch_failed(hass, login, close_session):
"""Test when we have errors during fetch."""
flow = init_config_flow(hass)
with patch(
"pylinky.client.LinkyClient.fetch_data", side_effect=PyLinkyAccessException()
):
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "access"}
with patch(
"pylinky.client.LinkyClient.fetch_data", side_effect=PyLinkyEnedisException()
):
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "enedis"}
with patch("pylinky.client.LinkyClient.fetch_data", side_effect=PyLinkyException()):
result = await flow.async_step_user(
{CONF_USERNAME: USERNAME, CONF_PASSWORD: PASSWORD}
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {"base": "unknown"}