Add a config flow for flume (#33419)

* Add a config flow for flume

* Sensors no longer block Home Assistant startup
since the flume api can take > 60s to respond on
the first poll

* Update to 0.4.0 to resolve the blocking startup issue

* Missed conversion to FlumeAuth

* FlumeAuth can do i/o if the token is expired, wrap it

* workaround async_add_entities updating disabled entities

* Fix conflict
This commit is contained in:
J. Nick Koston 2020-04-08 16:29:59 -05:00 committed by GitHub
parent fb8f8133a0
commit ac9429988b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 527 additions and 61 deletions

View file

@ -121,7 +121,7 @@ homeassistant/components/filter/* @dgomes
homeassistant/components/fitbit/* @robbiet480
homeassistant/components/fixer/* @fabaff
homeassistant/components/flock/* @fabaff
homeassistant/components/flume/* @ChrisMandich
homeassistant/components/flume/* @ChrisMandich @bdraco
homeassistant/components/flunearyou/* @bachya
homeassistant/components/fortigate/* @kifeo
homeassistant/components/fortios/* @kimfrellsen

View file

@ -0,0 +1,25 @@
{
"config" : {
"error" : {
"unknown" : "Unexpected error",
"invalid_auth" : "Invalid authentication",
"cannot_connect" : "Failed to connect, please try again"
},
"step" : {
"user" : {
"description" : "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token",
"title" : "Connect to your Flume Account",
"data" : {
"username" : "Username",
"client_secret" : "Client Secret",
"client_id" : "Client ID",
"password" : "Password"
}
}
},
"abort" : {
"already_configured" : "This account is already configured"
},
"title" : "Flume"
}
}

View file

@ -1 +1,99 @@
"""The Flume component."""
"""The flume integration."""
import asyncio
from functools import partial
import logging
from pyflume import FlumeAuth, FlumeDeviceList
from requests import Session
from requests.exceptions import RequestException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import (
BASE_TOKEN_FILENAME,
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
DOMAIN,
FLUME_AUTH,
FLUME_DEVICES,
FLUME_HTTP_SESSION,
PLATFORMS,
)
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the flume component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up flume from a config entry."""
config = entry.data
username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
client_id = config[CONF_CLIENT_ID]
client_secret = config[CONF_CLIENT_SECRET]
flume_token_full_path = hass.config.path(f"{BASE_TOKEN_FILENAME}-{username}")
http_session = Session()
try:
flume_auth = await hass.async_add_executor_job(
partial(
FlumeAuth,
username,
password,
client_id,
client_secret,
flume_token_file=flume_token_full_path,
http_session=http_session,
)
)
flume_devices = await hass.async_add_executor_job(
partial(FlumeDeviceList, flume_auth, http_session=http_session,)
)
except RequestException:
raise ConfigEntryNotReady
except Exception as ex: # pylint: disable=broad-except
_LOGGER.error("Invalid credentials for flume: %s", ex)
return False
hass.data[DOMAIN][entry.entry_id] = {
FLUME_DEVICES: flume_devices,
FLUME_AUTH: flume_auth,
FLUME_HTTP_SESSION: http_session,
}
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
hass.data[DOMAIN][entry.entry_id][FLUME_HTTP_SESSION].close()
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,104 @@
"""Config flow for flume integration."""
from functools import partial
import logging
from pyflume import FlumeAuth, FlumeDeviceList
from requests.exceptions import RequestException
import voluptuous as vol
from homeassistant import config_entries, core, exceptions
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from .const import BASE_TOKEN_FILENAME, CONF_CLIENT_ID, CONF_CLIENT_SECRET
from .const import DOMAIN # pylint:disable=unused-import
_LOGGER = logging.getLogger(__name__)
# If flume ever implements a login page for oauth
# we can use the oauth2 support built into Home Assistant.
#
# Currently they only implement the token endpoint
#
DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_CLIENT_ID): str,
vol.Required(CONF_CLIENT_SECRET): str,
}
)
async def validate_input(hass: core.HomeAssistant, data):
"""Validate the user input allows us to connect.
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
username = data[CONF_USERNAME]
password = data[CONF_PASSWORD]
client_id = data[CONF_CLIENT_ID]
client_secret = data[CONF_CLIENT_SECRET]
flume_token_full_path = hass.config.path(f"{BASE_TOKEN_FILENAME}-{username}")
try:
flume_auth = await hass.async_add_executor_job(
partial(
FlumeAuth,
username,
password,
client_id,
client_secret,
flume_token_file=flume_token_full_path,
)
)
flume_devices = await hass.async_add_executor_job(FlumeDeviceList, flume_auth)
except RequestException:
raise CannotConnect
except Exception: # pylint: disable=broad-except
raise InvalidAuth
if not flume_devices or not flume_devices.device_list:
raise CannotConnect
# Return info that you want to store in the config entry.
return {"title": username}
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for flume."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
await self.async_set_unique_id(user_input[CONF_USERNAME])
self._abort_if_unique_id_configured()
try:
info = await validate_input(self.hass, user_input)
return self.async_create_entry(title=info["title"], data=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"
return self.async_show_form(
step_id="user", data_schema=DATA_SCHEMA, errors=errors
)
async def async_step_import(self, user_input):
"""Handle import."""
return await self.async_step_user(user_input)
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,24 @@
"""The Flume component."""
DOMAIN = "flume"
PLATFORMS = ["sensor"]
DEFAULT_NAME = "Flume Sensor"
CONF_CLIENT_ID = "client_id"
CONF_CLIENT_SECRET = "client_secret"
FLUME_TYPE_SENSOR = 2
FLUME_AUTH = "flume_auth"
FLUME_HTTP_SESSION = "http_session"
FLUME_DEVICES = "devices"
CONF_TOKEN_FILE = "token_filename"
BASE_TOKEN_FILENAME = "FLUME_TOKEN_FILE"
KEY_DEVICE_TYPE = "type"
KEY_DEVICE_ID = "id"
KEY_DEVICE_LOCATION = "location"
KEY_DEVICE_LOCATION_NAME = "name"

View file

@ -2,6 +2,13 @@
"domain": "flume",
"name": "flume",
"documentation": "https://www.home-assistant.io/integrations/flume/",
"requirements": ["pyflume==0.3.0"],
"codeowners": ["@ChrisMandich"]
"requirements": [
"pyflume==0.4.0"
],
"dependencies": [],
"codeowners": [
"@ChrisMandich",
"@bdraco"
],
"config_flow": true
}

View file

@ -2,23 +2,34 @@
from datetime import timedelta
import logging
from pyflume import FlumeData, FlumeDeviceList
from requests import Session
from pyflume import FlumeData
import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
LOGGER = logging.getLogger(__name__)
from .const import (
CONF_CLIENT_ID,
CONF_CLIENT_SECRET,
DEFAULT_NAME,
DOMAIN,
FLUME_AUTH,
FLUME_DEVICES,
FLUME_HTTP_SESSION,
FLUME_TYPE_SENSOR,
KEY_DEVICE_ID,
KEY_DEVICE_LOCATION,
KEY_DEVICE_LOCATION_NAME,
KEY_DEVICE_TYPE,
)
DEFAULT_NAME = "Flume Sensor"
CONF_CLIENT_ID = "client_id"
CONF_CLIENT_SECRET = "client_secret"
FLUME_TYPE_SENSOR = 2
_LOGGER = logging.getLogger(__name__)
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15)
SCAN_INTERVAL = timedelta(minutes=1)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@ -27,68 +38,77 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): cv.string,
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_NAME): cv.string,
}
)
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Flume sensor."""
username = config[CONF_USERNAME]
password = config[CONF_PASSWORD]
client_id = config[CONF_CLIENT_ID]
client_secret = config[CONF_CLIENT_SECRET]
flume_token_file = hass.config.path("FLUME_TOKEN_FILE")
time_zone = str(hass.config.time_zone)
name = config[CONF_NAME]
flume_entity_list = []
"""Import the platform into a config entry."""
http_session = Session()
flume_devices = FlumeDeviceList(
username,
password,
client_id,
client_secret,
flume_token_file,
http_session=http_session,
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=config
)
)
for device in flume_devices.device_list:
if device["type"] == FLUME_TYPE_SENSOR:
device_id = device["id"]
device_name = device["location"]["name"]
flume = FlumeData(
username,
password,
client_id,
client_secret,
device_id,
time_zone,
SCAN_INTERVAL,
flume_token_file,
update_on_init=False,
http_session=http_session,
)
flume_entity_list.append(
FlumeSensor(flume, f"{name} {device_name}", device_id)
)
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up the Flume sensor."""
flume_domain_data = hass.data[DOMAIN][config_entry.entry_id]
flume_auth = flume_domain_data[FLUME_AUTH]
http_session = flume_domain_data[FLUME_HTTP_SESSION]
flume_devices = flume_domain_data[FLUME_DEVICES]
config = config_entry.data
name = config.get(CONF_NAME, DEFAULT_NAME)
flume_entity_list = []
for device in flume_devices.device_list:
if device[KEY_DEVICE_TYPE] != FLUME_TYPE_SENSOR:
continue
device_id = device[KEY_DEVICE_ID]
device_name = device[KEY_DEVICE_LOCATION][KEY_DEVICE_LOCATION_NAME]
device_friendly_name = f"{name} {device_name}"
flume_device = FlumeData(
flume_auth,
device_id,
SCAN_INTERVAL,
update_on_init=False,
http_session=http_session,
)
flume_entity_list.append(
FlumeSensor(flume_device, device_friendly_name, device_id)
)
if flume_entity_list:
add_entities(flume_entity_list, True)
async_add_entities(flume_entity_list)
class FlumeSensor(Entity):
"""Representation of the Flume sensor."""
def __init__(self, flume, name, device_id):
def __init__(self, flume_device, name, device_id):
"""Initialize the Flume sensor."""
self.flume = flume
self._flume_device = flume_device
self._name = name
self._device_id = device_id
self._state = None
self._undo_track_sensor = None
self._available = False
self._state = None
@property
def device_info(self):
"""Device info for the flume sensor."""
return {
"name": self._name,
"identifiers": {(DOMAIN, self._device_id)},
"manufacturer": "Flume, Inc.",
"model": "Flume Smart Water Monitor",
}
@property
def name(self):
@ -116,11 +136,23 @@ class FlumeSensor(Entity):
"""Device unique ID."""
return self._device_id
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data and updates the states."""
self._available = False
self.flume.update()
new_value = self.flume.value
if new_value is not None:
self._available = True
self._state = new_value
_LOGGER.debug("Updating flume sensor: %s", self._name)
try:
self._flume_device.update_force()
except Exception as ex: # pylint: disable=broad-except
if self._available:
_LOGGER.error("Update of flume sensor %s failed: %s", self._name, ex)
self._available = False
return
_LOGGER.debug("Successful update of flume sensor: %s", self._name)
self._state = self._flume_device.value
self._available = True
async def async_added_to_hass(self):
"""Request an update when added."""
# We do ask for an update with async_add_entities()
# because it will update disabled entities
self.async_schedule_update_ha_state()

View file

@ -0,0 +1,25 @@
{
"config" : {
"error" : {
"unknown" : "Unexpected error",
"invalid_auth" : "Invalid authentication",
"cannot_connect" : "Failed to connect, please try again"
},
"step" : {
"user" : {
"description" : "In order to access the Flume Personal API, you will need to request a 'Client ID' and 'Client Secret' at https://portal.flumetech.com/settings#token",
"title" : "Connect to your Flume Account",
"data" : {
"username" : "Username",
"client_secret" : "Client Secret",
"client_id" : "Client ID",
"password" : "Password"
}
}
},
"abort" : {
"already_configured" : "This account is already configured"
},
"title" : "Flume"
}
}

View file

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

View file

@ -1283,7 +1283,7 @@ pyflexit==0.3
pyflic-homeassistant==0.4.dev0
# homeassistant.components.flume
pyflume==0.3.0
pyflume==0.4.0
# homeassistant.components.flunearyou
pyflunearyou==1.0.7

View file

@ -495,6 +495,9 @@ pyeverlights==0.1.0
# homeassistant.components.fido
pyfido==2.1.1
# homeassistant.components.flume
pyflume==0.4.0
# homeassistant.components.flunearyou
pyflunearyou==1.0.7

View file

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

View file

@ -0,0 +1,146 @@
"""Test the flume config flow."""
from asynctest import MagicMock, patch
import requests.exceptions
from homeassistant import config_entries, setup
from homeassistant.components.flume.const import DOMAIN
def _get_mocked_flume_device_list():
flume_device_list_mock = MagicMock()
type(flume_device_list_mock).device_list = ["mock"]
return flume_device_list_mock
async def test_form(hass):
"""Test we get the form and can setup from user input."""
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"] == {}
mock_flume_device_list = _get_mocked_flume_device_list()
with patch(
"homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
return_value=mock_flume_device_list,
), patch(
"homeassistant.components.flume.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.flume.async_setup_entry", return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"client_id": "client_id",
"client_secret": "client_secret",
},
)
assert result2["type"] == "create_entry"
assert result2["title"] == "test-username"
assert result2["data"] == {
"username": "test-username",
"password": "test-password",
"client_id": "client_id",
"client_secret": "client_secret",
}
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_import(hass):
"""Test we can import the sensor platform config."""
await setup.async_setup_component(hass, "persistent_notification", {})
mock_flume_device_list = _get_mocked_flume_device_list()
with patch(
"homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
return_value=mock_flume_device_list,
), patch(
"homeassistant.components.flume.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.flume.async_setup_entry", return_value=True,
) as mock_setup_entry:
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data={
"username": "test-username",
"password": "test-password",
"client_id": "client_id",
"client_secret": "client_secret",
},
)
assert result["type"] == "create_entry"
assert result["title"] == "test-username"
assert result["data"] == {
"username": "test-username",
"password": "test-password",
"client_id": "client_id",
"client_secret": "client_secret",
}
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_invalid_auth(hass):
"""Test we handle invalid auth."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
side_effect=Exception,
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"client_id": "client_id",
"client_secret": "client_secret",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "invalid_auth"}
async def test_form_cannot_connect(hass):
"""Test we handle cannot connect error."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.flume.config_flow.FlumeAuth", return_value=True,
), patch(
"homeassistant.components.flume.config_flow.FlumeDeviceList",
side_effect=requests.exceptions.ConnectionError(),
):
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
"username": "test-username",
"password": "test-password",
"client_id": "client_id",
"client_secret": "client_secret",
},
)
assert result2["type"] == "form"
assert result2["errors"] == {"base": "cannot_connect"}