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
This commit is contained in:
Aaron Bach 2020-02-28 20:14:17 -07:00 committed by GitHub
parent bf33144c2b
commit e9a7b66df6
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 527 additions and 147 deletions

View file

@ -29,6 +29,7 @@ omit =
homeassistant/components/airly/air_quality.py homeassistant/components/airly/air_quality.py
homeassistant/components/airly/sensor.py homeassistant/components/airly/sensor.py
homeassistant/components/airly/const.py homeassistant/components/airly/const.py
homeassistant/components/airvisual/__init__.py
homeassistant/components/airvisual/sensor.py homeassistant/components/airvisual/sensor.py
homeassistant/components/aladdin_connect/cover.py homeassistant/components/aladdin_connect/cover.py
homeassistant/components/alarmdecoder/* homeassistant/components/alarmdecoder/*

View file

@ -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"
}
}

View file

@ -1 +1,201 @@
"""The airvisual component.""" """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)

View file

@ -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],
}
],
},
)

View file

@ -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"

View file

@ -1,6 +1,7 @@
{ {
"domain": "airvisual", "domain": "airvisual",
"name": "AirVisual", "name": "AirVisual",
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/airvisual", "documentation": "https://www.home-assistant.io/integrations/airvisual",
"requirements": ["pyairvisual==3.0.1"], "requirements": ["pyairvisual==3.0.1"],
"dependencies": [], "dependencies": [],

View file

@ -1,30 +1,23 @@
"""Support for AirVisual air quality sensors.""" """Support for AirVisual air quality sensors."""
from datetime import timedelta
from logging import getLogger 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 ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_ATTRIBUTION,
ATTR_LATITUDE, ATTR_LATITUDE,
ATTR_LONGITUDE, ATTR_LONGITUDE,
ATTR_STATE,
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION, CONCENTRATION_PARTS_PER_MILLION,
CONF_API_KEY,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_MONITORED_CONDITIONS,
CONF_SCAN_INTERVAL,
CONF_SHOW_ON_MAP,
CONF_STATE, 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.helpers.entity import Entity
from homeassistant.util import Throttle
from .const import CONF_CITY, CONF_COUNTRY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE
_LOGGER = getLogger(__name__) _LOGGER = getLogger(__name__)
@ -34,19 +27,19 @@ ATTR_POLLUTANT_SYMBOL = "pollutant_symbol"
ATTR_POLLUTANT_UNIT = "pollutant_unit" ATTR_POLLUTANT_UNIT = "pollutant_unit"
ATTR_REGION = "region" ATTR_REGION = "region"
CONF_CITY = "city"
CONF_COUNTRY = "country"
DEFAULT_ATTRIBUTION = "Data provided by AirVisual" DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
DEFAULT_SCAN_INTERVAL = timedelta(minutes=10)
SENSOR_TYPE_LEVEL = "air_pollution_level" MASS_PARTS_PER_MILLION = "ppm"
SENSOR_TYPE_AQI = "air_quality_index" MASS_PARTS_PER_BILLION = "ppb"
SENSOR_TYPE_POLLUTANT = "main_pollutant" 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 = [ SENSORS = [
(SENSOR_TYPE_LEVEL, "Air Pollution Level", "mdi:gauge", None), (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None),
(SENSOR_TYPE_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"),
(SENSOR_TYPE_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None),
] ]
POLLUTANT_LEVEL_MAPPING = [ POLLUTANT_LEVEL_MAPPING = [
@ -79,102 +72,67 @@ POLLUTANT_MAPPING = {
SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."} 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): async_add_entities(
"""Configure the platform and add the sensors.""" [
AirVisualSensor(airvisual, kind, name, icon, unit, locale, geography_id)
city = config.get(CONF_CITY) for geography_id in airvisual.data
state = config.get(CONF_STATE) for locale in SENSOR_LOCALES
country = config.get(CONF_COUNTRY) for kind, name, icon, unit in SENSORS
],
latitude = config.get(CONF_LATITUDE, hass.config.latitude) True,
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)
class AirVisualSensor(Entity): class AirVisualSensor(Entity):
"""Define an AirVisual sensor.""" """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.""" """Initialize."""
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._airvisual = airvisual
self._async_unsub_dispatcher_connects = []
self._geography_id = geography_id
self._icon = icon self._icon = icon
self._kind = kind
self._locale = locale self._locale = locale
self._location_id = location_id
self._name = name self._name = name
self._state = None self._state = None
self._type = kind
self._unit = unit self._unit = unit
self.airvisual = airvisual
@property self._attrs = {
def device_state_attributes(self): ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION,
"""Return the device state attributes.""" ATTR_CITY: airvisual.data[geography_id].get(CONF_CITY),
if self.airvisual.show_on_map: ATTR_STATE: airvisual.data[geography_id].get(CONF_STATE),
self._attrs[ATTR_LATITUDE] = self.airvisual.latitude ATTR_COUNTRY: airvisual.data[geography_id].get(CONF_COUNTRY),
self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude }
else:
self._attrs["lati"] = self.airvisual.latitude
self._attrs["long"] = self.airvisual.longitude
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 @property
def available(self): def available(self):
"""Return True if entity is available.""" """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 @property
def icon(self): def icon(self):
@ -194,22 +152,33 @@ class AirVisualSensor(Entity):
@property @property
def unique_id(self): def unique_id(self):
"""Return a unique, Home Assistant friendly identifier for this entity.""" """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 @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit the value is expressed in.""" """Return the unit the value is expressed in."""
return self._unit 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): async def async_update(self):
"""Update the sensor.""" """Update the sensor."""
await self.airvisual.async_update() try:
data = self.airvisual.pollution_info data = self._airvisual.data[self._geography_id]["current"]["pollution"]
except KeyError:
if not data:
return return
if self._type == SENSOR_TYPE_LEVEL: if self._kind == SENSOR_KIND_LEVEL:
aqi = data[f"aqi{self._locale}"] aqi = data[f"aqi{self._locale}"]
[level] = [ [level] = [
i i
@ -218,9 +187,9 @@ class AirVisualSensor(Entity):
] ]
self._state = level["label"] self._state = level["label"]
self._icon = level["icon"] self._icon = level["icon"]
elif self._type == SENSOR_TYPE_AQI: elif self._kind == SENSOR_KIND_AQI:
self._state = data[f"aqi{self._locale}"] 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}"] symbol = data[f"main{self._locale}"]
self._state = POLLUTANT_MAPPING[symbol]["label"] self._state = POLLUTANT_MAPPING[symbol]["label"]
self._attrs.update( self._attrs.update(
@ -230,43 +199,8 @@ class AirVisualSensor(Entity):
} }
) )
async def async_will_remove_from_hass(self) -> None:
class AirVisualData: """Disconnect dispatcher listener when removed."""
"""Define an object to hold sensor data.""" for cancel in self._async_unsub_dispatcher_connects:
cancel()
def __init__(self, client, **kwargs): self._async_unsub_dispatcher_connects = []
"""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 = {}

View file

@ -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."
}
}
}

View file

@ -9,6 +9,7 @@ FLOWS = [
"abode", "abode",
"adguard", "adguard",
"airly", "airly",
"airvisual",
"almond", "almond",
"ambiclimate", "ambiclimate",
"ambient_station", "ambient_station",

View file

@ -418,6 +418,9 @@ py_nextbusnext==0.1.4
# homeassistant.components.hisense_aehw4a1 # homeassistant.components.hisense_aehw4a1
pyaehw4a1==0.3.4 pyaehw4a1==0.3.4
# homeassistant.components.airvisual
pyairvisual==3.0.1
# homeassistant.components.almond # homeassistant.components.almond
pyalmond==0.0.2 pyalmond==0.0.2

View file

@ -0,0 +1 @@
"""Define tests for the AirVisual component."""

View file

@ -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}],
}