From f96aee2832b8834e48c24ccd54c3d0218614a641 Mon Sep 17 00:00:00 2001
From: Aaron Bach <bachya1208@gmail.com>
Date: Tue, 4 Sep 2018 01:22:44 -0600
Subject: [PATCH] Add config flow for OpenUV (#16159)

* OpenUV config flow in place

* Test folder in place

* Owner-requested comments

* Tests

* More tests

* Owner-requested changes (part 1 of 2)

* Updated requirements

* Owner-requested changes (2 of 2)

* Removed unnecessary import

* Bumping Travis

* Updated requirements

* More requirements

* Updated tests

* Owner-requested changes

* Hound

* Updated docstring
---
 .coveragerc                                   |   8 +-
 .../components/binary_sensor/openuv.py        |  27 ++--
 .../components/openuv/.translations/en.json   |  20 +++
 .../{openuv.py => openuv/__init__.py}         | 128 +++++++++++++-----
 .../components/openuv/config_flow.py          |  73 ++++++++++
 homeassistant/components/openuv/const.py      |   3 +
 homeassistant/components/openuv/strings.json  |  20 +++
 homeassistant/components/sensor/openuv.py     |  34 +++--
 homeassistant/config_entries.py               |   1 +
 requirements_all.txt                          |   2 +-
 requirements_test_all.txt                     |   3 +
 script/gen_requirements_all.py                |   1 +
 tests/components/openuv/__init__.py           |   1 +
 tests/components/openuv/test_config_flow.py   |  93 +++++++++++++
 14 files changed, 348 insertions(+), 66 deletions(-)
 create mode 100644 homeassistant/components/openuv/.translations/en.json
 rename homeassistant/components/{openuv.py => openuv/__init__.py} (57%)
 create mode 100644 homeassistant/components/openuv/config_flow.py
 create mode 100644 homeassistant/components/openuv/const.py
 create mode 100644 homeassistant/components/openuv/strings.json
 create mode 100644 tests/components/openuv/__init__.py
 create mode 100644 tests/components/openuv/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index 39c31e4e40b..bd531e62f72 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -123,7 +123,7 @@ omit =
     homeassistant/components/hangouts/const.py
     homeassistant/components/hangouts/hangouts_bot.py
     homeassistant/components/hangouts/hangups_utils.py
-    homeassistant/components/*/hangouts.py    
+    homeassistant/components/*/hangouts.py
 
     homeassistant/components/hdmi_cec.py
     homeassistant/components/*/hdmi_cec.py
@@ -145,12 +145,12 @@ omit =
 
     homeassistant/components/ihc/*
     homeassistant/components/*/ihc.py
-    
+
     homeassistant/components/insteon/*
     homeassistant/components/*/insteon.py
 
     homeassistant/components/insteon_local.py
-    
+
     homeassistant/components/insteon_plm.py
 
     homeassistant/components/ios.py
@@ -228,7 +228,7 @@ omit =
     homeassistant/components/opencv.py
     homeassistant/components/*/opencv.py
 
-    homeassistant/components/openuv.py
+    homeassistant/components/openuv/__init__.py
     homeassistant/components/*/openuv.py
 
     homeassistant/components/pilight.py
diff --git a/homeassistant/components/binary_sensor/openuv.py b/homeassistant/components/binary_sensor/openuv.py
index 0b299529a46..c7c27d73ee4 100644
--- a/homeassistant/components/binary_sensor/openuv.py
+++ b/homeassistant/components/binary_sensor/openuv.py
@@ -7,12 +7,11 @@ https://home-assistant.io/components/binary_sensor.openuv/
 import logging
 
 from homeassistant.components.binary_sensor import BinarySensorDevice
-from homeassistant.const import CONF_MONITORED_CONDITIONS
 from homeassistant.core import callback
 from homeassistant.helpers.dispatcher import async_dispatcher_connect
 from homeassistant.components.openuv import (
-    BINARY_SENSORS, DATA_PROTECTION_WINDOW, DOMAIN, TOPIC_UPDATE,
-    TYPE_PROTECTION_WINDOW, OpenUvEntity)
+    BINARY_SENSORS, DATA_OPENUV_CLIENT, DATA_PROTECTION_WINDOW, DOMAIN,
+    TOPIC_UPDATE, TYPE_PROTECTION_WINDOW, OpenUvEntity)
 from homeassistant.util.dt import as_local, parse_datetime, utcnow
 
 DEPENDENCIES = ['openuv']
@@ -26,17 +25,20 @@ ATTR_PROTECTION_WINDOW_ENDING_UV = 'end_uv'
 
 async def async_setup_platform(
         hass, config, async_add_entities, discovery_info=None):
-    """Set up the OpenUV binary sensor platform."""
-    if discovery_info is None:
-        return
+    """Set up an OpenUV sensor based on existing config."""
+    pass
 
-    openuv = hass.data[DOMAIN]
+
+async def async_setup_entry(hass, entry, async_add_entities):
+    """Set up an OpenUV sensor based on a config entry."""
+    openuv = hass.data[DOMAIN][DATA_OPENUV_CLIENT][entry.entry_id]
 
     binary_sensors = []
-    for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
+    for sensor_type in openuv.binary_sensor_conditions:
         name, icon = BINARY_SENSORS[sensor_type]
         binary_sensors.append(
-            OpenUvBinarySensor(openuv, sensor_type, name, icon))
+            OpenUvBinarySensor(
+                openuv, sensor_type, name, icon, entry.entry_id))
 
     async_add_entities(binary_sensors, True)
 
@@ -44,14 +46,16 @@ async def async_setup_platform(
 class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice):
     """Define a binary sensor for OpenUV."""
 
-    def __init__(self, openuv, sensor_type, name, icon):
+    def __init__(self, openuv, sensor_type, name, icon, entry_id):
         """Initialize the sensor."""
         super().__init__(openuv)
 
+        self._entry_id = entry_id
         self._icon = icon
         self._latitude = openuv.client.latitude
         self._longitude = openuv.client.longitude
         self._name = name
+        self._dispatch_remove = None
         self._sensor_type = sensor_type
         self._state = None
 
@@ -83,8 +87,9 @@ class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice):
 
     async def async_added_to_hass(self):
         """Register callbacks."""
-        async_dispatcher_connect(
+        self._dispatch_remove = async_dispatcher_connect(
             self.hass, TOPIC_UPDATE, self._update_data)
+        self.async_on_remove(self._dispatch_remove)
 
     async def async_update(self):
         """Update the state."""
diff --git a/homeassistant/components/openuv/.translations/en.json b/homeassistant/components/openuv/.translations/en.json
new file mode 100644
index 00000000000..df0232d01fc
--- /dev/null
+++ b/homeassistant/components/openuv/.translations/en.json
@@ -0,0 +1,20 @@
+{
+    "config": {
+        "error": {
+            "identifier_exists": "Coordinates already registered",
+            "invalid_api_key": "Invalid API key"
+        },
+        "step": {
+            "user": {
+                "data": {
+                    "api_key": "OpenUV API Key",
+                    "elevation": "Elevation",
+                    "latitude": "Latitude",
+                    "longitude": "Longitude"
+                },
+                "title": "Fill in your information"
+            }
+        },
+        "title": "OpenUV"
+    }
+}
\ No newline at end of file
diff --git a/homeassistant/components/openuv.py b/homeassistant/components/openuv/__init__.py
similarity index 57%
rename from homeassistant/components/openuv.py
rename to homeassistant/components/openuv/__init__.py
index d696f0e5100..bfd90b4a574 100644
--- a/homeassistant/components/openuv.py
+++ b/homeassistant/components/openuv/__init__.py
@@ -1,5 +1,5 @@
 """
-Support for data from openuv.io.
+Support for UV data from openuv.io.
 
 For more details about this component, please refer to the documentation at
 https://home-assistant.io/components/openuv/
@@ -9,21 +9,24 @@ from datetime import timedelta
 
 import voluptuous as vol
 
+from homeassistant.config_entries import SOURCE_IMPORT
 from homeassistant.const import (
     ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION,
     CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS,
     CONF_SCAN_INTERVAL, CONF_SENSORS)
-from homeassistant.helpers import (
-    aiohttp_client, config_validation as cv, discovery)
+from homeassistant.helpers import aiohttp_client, config_validation as cv
 from homeassistant.helpers.dispatcher import async_dispatcher_send
 from homeassistant.helpers.entity import Entity
 from homeassistant.helpers.event import async_track_time_interval
 
-REQUIREMENTS = ['pyopenuv==1.0.1']
-_LOGGER = logging.getLogger(__name__)
+from .config_flow import configured_instances
+from .const import DOMAIN
 
-DOMAIN = 'openuv'
+REQUIREMENTS = ['pyopenuv==1.0.4']
+_LOGGER = logging.getLogger(__name__)
 
+DATA_OPENUV_CLIENT = 'data_client'
+DATA_OPENUV_LISTENER = 'data_listener'
 DATA_PROTECTION_WINDOW = 'protection_window'
 DATA_UV = 'uv'
 
@@ -82,39 +85,77 @@ SENSOR_SCHEMA = vol.Schema({
 })
 
 CONFIG_SCHEMA = vol.Schema({
-    DOMAIN: vol.Schema({
-        vol.Required(CONF_API_KEY): cv.string,
-        vol.Optional(CONF_ELEVATION): float,
-        vol.Optional(CONF_LATITUDE): cv.latitude,
-        vol.Optional(CONF_LONGITUDE): cv.longitude,
-        vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA,
-        vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
-        vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
-            cv.time_period,
-    })
+    DOMAIN:
+        vol.Schema({
+            vol.Required(CONF_API_KEY): cv.string,
+            vol.Optional(CONF_ELEVATION): float,
+            vol.Optional(CONF_LATITUDE): cv.latitude,
+            vol.Optional(CONF_LONGITUDE): cv.longitude,
+            vol.Optional(CONF_BINARY_SENSORS, default={}):
+                BINARY_SENSOR_SCHEMA,
+            vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA,
+            vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL):
+                cv.time_period,
+        })
 }, extra=vol.ALLOW_EXTRA)
 
 
 async def async_setup(hass, config):
     """Set up the OpenUV component."""
-    from pyopenuv import Client
-    from pyopenuv.errors import OpenUvError
+    hass.data[DOMAIN] = {}
+    hass.data[DOMAIN][DATA_OPENUV_CLIENT] = {}
+    hass.data[DOMAIN][DATA_OPENUV_LISTENER] = {}
+
+    if DOMAIN not in config:
+        return True
 
     conf = config[DOMAIN]
-    api_key = conf[CONF_API_KEY]
-    elevation = conf.get(CONF_ELEVATION, hass.config.elevation)
     latitude = conf.get(CONF_LATITUDE, hass.config.latitude)
     longitude = conf.get(CONF_LONGITUDE, hass.config.longitude)
+    elevation = conf.get(CONF_ELEVATION, hass.config.elevation)
+
+    identifier = '{0}, {1}'.format(latitude, longitude)
+
+    if identifier not in configured_instances(hass):
+        hass.async_add_job(
+            hass.config_entries.flow.async_init(
+                DOMAIN,
+                context={'source': SOURCE_IMPORT},
+                data={
+                    CONF_API_KEY: conf[CONF_API_KEY],
+                    CONF_LATITUDE: latitude,
+                    CONF_LONGITUDE: longitude,
+                    CONF_ELEVATION: elevation,
+                    CONF_BINARY_SENSORS: conf[CONF_BINARY_SENSORS],
+                    CONF_SENSORS: conf[CONF_SENSORS],
+                }))
+
+    hass.data[DOMAIN][CONF_SCAN_INTERVAL] = conf[CONF_SCAN_INTERVAL]
+
+    return True
+
+
+async def async_setup_entry(hass, config_entry):
+    """Set up OpenUV as config entry."""
+    from pyopenuv import Client
+    from pyopenuv.errors import OpenUvError
 
     try:
         websession = aiohttp_client.async_get_clientsession(hass)
         openuv = OpenUV(
             Client(
-                api_key, latitude, longitude, websession, altitude=elevation),
-            conf[CONF_BINARY_SENSORS][CONF_MONITORED_CONDITIONS] +
-            conf[CONF_SENSORS][CONF_MONITORED_CONDITIONS])
+                config_entry.data[CONF_API_KEY],
+                config_entry.data.get(CONF_LATITUDE, hass.config.latitude),
+                config_entry.data.get(CONF_LONGITUDE, hass.config.longitude),
+                websession,
+                altitude=config_entry.data.get(
+                    CONF_ELEVATION, hass.config.elevation)),
+            config_entry.data.get(CONF_BINARY_SENSORS, {}).get(
+                CONF_MONITORED_CONDITIONS, list(BINARY_SENSORS)),
+            config_entry.data.get(CONF_SENSORS, {}).get(
+                CONF_MONITORED_CONDITIONS, list(SENSORS)))
         await openuv.async_update()
-        hass.data[DOMAIN] = openuv
+        hass.data[DOMAIN][DATA_OPENUV_CLIENT][config_entry.entry_id] = openuv
     except OpenUvError as err:
         _LOGGER.error('An error occurred: %s', str(err))
         hass.components.persistent_notification.create(
@@ -125,13 +166,9 @@ async def async_setup(hass, config):
             notification_id=NOTIFICATION_ID)
         return False
 
-    for component, schema in [
-            ('binary_sensor', conf[CONF_BINARY_SENSORS]),
-            ('sensor', conf[CONF_SENSORS]),
-    ]:
-        hass.async_create_task(
-            discovery.async_load_platform(
-                hass, component, DOMAIN, schema, config))
+    for component in ('binary_sensor', 'sensor'):
+        hass.async_create_task(hass.config_entries.async_forward_entry_setup(
+            config_entry, component))
 
     async def refresh_sensors(event_time):
         """Refresh OpenUV data."""
@@ -139,7 +176,25 @@ async def async_setup(hass, config):
         await openuv.async_update()
         async_dispatcher_send(hass, TOPIC_UPDATE)
 
-    async_track_time_interval(hass, refresh_sensors, conf[CONF_SCAN_INTERVAL])
+    hass.data[DOMAIN][DATA_OPENUV_LISTENER][
+        config_entry.entry_id] = async_track_time_interval(
+            hass, refresh_sensors,
+            hass.data[DOMAIN][CONF_SCAN_INTERVAL])
+
+    return True
+
+
+async def async_unload_entry(hass, config_entry):
+    """Unload an OpenUV config entry."""
+    for component in ('binary_sensor', 'sensor'):
+        await hass.config_entries.async_forward_entry_unload(
+            config_entry, component)
+
+    hass.data[DOMAIN][DATA_OPENUV_CLIENT].pop(config_entry.entry_id)
+
+    remove_listener = hass.data[DOMAIN][DATA_OPENUV_LISTENER].pop(
+        config_entry.entry_id)
+    remove_listener()
 
     return True
 
@@ -147,19 +202,20 @@ async def async_setup(hass, config):
 class OpenUV:
     """Define a generic OpenUV object."""
 
-    def __init__(self, client, monitored_conditions):
+    def __init__(self, client, binary_sensor_conditions, sensor_conditions):
         """Initialize."""
-        self._monitored_conditions = monitored_conditions
+        self.binary_sensor_conditions = binary_sensor_conditions
         self.client = client
         self.data = {}
+        self.sensor_conditions = sensor_conditions
 
     async def async_update(self):
         """Update sensor/binary sensor data."""
-        if TYPE_PROTECTION_WINDOW in self._monitored_conditions:
+        if TYPE_PROTECTION_WINDOW in self.binary_sensor_conditions:
             data = await self.client.uv_protection_window()
             self.data[DATA_PROTECTION_WINDOW] = data
 
-        if any(c in self._monitored_conditions for c in SENSORS):
+        if any(c in self.sensor_conditions for c in SENSORS):
             data = await self.client.uv_index()
             self.data[DATA_UV] = data
 
diff --git a/homeassistant/components/openuv/config_flow.py b/homeassistant/components/openuv/config_flow.py
new file mode 100644
index 00000000000..55ee566268e
--- /dev/null
+++ b/homeassistant/components/openuv/config_flow.py
@@ -0,0 +1,73 @@
+"""Config flow to configure the OpenUV component."""
+
+from collections import OrderedDict
+
+import voluptuous as vol
+
+from homeassistant import config_entries, data_entry_flow
+from homeassistant.core import callback
+from homeassistant.const import (
+    CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE)
+from homeassistant.helpers import aiohttp_client, config_validation as cv
+
+from .const import DOMAIN
+
+
+@callback
+def configured_instances(hass):
+    """Return a set of configured OpenUV instances."""
+    return set(
+        '{0}, {1}'.format(
+            entry.data[CONF_LATITUDE], entry.data[CONF_LONGITUDE])
+        for entry in hass.config_entries.async_entries(DOMAIN))
+
+
+@config_entries.HANDLERS.register(DOMAIN)
+class OpenUvFlowHandler(data_entry_flow.FlowHandler):
+    """Handle an OpenUV config flow."""
+
+    VERSION = 1
+
+    def __init__(self):
+        """Initialize the config flow."""
+        pass
+
+    async def async_step_import(self, import_config):
+        """Import a config entry from configuration.yaml."""
+        return await self.async_step_user(import_config)
+
+    async def async_step_user(self, user_input=None):
+        """Handle the start of the config flow."""
+        from pyopenuv.util import validate_api_key
+
+        errors = {}
+
+        if user_input is not None:
+            identifier = '{0}, {1}'.format(
+                user_input.get(CONF_LATITUDE, self.hass.config.latitude),
+                user_input.get(CONF_LONGITUDE, self.hass.config.longitude))
+
+            if identifier in configured_instances(self.hass):
+                errors['base'] = 'identifier_exists'
+            else:
+                websession = aiohttp_client.async_get_clientsession(self.hass)
+                api_key_validation = await validate_api_key(
+                    user_input[CONF_API_KEY], websession)
+                if api_key_validation:
+                    return self.async_create_entry(
+                        title=identifier,
+                        data=user_input,
+                    )
+                errors['base'] = 'invalid_api_key'
+
+        data_schema = OrderedDict()
+        data_schema[vol.Required(CONF_API_KEY)] = str
+        data_schema[vol.Optional(CONF_LATITUDE)] = cv.latitude
+        data_schema[vol.Optional(CONF_LONGITUDE)] = cv.longitude
+        data_schema[vol.Optional(CONF_ELEVATION)] = vol.Coerce(float)
+
+        return self.async_show_form(
+            step_id='user',
+            data_schema=vol.Schema(data_schema),
+            errors=errors,
+        )
diff --git a/homeassistant/components/openuv/const.py b/homeassistant/components/openuv/const.py
new file mode 100644
index 00000000000..1aa3d2abcaa
--- /dev/null
+++ b/homeassistant/components/openuv/const.py
@@ -0,0 +1,3 @@
+"""Define constants for the OpenUV component."""
+
+DOMAIN = 'openuv'
diff --git a/homeassistant/components/openuv/strings.json b/homeassistant/components/openuv/strings.json
new file mode 100644
index 00000000000..9c5af45619e
--- /dev/null
+++ b/homeassistant/components/openuv/strings.json
@@ -0,0 +1,20 @@
+{
+  "config": {
+    "title": "OpenUV",
+    "step": {
+      "user": {
+        "title": "Fill in your information",
+        "data": {
+          "api_key": "OpenUV API Key",
+          "elevation": "Elevation",
+          "latitude": "Latitude",
+          "longitude": "Longitude"
+        }
+      }
+    },
+    "error": {
+      "identifier_exists": "Coordinates already registered",
+      "invalid_api_key": "Invalid API key"
+    }
+  }
+}
diff --git a/homeassistant/components/sensor/openuv.py b/homeassistant/components/sensor/openuv.py
index aaa04590b3f..22712aa306b 100644
--- a/homeassistant/components/sensor/openuv.py
+++ b/homeassistant/components/sensor/openuv.py
@@ -6,13 +6,12 @@ https://home-assistant.io/components/sensor.openuv/
 """
 import logging
 
-from homeassistant.const import CONF_MONITORED_CONDITIONS
 from homeassistant.core import callback
 from homeassistant.helpers.dispatcher import async_dispatcher_connect
 from homeassistant.components.openuv import (
-    DATA_UV, DOMAIN, SENSORS, TOPIC_UPDATE, TYPE_CURRENT_OZONE_LEVEL,
-    TYPE_CURRENT_UV_INDEX, TYPE_CURRENT_UV_LEVEL, TYPE_MAX_UV_INDEX,
-    TYPE_SAFE_EXPOSURE_TIME_1, TYPE_SAFE_EXPOSURE_TIME_2,
+    DATA_OPENUV_CLIENT, DATA_UV, DOMAIN, SENSORS, TOPIC_UPDATE,
+    TYPE_CURRENT_OZONE_LEVEL, TYPE_CURRENT_UV_INDEX, TYPE_CURRENT_UV_LEVEL,
+    TYPE_MAX_UV_INDEX, TYPE_SAFE_EXPOSURE_TIME_1, TYPE_SAFE_EXPOSURE_TIME_2,
     TYPE_SAFE_EXPOSURE_TIME_3, TYPE_SAFE_EXPOSURE_TIME_4,
     TYPE_SAFE_EXPOSURE_TIME_5, TYPE_SAFE_EXPOSURE_TIME_6, OpenUvEntity)
 from homeassistant.util.dt import as_local, parse_datetime
@@ -40,16 +39,20 @@ UV_LEVEL_LOW = "Low"
 
 async def async_setup_platform(
         hass, config, async_add_entities, discovery_info=None):
-    """Set up the OpenUV binary sensor platform."""
-    if discovery_info is None:
-        return
+    """Set up an OpenUV sensor based on existing config."""
+    pass
 
-    openuv = hass.data[DOMAIN]
+
+async def async_setup_entry(hass, entry, async_add_entities):
+    """Set up a Nest sensor based on a config entry."""
+    openuv = hass.data[DOMAIN][DATA_OPENUV_CLIENT][entry.entry_id]
 
     sensors = []
-    for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]:
+    for sensor_type in openuv.sensor_conditions:
         name, icon, unit = SENSORS[sensor_type]
-        sensors.append(OpenUvSensor(openuv, sensor_type, name, icon, unit))
+        sensors.append(
+            OpenUvSensor(
+                openuv, sensor_type, name, icon, unit, entry.entry_id))
 
     async_add_entities(sensors, True)
 
@@ -57,10 +60,12 @@ async def async_setup_platform(
 class OpenUvSensor(OpenUvEntity):
     """Define a binary sensor for OpenUV."""
 
-    def __init__(self, openuv, sensor_type, name, icon, unit):
+    def __init__(self, openuv, sensor_type, name, icon, unit, entry_id):
         """Initialize the sensor."""
         super().__init__(openuv)
 
+        self._dispatch_remove = None
+        self._entry_id = entry_id
         self._icon = icon
         self._latitude = openuv.client.latitude
         self._longitude = openuv.client.longitude
@@ -102,7 +107,9 @@ class OpenUvSensor(OpenUvEntity):
 
     async def async_added_to_hass(self):
         """Register callbacks."""
-        async_dispatcher_connect(self.hass, TOPIC_UPDATE, self._update_data)
+        self._dispatch_remove = async_dispatcher_connect(
+            self.hass, TOPIC_UPDATE, self._update_data)
+        self.async_on_remove(self._dispatch_remove)
 
     async def async_update(self):
         """Update the state."""
@@ -125,8 +132,7 @@ class OpenUvSensor(OpenUvEntity):
         elif self._sensor_type == TYPE_MAX_UV_INDEX:
             self._state = data['uv_max']
             self._attrs.update({
-                ATTR_MAX_UV_TIME: as_local(
-                    parse_datetime(data['uv_max_time']))
+                ATTR_MAX_UV_TIME: as_local(parse_datetime(data['uv_max_time']))
             })
         elif self._sensor_type in (TYPE_SAFE_EXPOSURE_TIME_1,
                                    TYPE_SAFE_EXPOSURE_TIME_2,
diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py
index 6eae9e13030..15932f2c3f8 100644
--- a/homeassistant/config_entries.py
+++ b/homeassistant/config_entries.py
@@ -141,6 +141,7 @@ FLOWS = [
     'homematicip_cloud',
     'hue',
     'nest',
+    'openuv',
     'sonos',
     'zone',
 ]
diff --git a/requirements_all.txt b/requirements_all.txt
index d8d73cc36a1..b155612350b 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -990,7 +990,7 @@ pynut2==2.1.2
 pynx584==0.4
 
 # homeassistant.components.openuv
-pyopenuv==1.0.1
+pyopenuv==1.0.4
 
 # homeassistant.components.iota
 pyota==2.0.5
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 0ee02e0d109..b9e44445114 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -154,6 +154,9 @@ pymonoprice==0.3
 # homeassistant.components.binary_sensor.nx584
 pynx584==0.4
 
+# homeassistant.components.openuv
+pyopenuv==1.0.4
+
 # homeassistant.auth.mfa_modules.totp
 # homeassistant.components.sensor.otp
 pyotp==2.2.6
diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py
index 4b694ec7ec0..fc8e67b1ab6 100755
--- a/script/gen_requirements_all.py
+++ b/script/gen_requirements_all.py
@@ -78,6 +78,7 @@ TEST_REQUIREMENTS = (
     'pylitejet',
     'pymonoprice',
     'pynx584',
+    'pyopenuv',
     'pyotp',
     'pyqwikswitch',
     'PyRMVtransport',
diff --git a/tests/components/openuv/__init__.py b/tests/components/openuv/__init__.py
new file mode 100644
index 00000000000..0e3595b1e51
--- /dev/null
+++ b/tests/components/openuv/__init__.py
@@ -0,0 +1 @@
+"""Define tests for the OpenUV component."""
diff --git a/tests/components/openuv/test_config_flow.py b/tests/components/openuv/test_config_flow.py
new file mode 100644
index 00000000000..0e50bddabde
--- /dev/null
+++ b/tests/components/openuv/test_config_flow.py
@@ -0,0 +1,93 @@
+"""Define tests for the OpenUV config flow."""
+from unittest.mock import patch
+
+from homeassistant import data_entry_flow
+from homeassistant.components.openuv import DOMAIN, config_flow
+from homeassistant.const import (
+    CONF_API_KEY, CONF_ELEVATION, CONF_LATITUDE, CONF_LONGITUDE)
+
+from tests.common import MockConfigEntry, mock_coro
+
+
+async def test_duplicate_error(hass):
+    """Test that errors are shown when duplicates are added."""
+    conf = {
+        CONF_API_KEY: '12345abcde',
+        CONF_ELEVATION: 59.1234,
+        CONF_LATITUDE: 39.128712,
+        CONF_LONGITUDE: -104.9812612,
+    }
+
+    MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass)
+    flow = config_flow.OpenUvFlowHandler()
+    flow.hass = hass
+
+    result = await flow.async_step_user(user_input=conf)
+    assert result['errors'] == {'base': 'identifier_exists'}
+
+
+@patch('pyopenuv.util.validate_api_key', return_value=mock_coro(False))
+async def test_invalid_api_key(hass):
+    """Test that an invalid API key throws an error."""
+    conf = {
+        CONF_API_KEY: '12345abcde',
+        CONF_ELEVATION: 59.1234,
+        CONF_LATITUDE: 39.128712,
+        CONF_LONGITUDE: -104.9812612,
+    }
+
+    flow = config_flow.OpenUvFlowHandler()
+    flow.hass = hass
+
+    result = await flow.async_step_user(user_input=conf)
+    assert result['errors'] == {'base': 'invalid_api_key'}
+
+
+async def test_show_form(hass):
+    """Test that the form is served with no input."""
+    flow = config_flow.OpenUvFlowHandler()
+    flow.hass = hass
+
+    result = await flow.async_step_user(user_input=None)
+
+    assert result['type'] == data_entry_flow.RESULT_TYPE_FORM
+    assert result['step_id'] == 'user'
+
+
+@patch('pyopenuv.util.validate_api_key', return_value=mock_coro(True))
+async def test_step_import(hass):
+    """Test that the import step works."""
+    conf = {
+        CONF_API_KEY: '12345abcde',
+    }
+
+    flow = config_flow.OpenUvFlowHandler()
+    flow.hass = hass
+
+    result = await flow.async_step_import(import_config=conf)
+
+    assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result['title'] == '{0}, {1}'.format(
+        hass.config.latitude, hass.config.longitude)
+    assert result['data'] == conf
+
+
+@patch('pyopenuv.util.validate_api_key', return_value=mock_coro(True))
+async def test_step_user(hass):
+    """Test that the user step works."""
+    conf = {
+        CONF_API_KEY: '12345abcde',
+        CONF_ELEVATION: 59.1234,
+        CONF_LATITUDE: 39.128712,
+        CONF_LONGITUDE: -104.9812612,
+    }
+
+    flow = config_flow.OpenUvFlowHandler()
+    flow.hass = hass
+
+    result = await flow.async_step_user(user_input=conf)
+
+    assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result['title'] == '{0}, {1}'.format(
+        conf[CONF_LATITUDE], conf[CONF_LONGITUDE])
+    assert result['data'] == conf
-- 
GitLab