diff --git a/CODEOWNERS b/CODEOWNERS index e8fadc9ed44..edc15f00899 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/homeassistant/components/flume/.translations/en.json b/homeassistant/components/flume/.translations/en.json new file mode 100644 index 00000000000..ed557133a9b --- /dev/null +++ b/homeassistant/components/flume/.translations/en.json @@ -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" + } +} diff --git a/homeassistant/components/flume/__init__.py b/homeassistant/components/flume/__init__.py index ab626e1f156..2c18864194e 100644 --- a/homeassistant/components/flume/__init__.py +++ b/homeassistant/components/flume/__init__.py @@ -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 diff --git a/homeassistant/components/flume/config_flow.py b/homeassistant/components/flume/config_flow.py new file mode 100644 index 00000000000..3232245a4a9 --- /dev/null +++ b/homeassistant/components/flume/config_flow.py @@ -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.""" diff --git a/homeassistant/components/flume/const.py b/homeassistant/components/flume/const.py new file mode 100644 index 00000000000..17bbb60edb0 --- /dev/null +++ b/homeassistant/components/flume/const.py @@ -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" diff --git a/homeassistant/components/flume/manifest.json b/homeassistant/components/flume/manifest.json index 71d0992e9fd..b0bf08cd8fa 100644 --- a/homeassistant/components/flume/manifest.json +++ b/homeassistant/components/flume/manifest.json @@ -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 } diff --git a/homeassistant/components/flume/sensor.py b/homeassistant/components/flume/sensor.py index 2694842134f..ff320f41dd1 100644 --- a/homeassistant/components/flume/sensor.py +++ b/homeassistant/components/flume/sensor.py @@ -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() diff --git a/homeassistant/components/flume/strings.json b/homeassistant/components/flume/strings.json new file mode 100644 index 00000000000..ed557133a9b --- /dev/null +++ b/homeassistant/components/flume/strings.json @@ -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" + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 4d4509d7443..bbc6c7d2cfb 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -31,6 +31,7 @@ FLOWS = [ "elkm1", "emulated_roku", "esphome", + "flume", "flunearyou", "freebox", "garmin_connect", diff --git a/requirements_all.txt b/requirements_all.txt index 4ae2a070efc..5325986cc7e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c51f9ada7ae..f5a436a1166 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 diff --git a/tests/components/flume/__init__.py b/tests/components/flume/__init__.py new file mode 100644 index 00000000000..9e1bf64c74d --- /dev/null +++ b/tests/components/flume/__init__.py @@ -0,0 +1 @@ +"""Tests for the flume integration.""" diff --git a/tests/components/flume/test_config_flow.py b/tests/components/flume/test_config_flow.py new file mode 100644 index 00000000000..6ce3391c2c6 --- /dev/null +++ b/tests/components/flume/test_config_flow.py @@ -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"}