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/fitbit/* @robbiet480
homeassistant/components/fixer/* @fabaff homeassistant/components/fixer/* @fabaff
homeassistant/components/flock/* @fabaff homeassistant/components/flock/* @fabaff
homeassistant/components/flume/* @ChrisMandich homeassistant/components/flume/* @ChrisMandich @bdraco
homeassistant/components/flunearyou/* @bachya homeassistant/components/flunearyou/* @bachya
homeassistant/components/fortigate/* @kifeo homeassistant/components/fortigate/* @kifeo
homeassistant/components/fortios/* @kimfrellsen 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", "domain": "flume",
"name": "flume", "name": "flume",
"documentation": "https://www.home-assistant.io/integrations/flume/", "documentation": "https://www.home-assistant.io/integrations/flume/",
"requirements": ["pyflume==0.3.0"], "requirements": [
"codeowners": ["@ChrisMandich"] "pyflume==0.4.0"
],
"dependencies": [],
"codeowners": [
"@ChrisMandich",
"@bdraco"
],
"config_flow": true
} }

View File

@ -2,23 +2,34 @@
from datetime import timedelta from datetime import timedelta
import logging import logging
from pyflume import FlumeData, FlumeDeviceList from pyflume import FlumeData
from requests import Session
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity import Entity 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" _LOGGER = logging.getLogger(__name__)
CONF_CLIENT_ID = "client_id"
CONF_CLIENT_SECRET = "client_secret"
FLUME_TYPE_SENSOR = 2
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=15)
SCAN_INTERVAL = timedelta(minutes=1) SCAN_INTERVAL = timedelta(minutes=1)
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
@ -27,68 +38,77 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_PASSWORD): cv.string,
vol.Required(CONF_CLIENT_ID): cv.string, vol.Required(CONF_CLIENT_ID): cv.string,
vol.Required(CONF_CLIENT_SECRET): 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): def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the Flume sensor.""" """Import the platform into a config entry."""
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 = []
http_session = Session() hass.async_create_task(
hass.config_entries.flow.async_init(
flume_devices = FlumeDeviceList( DOMAIN, context={"source": SOURCE_IMPORT}, data=config
username, )
password,
client_id,
client_secret,
flume_token_file,
http_session=http_session,
) )
for device in flume_devices.device_list:
if device["type"] == FLUME_TYPE_SENSOR:
device_id = device["id"]
device_name = device["location"]["name"]
flume = FlumeData( async def async_setup_entry(hass, config_entry, async_add_entities):
username, """Set up the Flume sensor."""
password,
client_id, flume_domain_data = hass.data[DOMAIN][config_entry.entry_id]
client_secret,
device_id, flume_auth = flume_domain_data[FLUME_AUTH]
time_zone, http_session = flume_domain_data[FLUME_HTTP_SESSION]
SCAN_INTERVAL, flume_devices = flume_domain_data[FLUME_DEVICES]
flume_token_file,
update_on_init=False, config = config_entry.data
http_session=http_session, name = config.get(CONF_NAME, DEFAULT_NAME)
)
flume_entity_list.append( flume_entity_list = []
FlumeSensor(flume, f"{name} {device_name}", device_id) 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: if flume_entity_list:
add_entities(flume_entity_list, True) async_add_entities(flume_entity_list)
class FlumeSensor(Entity): class FlumeSensor(Entity):
"""Representation of the Flume sensor.""" """Representation of the Flume sensor."""
def __init__(self, flume, name, device_id): def __init__(self, flume_device, name, device_id):
"""Initialize the Flume sensor.""" """Initialize the Flume sensor."""
self.flume = flume self._flume_device = flume_device
self._name = name self._name = name
self._device_id = device_id self._device_id = device_id
self._state = None self._undo_track_sensor = None
self._available = False 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 @property
def name(self): def name(self):
@ -116,11 +136,23 @@ class FlumeSensor(Entity):
"""Device unique ID.""" """Device unique ID."""
return self._device_id return self._device_id
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self): def update(self):
"""Get the latest data and updates the states.""" """Get the latest data and updates the states."""
self._available = False _LOGGER.debug("Updating flume sensor: %s", self._name)
self.flume.update() try:
new_value = self.flume.value self._flume_device.update_force()
if new_value is not None: except Exception as ex: # pylint: disable=broad-except
self._available = True if self._available:
self._state = new_value _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", "elkm1",
"emulated_roku", "emulated_roku",
"esphome", "esphome",
"flume",
"flunearyou", "flunearyou",
"freebox", "freebox",
"garmin_connect", "garmin_connect",

View File

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

View File

@ -495,6 +495,9 @@ pyeverlights==0.1.0
# homeassistant.components.fido # homeassistant.components.fido
pyfido==2.1.1 pyfido==2.1.1
# homeassistant.components.flume
pyflume==0.4.0
# homeassistant.components.flunearyou # homeassistant.components.flunearyou
pyflunearyou==1.0.7 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"}