From e37bb513209f88fda6593b306bcd235dc298cc29 Mon Sep 17 00:00:00 2001
From: "J.P. Krauss" <jkrauss@asymworks.com>
Date: Wed, 30 Dec 2020 11:25:57 -0800
Subject: [PATCH] Add AirNow Integration (#40091)

---
 .coveragerc                                   |   2 +
 CODEOWNERS                                    |   1 +
 homeassistant/components/airnow/__init__.py   | 161 +++++++++++++++
 .../components/airnow/config_flow.py          | 110 +++++++++++
 homeassistant/components/airnow/const.py      |  21 ++
 homeassistant/components/airnow/manifest.json |  12 ++
 homeassistant/components/airnow/sensor.py     | 118 +++++++++++
 homeassistant/components/airnow/strings.json  |  26 +++
 .../components/airnow/translations/en.json    |  27 +++
 homeassistant/generated/config_flows.py       |   1 +
 requirements_all.txt                          |   3 +
 requirements_test_all.txt                     |   3 +
 tests/components/airnow/__init__.py           |   1 +
 tests/components/airnow/test_config_flow.py   | 185 ++++++++++++++++++
 14 files changed, 671 insertions(+)
 create mode 100644 homeassistant/components/airnow/__init__.py
 create mode 100644 homeassistant/components/airnow/config_flow.py
 create mode 100644 homeassistant/components/airnow/const.py
 create mode 100644 homeassistant/components/airnow/manifest.json
 create mode 100644 homeassistant/components/airnow/sensor.py
 create mode 100644 homeassistant/components/airnow/strings.json
 create mode 100644 homeassistant/components/airnow/translations/en.json
 create mode 100644 tests/components/airnow/__init__.py
 create mode 100644 tests/components/airnow/test_config_flow.py

diff --git a/.coveragerc b/.coveragerc
index 9f2fdc80716..5778d541a68 100644
--- a/.coveragerc
+++ b/.coveragerc
@@ -29,6 +29,8 @@ omit =
     homeassistant/components/agent_dvr/camera.py
     homeassistant/components/agent_dvr/const.py
     homeassistant/components/agent_dvr/helpers.py
+    homeassistant/components/airnow/__init__.py
+    homeassistant/components/airnow/sensor.py
     homeassistant/components/airvisual/__init__.py
     homeassistant/components/airvisual/air_quality.py
     homeassistant/components/airvisual/sensor.py
diff --git a/CODEOWNERS b/CODEOWNERS
index a660d930128..7b874bb0ebf 100644
--- a/CODEOWNERS
+++ b/CODEOWNERS
@@ -26,6 +26,7 @@ homeassistant/components/adguard/* @frenck
 homeassistant/components/advantage_air/* @Bre77
 homeassistant/components/agent_dvr/* @ispysoftware
 homeassistant/components/airly/* @bieniu
+homeassistant/components/airnow/* @asymworks
 homeassistant/components/airvisual/* @bachya
 homeassistant/components/alarmdecoder/* @ajschmidt8
 homeassistant/components/alexa/* @home-assistant/cloud @ochlocracy
diff --git a/homeassistant/components/airnow/__init__.py b/homeassistant/components/airnow/__init__.py
new file mode 100644
index 00000000000..5cbc87947f9
--- /dev/null
+++ b/homeassistant/components/airnow/__init__.py
@@ -0,0 +1,161 @@
+"""The AirNow integration."""
+import asyncio
+import datetime
+import logging
+
+from aiohttp.client_exceptions import ClientConnectorError
+from pyairnow import WebServiceAPI
+from pyairnow.conv import aqi_to_concentration
+from pyairnow.errors import AirNowError
+
+from homeassistant.config_entries import ConfigEntry
+from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
+from homeassistant.core import HomeAssistant
+from homeassistant.exceptions import ConfigEntryNotReady
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
+
+from .const import (
+    ATTR_API_AQI,
+    ATTR_API_AQI_DESCRIPTION,
+    ATTR_API_AQI_LEVEL,
+    ATTR_API_AQI_PARAM,
+    ATTR_API_CAT_DESCRIPTION,
+    ATTR_API_CAT_LEVEL,
+    ATTR_API_CATEGORY,
+    ATTR_API_PM25,
+    ATTR_API_POLLUTANT,
+    ATTR_API_REPORT_DATE,
+    ATTR_API_REPORT_HOUR,
+    ATTR_API_STATE,
+    ATTR_API_STATION,
+    ATTR_API_STATION_LATITUDE,
+    ATTR_API_STATION_LONGITUDE,
+    DOMAIN,
+)
+
+_LOGGER = logging.getLogger(__name__)
+PLATFORMS = ["sensor"]
+
+
+async def async_setup(hass: HomeAssistant, config: dict):
+    """Set up the AirNow component."""
+    return True
+
+
+async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
+    """Set up AirNow from a config entry."""
+    api_key = entry.data[CONF_API_KEY]
+    latitude = entry.data[CONF_LATITUDE]
+    longitude = entry.data[CONF_LONGITUDE]
+    distance = entry.data[CONF_RADIUS]
+
+    # Reports are published hourly but update twice per hour
+    update_interval = datetime.timedelta(minutes=30)
+
+    # Setup the Coordinator
+    session = async_get_clientsession(hass)
+    coordinator = AirNowDataUpdateCoordinator(
+        hass, session, api_key, latitude, longitude, distance, update_interval
+    )
+
+    # Sync with Coordinator
+    await coordinator.async_refresh()
+    if not coordinator.last_update_success:
+        raise ConfigEntryNotReady
+
+    # Store Entity and Initialize Platforms
+    hass.data.setdefault(DOMAIN, {})
+    hass.data[DOMAIN][entry.entry_id] = coordinator
+
+    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
+            ]
+        )
+    )
+    if unload_ok:
+        hass.data[DOMAIN].pop(entry.entry_id)
+
+    return unload_ok
+
+
+class AirNowDataUpdateCoordinator(DataUpdateCoordinator):
+    """Define an object to hold Airly data."""
+
+    def __init__(
+        self, hass, session, api_key, latitude, longitude, distance, update_interval
+    ):
+        """Initialize."""
+        self.latitude = latitude
+        self.longitude = longitude
+        self.distance = distance
+
+        self.airnow = WebServiceAPI(api_key, session=session)
+
+        super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
+
+    async def _async_update_data(self):
+        """Update data via library."""
+        data = {}
+        try:
+            obs = await self.airnow.observations.latLong(
+                self.latitude,
+                self.longitude,
+                distance=self.distance,
+            )
+
+        except (AirNowError, ClientConnectorError) as error:
+            raise UpdateFailed(error) from error
+
+        if not obs:
+            raise UpdateFailed("No data was returned from AirNow")
+
+        max_aqi = 0
+        max_aqi_level = 0
+        max_aqi_desc = ""
+        max_aqi_poll = ""
+        for obv in obs:
+            # Convert AQIs to Concentration
+            pollutant = obv[ATTR_API_AQI_PARAM]
+            concentration = aqi_to_concentration(obv[ATTR_API_AQI], pollutant)
+            data[obv[ATTR_API_AQI_PARAM]] = concentration
+
+            # Overall AQI is the max of all pollutant AQIs
+            if obv[ATTR_API_AQI] > max_aqi:
+                max_aqi = obv[ATTR_API_AQI]
+                max_aqi_level = obv[ATTR_API_CATEGORY][ATTR_API_CAT_LEVEL]
+                max_aqi_desc = obv[ATTR_API_CATEGORY][ATTR_API_CAT_DESCRIPTION]
+                max_aqi_poll = pollutant
+
+            # Copy other data from PM2.5 Value
+            if obv[ATTR_API_AQI_PARAM] == ATTR_API_PM25:
+                # Copy Report Details
+                data[ATTR_API_REPORT_DATE] = obv[ATTR_API_REPORT_DATE]
+                data[ATTR_API_REPORT_HOUR] = obv[ATTR_API_REPORT_HOUR]
+
+                # Copy Station Details
+                data[ATTR_API_STATE] = obv[ATTR_API_STATE]
+                data[ATTR_API_STATION] = obv[ATTR_API_STATION]
+                data[ATTR_API_STATION_LATITUDE] = obv[ATTR_API_STATION_LATITUDE]
+                data[ATTR_API_STATION_LONGITUDE] = obv[ATTR_API_STATION_LONGITUDE]
+
+        # Store Overall AQI
+        data[ATTR_API_AQI] = max_aqi
+        data[ATTR_API_AQI_LEVEL] = max_aqi_level
+        data[ATTR_API_AQI_DESCRIPTION] = max_aqi_desc
+        data[ATTR_API_POLLUTANT] = max_aqi_poll
+
+        return data
diff --git a/homeassistant/components/airnow/config_flow.py b/homeassistant/components/airnow/config_flow.py
new file mode 100644
index 00000000000..6d53ac133ee
--- /dev/null
+++ b/homeassistant/components/airnow/config_flow.py
@@ -0,0 +1,110 @@
+"""Config flow for AirNow integration."""
+import logging
+
+from pyairnow import WebServiceAPI
+from pyairnow.errors import AirNowError, InvalidKeyError
+import voluptuous as vol
+
+from homeassistant import config_entries, core, exceptions
+from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
+from homeassistant.helpers.aiohttp_client import async_get_clientsession
+import homeassistant.helpers.config_validation as cv
+
+from .const import DOMAIN  # pylint:disable=unused-import
+
+_LOGGER = logging.getLogger(__name__)
+
+
+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.
+    """
+    session = async_get_clientsession(hass)
+    client = WebServiceAPI(data[CONF_API_KEY], session=session)
+
+    lat = data[CONF_LATITUDE]
+    lng = data[CONF_LONGITUDE]
+    distance = data[CONF_RADIUS]
+
+    # Check that the provided latitude/longitude provide a response
+    try:
+        test_data = await client.observations.latLong(lat, lng, distance=distance)
+
+    except InvalidKeyError as exc:
+        raise InvalidAuth from exc
+    except AirNowError as exc:
+        raise CannotConnect from exc
+
+    if not test_data:
+        raise InvalidLocation
+
+    # Validation Succeeded
+    return True
+
+
+class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
+    """Handle a config flow for AirNow."""
+
+    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:
+            # Set a unique id based on latitude/longitude
+            await self.async_set_unique_id(
+                f"{user_input[CONF_LATITUDE]}-{user_input[CONF_LONGITUDE]}"
+            )
+            self._abort_if_unique_id_configured()
+
+            try:
+                # Validate inputs
+                await validate_input(self.hass, user_input)
+
+            except CannotConnect:
+                errors["base"] = "cannot_connect"
+            except InvalidAuth:
+                errors["base"] = "invalid_auth"
+            except InvalidLocation:
+                errors["base"] = "invalid_location"
+            except Exception:  # pylint: disable=broad-except
+                _LOGGER.exception("Unexpected exception")
+                errors["base"] = "unknown"
+            else:
+                # Create Entry
+                return self.async_create_entry(
+                    title=f"AirNow Sensor at {user_input[CONF_LATITUDE]}, {user_input[CONF_LONGITUDE]}",
+                    data=user_input,
+                )
+
+        return self.async_show_form(
+            step_id="user",
+            data_schema=vol.Schema(
+                {
+                    vol.Required(CONF_API_KEY): str,
+                    vol.Optional(
+                        CONF_LATITUDE, default=self.hass.config.latitude
+                    ): cv.latitude,
+                    vol.Optional(
+                        CONF_LONGITUDE, default=self.hass.config.longitude
+                    ): cv.longitude,
+                    vol.Optional(CONF_RADIUS, default=150): int,
+                }
+            ),
+            errors=errors,
+        )
+
+
+class CannotConnect(exceptions.HomeAssistantError):
+    """Error to indicate we cannot connect."""
+
+
+class InvalidAuth(exceptions.HomeAssistantError):
+    """Error to indicate there is invalid auth."""
+
+
+class InvalidLocation(exceptions.HomeAssistantError):
+    """Error to indicate the location is invalid."""
diff --git a/homeassistant/components/airnow/const.py b/homeassistant/components/airnow/const.py
new file mode 100644
index 00000000000..67a9289efc5
--- /dev/null
+++ b/homeassistant/components/airnow/const.py
@@ -0,0 +1,21 @@
+"""Constants for the AirNow integration."""
+ATTR_API_AQI = "AQI"
+ATTR_API_AQI_LEVEL = "Category.Number"
+ATTR_API_AQI_DESCRIPTION = "Category.Name"
+ATTR_API_AQI_PARAM = "ParameterName"
+ATTR_API_CATEGORY = "Category"
+ATTR_API_CAT_LEVEL = "Number"
+ATTR_API_CAT_DESCRIPTION = "Name"
+ATTR_API_O3 = "O3"
+ATTR_API_PM25 = "PM2.5"
+ATTR_API_POLLUTANT = "Pollutant"
+ATTR_API_REPORT_DATE = "HourObserved"
+ATTR_API_REPORT_HOUR = "DateObserved"
+ATTR_API_STATE = "StateCode"
+ATTR_API_STATION = "ReportingArea"
+ATTR_API_STATION_LATITUDE = "Latitude"
+ATTR_API_STATION_LONGITUDE = "Longitude"
+DEFAULT_NAME = "AirNow"
+DOMAIN = "airnow"
+SENSOR_AQI_ATTR_DESCR = "description"
+SENSOR_AQI_ATTR_LEVEL = "level"
diff --git a/homeassistant/components/airnow/manifest.json b/homeassistant/components/airnow/manifest.json
new file mode 100644
index 00000000000..fee89ae4fff
--- /dev/null
+++ b/homeassistant/components/airnow/manifest.json
@@ -0,0 +1,12 @@
+{
+  "domain": "airnow",
+  "name": "AirNow",
+  "config_flow": true,
+  "documentation": "https://www.home-assistant.io/integrations/airnow",
+  "requirements": [
+    "pyairnow==1.1.0"
+  ],
+  "codeowners": [
+    "@asymworks"
+  ]
+}
diff --git a/homeassistant/components/airnow/sensor.py b/homeassistant/components/airnow/sensor.py
new file mode 100644
index 00000000000..fed6def2b36
--- /dev/null
+++ b/homeassistant/components/airnow/sensor.py
@@ -0,0 +1,118 @@
+"""Support for the AirNow sensor service."""
+from homeassistant.const import (
+    ATTR_ATTRIBUTION,
+    ATTR_DEVICE_CLASS,
+    CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+    CONCENTRATION_PARTS_PER_MILLION,
+)
+from homeassistant.helpers.update_coordinator import CoordinatorEntity
+
+from .const import (
+    ATTR_API_AQI,
+    ATTR_API_AQI_DESCRIPTION,
+    ATTR_API_AQI_LEVEL,
+    ATTR_API_O3,
+    ATTR_API_PM25,
+    DOMAIN,
+    SENSOR_AQI_ATTR_DESCR,
+    SENSOR_AQI_ATTR_LEVEL,
+)
+
+ATTRIBUTION = "Data provided by AirNow"
+
+ATTR_ICON = "icon"
+ATTR_LABEL = "label"
+ATTR_UNIT = "unit"
+
+PARALLEL_UPDATES = 1
+
+SENSOR_TYPES = {
+    ATTR_API_AQI: {
+        ATTR_DEVICE_CLASS: None,
+        ATTR_ICON: "mdi:blur",
+        ATTR_LABEL: ATTR_API_AQI,
+        ATTR_UNIT: "aqi",
+    },
+    ATTR_API_PM25: {
+        ATTR_DEVICE_CLASS: None,
+        ATTR_ICON: "mdi:blur",
+        ATTR_LABEL: ATTR_API_PM25,
+        ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
+    },
+    ATTR_API_O3: {
+        ATTR_DEVICE_CLASS: None,
+        ATTR_ICON: "mdi:blur",
+        ATTR_LABEL: ATTR_API_O3,
+        ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION,
+    },
+}
+
+
+async def async_setup_entry(hass, config_entry, async_add_entities):
+    """Set up AirNow sensor entities based on a config entry."""
+    coordinator = hass.data[DOMAIN][config_entry.entry_id]
+
+    sensors = []
+    for sensor in SENSOR_TYPES:
+        sensors.append(AirNowSensor(coordinator, sensor))
+
+    async_add_entities(sensors, False)
+
+
+class AirNowSensor(CoordinatorEntity):
+    """Define an AirNow sensor."""
+
+    def __init__(self, coordinator, kind):
+        """Initialize."""
+        super().__init__(coordinator)
+        self.kind = kind
+        self._device_class = None
+        self._state = None
+        self._icon = None
+        self._unit_of_measurement = None
+        self._attrs = {ATTR_ATTRIBUTION: ATTRIBUTION}
+
+    @property
+    def name(self):
+        """Return the name."""
+        return f"AirNow {SENSOR_TYPES[self.kind][ATTR_LABEL]}"
+
+    @property
+    def state(self):
+        """Return the state."""
+        self._state = self.coordinator.data[self.kind]
+        return self._state
+
+    @property
+    def device_state_attributes(self):
+        """Return the state attributes."""
+        if self.kind == ATTR_API_AQI:
+            self._attrs[SENSOR_AQI_ATTR_DESCR] = self.coordinator.data[
+                ATTR_API_AQI_DESCRIPTION
+            ]
+            self._attrs[SENSOR_AQI_ATTR_LEVEL] = self.coordinator.data[
+                ATTR_API_AQI_LEVEL
+            ]
+
+        return self._attrs
+
+    @property
+    def icon(self):
+        """Return the icon."""
+        self._icon = SENSOR_TYPES[self.kind][ATTR_ICON]
+        return self._icon
+
+    @property
+    def device_class(self):
+        """Return the device_class."""
+        return SENSOR_TYPES[self.kind][ATTR_DEVICE_CLASS]
+
+    @property
+    def unique_id(self):
+        """Return a unique_id for this entity."""
+        return f"{self.coordinator.latitude}-{self.coordinator.longitude}-{self.kind.lower()}"
+
+    @property
+    def unit_of_measurement(self):
+        """Return the unit the value is expressed in."""
+        return SENSOR_TYPES[self.kind][ATTR_UNIT]
diff --git a/homeassistant/components/airnow/strings.json b/homeassistant/components/airnow/strings.json
new file mode 100644
index 00000000000..a73ad6d179c
--- /dev/null
+++ b/homeassistant/components/airnow/strings.json
@@ -0,0 +1,26 @@
+{
+  "title": "AirNow",
+  "config": {
+    "step": {
+      "user": {
+        "title": "AirNow",
+        "description": "Set up AirNow air quality integration. To generate API key go to https://docs.airnowapi.org/account/request/",
+        "data": {
+          "api_key": "[%key:common::config_flow::data::api_key%]",
+          "latitude": "[%key:common::config_flow::data::latitude%]",
+          "longitude": "[%key:common::config_flow::data::longitude%]",
+          "radius": "Station Radius (miles; optional)"
+        }
+      }
+    },
+    "error": {
+      "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+      "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+      "invalid_location": "No results found for that location",
+      "unknown": "[%key:common::config_flow::error::unknown%]"
+    },
+    "abort": {
+      "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+    }
+  }
+}
diff --git a/homeassistant/components/airnow/translations/en.json b/homeassistant/components/airnow/translations/en.json
new file mode 100644
index 00000000000..5c5259c74e2
--- /dev/null
+++ b/homeassistant/components/airnow/translations/en.json
@@ -0,0 +1,27 @@
+{
+    "config": {
+        "abort": {
+            "already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
+        },
+        "error": {
+            "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
+            "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
+            "invalid_location": "No results found for that location",
+            "unknown": "[%key:common::config_flow::error::unknown%]"
+        },
+        "step": {
+            "user": {
+                "data": {
+                    "api_key": "API Key",
+                    "latitude": "Latitude",
+                    "longitude": "Longitude",
+                    "name": "Name of the Entity",
+                    "radius": "Station Radius (miles; optional)"
+                },
+                "description": "Set up AirNow air quality integration. To generate API key go to https://docs.airnowapi.org/account/request/",
+                "title": "AirNow"
+            }
+        }
+    },
+    "title": "AirNow"
+}
\ No newline at end of file
diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py
index 9e204e91da5..b94b4deee94 100644
--- a/homeassistant/generated/config_flows.py
+++ b/homeassistant/generated/config_flows.py
@@ -13,6 +13,7 @@ FLOWS = [
     "advantage_air",
     "agent_dvr",
     "airly",
+    "airnow",
     "airvisual",
     "alarmdecoder",
     "almond",
diff --git a/requirements_all.txt b/requirements_all.txt
index 5c699a91ca9..d601cbdac34 100644
--- a/requirements_all.txt
+++ b/requirements_all.txt
@@ -1258,6 +1258,9 @@ pyaehw4a1==0.3.9
 # homeassistant.components.aftership
 pyaftership==0.1.2
 
+# homeassistant.components.airnow
+pyairnow==1.1.0
+
 # homeassistant.components.airvisual
 pyairvisual==5.0.4
 
diff --git a/requirements_test_all.txt b/requirements_test_all.txt
index 65665a57f49..06b8f504fe4 100644
--- a/requirements_test_all.txt
+++ b/requirements_test_all.txt
@@ -633,6 +633,9 @@ py_nextbusnext==0.1.4
 # homeassistant.components.hisense_aehw4a1
 pyaehw4a1==0.3.9
 
+# homeassistant.components.airnow
+pyairnow==1.1.0
+
 # homeassistant.components.airvisual
 pyairvisual==5.0.4
 
diff --git a/tests/components/airnow/__init__.py b/tests/components/airnow/__init__.py
new file mode 100644
index 00000000000..d7fc1922ee8
--- /dev/null
+++ b/tests/components/airnow/__init__.py
@@ -0,0 +1 @@
+"""Tests for the AirNow integration."""
diff --git a/tests/components/airnow/test_config_flow.py b/tests/components/airnow/test_config_flow.py
new file mode 100644
index 00000000000..2db3c22795f
--- /dev/null
+++ b/tests/components/airnow/test_config_flow.py
@@ -0,0 +1,185 @@
+"""Test the AirNow config flow."""
+from pyairnow.errors import AirNowError, InvalidKeyError
+
+from homeassistant import config_entries, data_entry_flow, setup
+from homeassistant.components.airnow.const import DOMAIN
+from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS
+
+from tests.async_mock import patch
+from tests.common import MockConfigEntry
+
+CONFIG = {
+    CONF_API_KEY: "abc123",
+    CONF_LATITUDE: 34.053718,
+    CONF_LONGITUDE: -118.244842,
+    CONF_RADIUS: 75,
+}
+
+# Mock AirNow Response
+MOCK_RESPONSE = [
+    {
+        "DateObserved": "2020-12-20",
+        "HourObserved": 15,
+        "LocalTimeZone": "PST",
+        "ReportingArea": "Central LA CO",
+        "StateCode": "CA",
+        "Latitude": 34.0663,
+        "Longitude": -118.2266,
+        "ParameterName": "O3",
+        "AQI": 44,
+        "Category": {
+            "Number": 1,
+            "Name": "Good",
+        },
+    },
+    {
+        "DateObserved": "2020-12-20",
+        "HourObserved": 15,
+        "LocalTimeZone": "PST",
+        "ReportingArea": "Central LA CO",
+        "StateCode": "CA",
+        "Latitude": 34.0663,
+        "Longitude": -118.2266,
+        "ParameterName": "PM2.5",
+        "AQI": 37,
+        "Category": {
+            "Number": 1,
+            "Name": "Good",
+        },
+    },
+    {
+        "DateObserved": "2020-12-20",
+        "HourObserved": 15,
+        "LocalTimeZone": "PST",
+        "ReportingArea": "Central LA CO",
+        "StateCode": "CA",
+        "Latitude": 34.0663,
+        "Longitude": -118.2266,
+        "ParameterName": "PM10",
+        "AQI": 11,
+        "Category": {
+            "Number": 1,
+            "Name": "Good",
+        },
+    },
+]
+
+
+async def test_form(hass):
+    """Test we get the form."""
+    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"] == data_entry_flow.RESULT_TYPE_FORM
+    assert result["errors"] == {}
+
+    with patch("pyairnow.WebServiceAPI._get", return_value=MOCK_RESPONSE,), patch(
+        "homeassistant.components.airnow.async_setup", return_value=True
+    ) as mock_setup, patch(
+        "homeassistant.components.airnow.async_setup_entry",
+        return_value=True,
+    ) as mock_setup_entry:
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            CONFIG,
+        )
+
+        await hass.async_block_till_done()
+
+    assert result2["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
+    assert result2["data"] == CONFIG
+    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(
+        "pyairnow.WebServiceAPI._get",
+        side_effect=InvalidKeyError,
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            CONFIG,
+        )
+
+    assert result2["type"] == "form"
+    assert result2["errors"] == {"base": "invalid_auth"}
+
+
+async def test_form_invalid_location(hass):
+    """Test we handle invalid location."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
+    )
+
+    with patch("pyairnow.WebServiceAPI._get", return_value={}):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            CONFIG,
+        )
+
+    assert result2["type"] == "form"
+    assert result2["errors"] == {"base": "invalid_location"}
+
+
+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(
+        "pyairnow.WebServiceAPI._get",
+        side_effect=AirNowError,
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            CONFIG,
+        )
+
+    assert result2["type"] == "form"
+    assert result2["errors"] == {"base": "cannot_connect"}
+
+
+async def test_form_unexpected(hass):
+    """Test we handle an unexpected error."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
+    )
+
+    with patch(
+        "homeassistant.components.airnow.config_flow.validate_input",
+        side_effect=RuntimeError,
+    ):
+        result2 = await hass.config_entries.flow.async_configure(
+            result["flow_id"],
+            CONFIG,
+        )
+
+    assert result2["type"] == "form"
+    assert result2["errors"] == {"base": "unknown"}
+
+
+async def test_entry_already_exists(hass):
+    """Test that the form aborts if the Lat/Lng is already configured."""
+    result = await hass.config_entries.flow.async_init(
+        DOMAIN, context={"source": config_entries.SOURCE_USER}
+    )
+
+    mock_id = f"{CONFIG[CONF_LATITUDE]}-{CONFIG[CONF_LONGITUDE]}"
+    mock_entry = MockConfigEntry(domain=DOMAIN, unique_id=mock_id)
+    mock_entry.add_to_hass(hass)
+
+    result2 = await hass.config_entries.flow.async_configure(
+        result["flow_id"],
+        CONFIG,
+    )
+
+    assert result2["type"] == "abort"
+    assert result2["reason"] == "already_configured"
-- 
GitLab