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