From e9a7b66df67426f00529d76cafedc6300dc03ee3 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 28 Feb 2020 20:14:17 -0700 Subject: [PATCH] Add config entry for AirVisual (#32072) * Add config entry for AirVisual * Update coverage * Catch invalid API key from config schema * Rename geographies to stations * Revert "Rename geographies to stations" This reverts commit 5477f89c24cb3f58965351985b1021fc5fc794a5. * Update strings * Update CONNECTION_CLASS * Remove options (subsequent PR) * Handle import step separately * Code review comments and simplification * Move default geography logic to config flow * Register domain in config flow init * Add tests * Update strings * Bump requirements * Update homeassistant/components/airvisual/config_flow.py * Update homeassistant/components/airvisual/config_flow.py * Make schemas stricter * Linting * Linting * Code review comments * Put config flow unique ID logic into a method * Fix tests * Streamline * Linting * show_on_map in options with default value * Code review comments * Default options * Update tests * Test update * Move config entry into data object (in prep for options flow) * Empty commit to re-trigger build --- .coveragerc | 1 + .../airvisual/.translations/en.json | 23 ++ .../components/airvisual/__init__.py | 200 +++++++++++++++ .../components/airvisual/config_flow.py | 92 +++++++ homeassistant/components/airvisual/const.py | 14 ++ .../components/airvisual/manifest.json | 1 + homeassistant/components/airvisual/sensor.py | 228 +++++++----------- .../components/airvisual/strings.json | 23 ++ homeassistant/generated/config_flows.py | 1 + requirements_test_all.txt | 3 + tests/components/airvisual/__init__.py | 1 + .../components/airvisual/test_config_flow.py | 87 +++++++ 12 files changed, 527 insertions(+), 147 deletions(-) create mode 100644 homeassistant/components/airvisual/.translations/en.json create mode 100644 homeassistant/components/airvisual/config_flow.py create mode 100644 homeassistant/components/airvisual/const.py create mode 100644 homeassistant/components/airvisual/strings.json create mode 100644 tests/components/airvisual/__init__.py create mode 100644 tests/components/airvisual/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index 94b7deea82b..44ca6709eca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -29,6 +29,7 @@ omit = homeassistant/components/airly/air_quality.py homeassistant/components/airly/sensor.py homeassistant/components/airly/const.py + homeassistant/components/airvisual/__init__.py homeassistant/components/airvisual/sensor.py homeassistant/components/aladdin_connect/cover.py homeassistant/components/alarmdecoder/* diff --git a/homeassistant/components/airvisual/.translations/en.json b/homeassistant/components/airvisual/.translations/en.json new file mode 100644 index 00000000000..844174220b1 --- /dev/null +++ b/homeassistant/components/airvisual/.translations/en.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "This API key is already in use." + }, + "error": { + "invalid_api_key": "Invalid API key" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "show_on_map": "Show monitored geography on the map" + }, + "description": "Monitor air quality in a geographical location.", + "title": "Configure AirVisual" + } + }, + "title": "AirVisual" + } +} \ No newline at end of file diff --git a/homeassistant/components/airvisual/__init__.py b/homeassistant/components/airvisual/__init__.py index b1f79d17241..24d8257d041 100644 --- a/homeassistant/components/airvisual/__init__.py +++ b/homeassistant/components/airvisual/__init__.py @@ -1 +1,201 @@ """The airvisual component.""" +import asyncio +import logging + +from pyairvisual import Client +from pyairvisual.errors import AirVisualError, InvalidKeyError +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + CONF_API_KEY, + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_SHOW_ON_MAP, + CONF_STATE, +) +from homeassistant.core import callback +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval + +from .const import ( + CONF_CITY, + CONF_COUNTRY, + CONF_GEOGRAPHIES, + DATA_CLIENT, + DEFAULT_SCAN_INTERVAL, + DOMAIN, + TOPIC_UPDATE, +) + +_LOGGER = logging.getLogger(__name__) + +DATA_LISTENER = "listener" + +DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True} + +CONF_NODE_ID = "node_id" + +GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema( + { + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + } +) + +GEOGRAPHY_PLACE_SCHEMA = vol.Schema( + { + vol.Required(CONF_CITY): cv.string, + vol.Required(CONF_STATE): cv.string, + vol.Required(CONF_COUNTRY): cv.string, + } +) + +CLOUD_API_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_GEOGRAPHIES, default=[]): vol.All( + cv.ensure_list, + [vol.Any(GEOGRAPHY_COORDINATES_SCHEMA, GEOGRAPHY_PLACE_SCHEMA)], + ), + } +) + +CONFIG_SCHEMA = vol.Schema({DOMAIN: CLOUD_API_SCHEMA}, extra=vol.ALLOW_EXTRA) + + +@callback +def async_get_geography_id(geography_dict): + """Generate a unique ID from a geography dict.""" + if CONF_CITY in geography_dict: + return ",".join( + ( + geography_dict[CONF_CITY], + geography_dict[CONF_STATE], + geography_dict[CONF_COUNTRY], + ) + ) + return ",".join( + (str(geography_dict[CONF_LATITUDE]), str(geography_dict[CONF_LONGITUDE])) + ) + + +async def async_setup(hass, config): + """Set up the AirVisual component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + hass.data[DOMAIN][DATA_LISTENER] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + ) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up AirVisual as config entry.""" + entry_updates = {} + if not config_entry.unique_id: + # If the config entry doesn't already have a unique ID, set one: + entry_updates["unique_id"] = config_entry.data[CONF_API_KEY] + if not config_entry.options: + # If the config entry doesn't already have any options set, set defaults: + entry_updates["options"] = DEFAULT_OPTIONS + + if entry_updates: + hass.config_entries.async_update_entry(config_entry, **entry_updates) + + websession = aiohttp_client.async_get_clientsession(hass) + + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = AirVisualData( + hass, Client(websession, api_key=config_entry.data[CONF_API_KEY]), config_entry + ) + + try: + await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update() + except InvalidKeyError: + _LOGGER.error("Invalid API key provided") + raise ConfigEntryNotReady + + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, "sensor") + ) + + async def refresh(event_time): + """Refresh data from AirVisual.""" + await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update() + + hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( + hass, refresh, DEFAULT_SCAN_INTERVAL + ) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an AirVisual config entry.""" + hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + + remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) + remove_listener() + + await hass.config_entries.async_forward_entry_unload(config_entry, "sensor") + + return True + + +class AirVisualData: + """Define a class to manage data from the AirVisual cloud API.""" + + def __init__(self, hass, client, config_entry): + """Initialize.""" + self._client = client + self._hass = hass + self.data = {} + self.show_on_map = config_entry.options[CONF_SHOW_ON_MAP] + + self.geographies = { + async_get_geography_id(geography): geography + for geography in config_entry.data[CONF_GEOGRAPHIES] + } + + async def async_update(self): + """Get new data for all locations from the AirVisual cloud API.""" + tasks = [] + + for geography in self.geographies.values(): + if CONF_CITY in geography: + tasks.append( + self._client.api.city( + geography[CONF_CITY], + geography[CONF_STATE], + geography[CONF_COUNTRY], + ) + ) + else: + tasks.append( + self._client.api.nearest_city( + geography[CONF_LATITUDE], geography[CONF_LONGITUDE], + ) + ) + + results = await asyncio.gather(*tasks, return_exceptions=True) + for geography_id, result in zip(self.geographies, results): + if isinstance(result, AirVisualError): + _LOGGER.error("Error while retrieving data: %s", result) + self.data[geography_id] = {} + continue + self.data[geography_id] = result + + _LOGGER.debug("Received new data") + async_dispatcher_send(self._hass, TOPIC_UPDATE) diff --git a/homeassistant/components/airvisual/config_flow.py b/homeassistant/components/airvisual/config_flow.py new file mode 100644 index 00000000000..4dd7fb80de8 --- /dev/null +++ b/homeassistant/components/airvisual/config_flow.py @@ -0,0 +1,92 @@ +"""Define a config flow manager for AirVisual.""" +from pyairvisual import Client +from pyairvisual.errors import InvalidKeyError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client, config_validation as cv + +from .const import CONF_GEOGRAPHIES, DOMAIN # pylint: disable=unused-import + + +class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a AirVisual config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + @property + def cloud_api_schema(self): + """Return the data schema for the cloud API.""" + return vol.Schema( + { + vol.Required(CONF_API_KEY): str, + vol.Required( + CONF_LATITUDE, default=self.hass.config.latitude + ): cv.latitude, + vol.Required( + CONF_LONGITUDE, default=self.hass.config.longitude + ): cv.longitude, + } + ) + + async def _async_set_unique_id(self, unique_id): + """Set the unique ID of the config flow and abort if it already exists.""" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + @callback + async def _show_form(self, errors=None): + """Show the form to the user.""" + return self.async_show_form( + step_id="user", data_schema=self.cloud_api_schema, errors=errors or {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + await self._async_set_unique_id(import_config[CONF_API_KEY]) + + data = {**import_config} + if not data.get(CONF_GEOGRAPHIES): + data[CONF_GEOGRAPHIES] = [ + { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + } + ] + + return self.async_create_entry( + title=f"Cloud API (API key: {import_config[CONF_API_KEY][:4]}...)", + data=data, + ) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + if not user_input: + return await self._show_form() + + await self._async_set_unique_id(user_input[CONF_API_KEY]) + + websession = aiohttp_client.async_get_clientsession(self.hass) + + client = Client(websession, api_key=user_input[CONF_API_KEY]) + + try: + await client.api.nearest_city() + except InvalidKeyError: + return await self._show_form(errors={CONF_API_KEY: "invalid_api_key"}) + + return self.async_create_entry( + title=f"Cloud API (API key: {user_input[CONF_API_KEY][:4]}...)", + data={ + CONF_API_KEY: user_input[CONF_API_KEY], + CONF_GEOGRAPHIES: [ + { + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + } + ], + }, + ) diff --git a/homeassistant/components/airvisual/const.py b/homeassistant/components/airvisual/const.py new file mode 100644 index 00000000000..ab54e191116 --- /dev/null +++ b/homeassistant/components/airvisual/const.py @@ -0,0 +1,14 @@ +"""Define AirVisual constants.""" +from datetime import timedelta + +DOMAIN = "airvisual" + +CONF_CITY = "city" +CONF_COUNTRY = "country" +CONF_GEOGRAPHIES = "geographies" + +DATA_CLIENT = "client" + +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) + +TOPIC_UPDATE = f"{DOMAIN}_update" diff --git a/homeassistant/components/airvisual/manifest.json b/homeassistant/components/airvisual/manifest.json index a689ee6acf0..756fb56acc1 100644 --- a/homeassistant/components/airvisual/manifest.json +++ b/homeassistant/components/airvisual/manifest.json @@ -1,6 +1,7 @@ { "domain": "airvisual", "name": "AirVisual", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/airvisual", "requirements": ["pyairvisual==3.0.1"], "dependencies": [], diff --git a/homeassistant/components/airvisual/sensor.py b/homeassistant/components/airvisual/sensor.py index a7bf3f4dd1b..a25114b7f02 100644 --- a/homeassistant/components/airvisual/sensor.py +++ b/homeassistant/components/airvisual/sensor.py @@ -1,30 +1,23 @@ """Support for AirVisual air quality sensors.""" -from datetime import timedelta from logging import getLogger -from pyairvisual import Client -from pyairvisual.errors import AirVisualError -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, + ATTR_STATE, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, - CONF_SCAN_INTERVAL, - CONF_SHOW_ON_MAP, CONF_STATE, ) -from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle + +from .const import CONF_CITY, CONF_COUNTRY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE _LOGGER = getLogger(__name__) @@ -34,19 +27,19 @@ ATTR_POLLUTANT_SYMBOL = "pollutant_symbol" ATTR_POLLUTANT_UNIT = "pollutant_unit" ATTR_REGION = "region" -CONF_CITY = "city" -CONF_COUNTRY = "country" - DEFAULT_ATTRIBUTION = "Data provided by AirVisual" -DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) -SENSOR_TYPE_LEVEL = "air_pollution_level" -SENSOR_TYPE_AQI = "air_quality_index" -SENSOR_TYPE_POLLUTANT = "main_pollutant" +MASS_PARTS_PER_MILLION = "ppm" +MASS_PARTS_PER_BILLION = "ppb" +VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" + +SENSOR_KIND_LEVEL = "air_pollution_level" +SENSOR_KIND_AQI = "air_quality_index" +SENSOR_KIND_POLLUTANT = "main_pollutant" SENSORS = [ - (SENSOR_TYPE_LEVEL, "Air Pollution Level", "mdi:gauge", None), - (SENSOR_TYPE_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), - (SENSOR_TYPE_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), + (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), + (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), + (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), ] POLLUTANT_LEVEL_MAPPING = [ @@ -79,102 +72,67 @@ POLLUTANT_MAPPING = { SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_LOCALES)): vol.All( - cv.ensure_list, [vol.In(SENSOR_LOCALES)] - ), - vol.Inclusive(CONF_CITY, "city"): cv.string, - vol.Inclusive(CONF_COUNTRY, "city"): cv.string, - vol.Inclusive(CONF_LATITUDE, "coords"): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, "coords"): cv.longitude, - vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean, - vol.Inclusive(CONF_STATE, "city"): cv.string, - vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): cv.time_period, - } -) +async def async_setup_entry(hass, entry, async_add_entities): + """Set up AirVisual sensors based on a config entry.""" + airvisual = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] -async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Configure the platform and add the sensors.""" - - city = config.get(CONF_CITY) - state = config.get(CONF_STATE) - country = config.get(CONF_COUNTRY) - - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - - websession = aiohttp_client.async_get_clientsession(hass) - - if city and state and country: - _LOGGER.debug( - "Using city, state, and country: %s, %s, %s", city, state, country - ) - location_id = ",".join((city, state, country)) - data = AirVisualData( - Client(websession, api_key=config[CONF_API_KEY]), - city=city, - state=state, - country=country, - show_on_map=config[CONF_SHOW_ON_MAP], - scan_interval=config[CONF_SCAN_INTERVAL], - ) - else: - _LOGGER.debug("Using latitude and longitude: %s, %s", latitude, longitude) - location_id = ",".join((str(latitude), str(longitude))) - data = AirVisualData( - Client(websession, api_key=config[CONF_API_KEY]), - latitude=latitude, - longitude=longitude, - show_on_map=config[CONF_SHOW_ON_MAP], - scan_interval=config[CONF_SCAN_INTERVAL], - ) - - await data.async_update() - - sensors = [] - for locale in config[CONF_MONITORED_CONDITIONS]: - for kind, name, icon, unit in SENSORS: - sensors.append( - AirVisualSensor(data, kind, name, icon, unit, locale, location_id) - ) - - async_add_entities(sensors, True) + async_add_entities( + [ + AirVisualSensor(airvisual, kind, name, icon, unit, locale, geography_id) + for geography_id in airvisual.data + for locale in SENSOR_LOCALES + for kind, name, icon, unit in SENSORS + ], + True, + ) class AirVisualSensor(Entity): """Define an AirVisual sensor.""" - def __init__(self, airvisual, kind, name, icon, unit, locale, location_id): + def __init__(self, airvisual, kind, name, icon, unit, locale, geography_id): """Initialize.""" - self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._airvisual = airvisual + self._async_unsub_dispatcher_connects = [] + self._geography_id = geography_id self._icon = icon + self._kind = kind self._locale = locale - self._location_id = location_id self._name = name self._state = None - self._type = kind self._unit = unit - self.airvisual = airvisual - @property - def device_state_attributes(self): - """Return the device state attributes.""" - if self.airvisual.show_on_map: - self._attrs[ATTR_LATITUDE] = self.airvisual.latitude - self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude - else: - self._attrs["lati"] = self.airvisual.latitude - self._attrs["long"] = self.airvisual.longitude + self._attrs = { + ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, + ATTR_CITY: airvisual.data[geography_id].get(CONF_CITY), + ATTR_STATE: airvisual.data[geography_id].get(CONF_STATE), + ATTR_COUNTRY: airvisual.data[geography_id].get(CONF_COUNTRY), + } - return self._attrs + geography = airvisual.geographies[geography_id] + if geography.get(CONF_LATITUDE): + if airvisual.show_on_map: + self._attrs[ATTR_LATITUDE] = geography[CONF_LATITUDE] + self._attrs[ATTR_LONGITUDE] = geography[CONF_LONGITUDE] + else: + self._attrs["lati"] = geography[CONF_LATITUDE] + self._attrs["long"] = geography[CONF_LONGITUDE] @property def available(self): """Return True if entity is available.""" - return bool(self.airvisual.pollution_info) + try: + return bool( + self._airvisual.data[self._geography_id]["current"]["pollution"] + ) + except KeyError: + return False + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._attrs @property def icon(self): @@ -194,22 +152,33 @@ class AirVisualSensor(Entity): @property def unique_id(self): """Return a unique, Home Assistant friendly identifier for this entity.""" - return f"{self._location_id}_{self._locale}_{self._type}" + return f"{self._geography_id}_{self._locale}_{self._kind}" @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit + async def async_added_to_hass(self): + """Register callbacks.""" + + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connects.append( + async_dispatcher_connect(self.hass, TOPIC_UPDATE, update) + ) + async def async_update(self): """Update the sensor.""" - await self.airvisual.async_update() - data = self.airvisual.pollution_info - - if not data: + try: + data = self._airvisual.data[self._geography_id]["current"]["pollution"] + except KeyError: return - if self._type == SENSOR_TYPE_LEVEL: + if self._kind == SENSOR_KIND_LEVEL: aqi = data[f"aqi{self._locale}"] [level] = [ i @@ -218,9 +187,9 @@ class AirVisualSensor(Entity): ] self._state = level["label"] self._icon = level["icon"] - elif self._type == SENSOR_TYPE_AQI: + elif self._kind == SENSOR_KIND_AQI: self._state = data[f"aqi{self._locale}"] - elif self._type == SENSOR_TYPE_POLLUTANT: + elif self._kind == SENSOR_KIND_POLLUTANT: symbol = data[f"main{self._locale}"] self._state = POLLUTANT_MAPPING[symbol]["label"] self._attrs.update( @@ -230,43 +199,8 @@ class AirVisualSensor(Entity): } ) - -class AirVisualData: - """Define an object to hold sensor data.""" - - def __init__(self, client, **kwargs): - """Initialize.""" - self._client = client - self.city = kwargs.get(CONF_CITY) - self.country = kwargs.get(CONF_COUNTRY) - self.latitude = kwargs.get(CONF_LATITUDE) - self.longitude = kwargs.get(CONF_LONGITUDE) - self.pollution_info = {} - self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP) - self.state = kwargs.get(CONF_STATE) - - self.async_update = Throttle(kwargs[CONF_SCAN_INTERVAL])(self._async_update) - - async def _async_update(self): - """Update AirVisual data.""" - - try: - if self.city and self.state and self.country: - resp = await self._client.api.city(self.city, self.state, self.country) - self.longitude, self.latitude = resp["location"]["coordinates"] - else: - resp = await self._client.api.nearest_city( - self.latitude, self.longitude - ) - - _LOGGER.debug("New data retrieved: %s", resp) - - self.pollution_info = resp["current"]["pollution"] - except (KeyError, AirVisualError) as err: - if self.city and self.state and self.country: - location = (self.city, self.state, self.country) - else: - location = (self.latitude, self.longitude) - - _LOGGER.error("Can't retrieve data for location: %s (%s)", location, err) - self.pollution_info = {} + async def async_will_remove_from_hass(self) -> None: + """Disconnect dispatcher listener when removed.""" + for cancel in self._async_unsub_dispatcher_connects: + cancel() + self._async_unsub_dispatcher_connects = [] diff --git a/homeassistant/components/airvisual/strings.json b/homeassistant/components/airvisual/strings.json new file mode 100644 index 00000000000..6c3f17c92dd --- /dev/null +++ b/homeassistant/components/airvisual/strings.json @@ -0,0 +1,23 @@ +{ + "config": { + "title": "AirVisual", + "step": { + "user": { + "title": "Configure AirVisual", + "description": "Monitor air quality in a geographical location.", + "data": { + "api_key": "API Key", + "latitude": "Latitude", + "longitude": "Longitude", + "show_on_map": "Show monitored geography on the map" + } + } + }, + "error": { + "invalid_api_key": "Invalid API key" + }, + "abort": { + "already_configured": "This API key is already in use." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d0162f84737..55c013b46ad 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -9,6 +9,7 @@ FLOWS = [ "abode", "adguard", "airly", + "airvisual", "almond", "ambiclimate", "ambient_station", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 50d27d9fdbb..59b51e13062 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -418,6 +418,9 @@ py_nextbusnext==0.1.4 # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.4 +# homeassistant.components.airvisual +pyairvisual==3.0.1 + # homeassistant.components.almond pyalmond==0.0.2 diff --git a/tests/components/airvisual/__init__.py b/tests/components/airvisual/__init__.py new file mode 100644 index 00000000000..4c116d75d0f --- /dev/null +++ b/tests/components/airvisual/__init__.py @@ -0,0 +1 @@ +"""Define tests for the AirVisual component.""" diff --git a/tests/components/airvisual/test_config_flow.py b/tests/components/airvisual/test_config_flow.py new file mode 100644 index 00000000000..fb4325bd6ee --- /dev/null +++ b/tests/components/airvisual/test_config_flow.py @@ -0,0 +1,87 @@ +"""Define tests for the AirVisual config flow.""" +from unittest.mock import patch + +from pyairvisual.errors import InvalidKeyError + +from homeassistant import data_entry_flow +from homeassistant.components.airvisual import CONF_GEOGRAPHIES, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_API_KEY, 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: "abcde12345"} + + MockConfigEntry(domain=DOMAIN, unique_id="abcde12345", data=conf).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_invalid_api_key(hass): + """Test that invalid credentials throws an error.""" + conf = {CONF_API_KEY: "abcde12345"} + + with patch( + "pyairvisual.api.API.nearest_city", + return_value=mock_coro(exception=InvalidKeyError), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == "user" + + +async def test_step_import(hass): + """Test that the import step works.""" + conf = {CONF_API_KEY: "abcde12345"} + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (API key: abcd...)" + assert result["data"] == { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}], + } + + +async def test_step_user(hass): + """Test that the user step works.""" + conf = { + CONF_API_KEY: "abcde12345", + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + } + + with patch( + "pyairvisual.api.API.nearest_city", return_value=mock_coro(), + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=conf + ) + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "Cloud API (API key: abcd...)" + assert result["data"] == { + CONF_API_KEY: "abcde12345", + CONF_GEOGRAPHIES: [{CONF_LATITUDE: 32.87336, CONF_LONGITUDE: -117.22743}], + }