Add support for AirVisual Node/Pro units (#32815)

* Add support for AirVisual Node Pro units

* Fixed tests

* Updated dependencies

* Guard looks cleaner

* Limit options update to geography-based entries

* Docstring

* Use proper precision in display_temp

* Add availability for AirVisualNodeProSensor

* Updated translations

* Samba stuff in play

* Wrap up Samba

* Fix tests

* Remove unnecessary updates

* Normalize labels

* Bump requirements

* Don't include configuration.yaml support for this new functionality

* Fix tests

* Code review

* Code review

* Update coveragerc

* Code review
This commit is contained in:
Aaron Bach 2020-04-22 17:41:14 -06:00 committed by GitHub
parent 4448eb94a1
commit 4d292c2723
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 672 additions and 187 deletions

View file

@ -21,6 +21,7 @@ omit =
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/__init__.py
homeassistant/components/airvisual/air_quality.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

@ -1,22 +1,29 @@
"""The airvisual component.""" """The airvisual component."""
import logging import asyncio
from datetime import timedelta
from pyairvisual import Client from pyairvisual import Client
from pyairvisual.errors import AirVisualError, InvalidKeyError from pyairvisual.errors import AirVisualError, NodeProError
import voluptuous as vol import voluptuous as vol
from homeassistant.config_entries import SOURCE_IMPORT from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION,
CONF_API_KEY, CONF_API_KEY,
CONF_IP_ADDRESS,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_PASSWORD,
CONF_SHOW_ON_MAP, CONF_SHOW_ON_MAP,
CONF_STATE, CONF_STATE,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers import aiohttp_client, config_validation as cv
from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.dispatcher import (
async_dispatcher_connect,
async_dispatcher_send,
)
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.event import async_track_time_interval from homeassistant.helpers.event import async_track_time_interval
from .const import ( from .const import (
@ -24,15 +31,20 @@ from .const import (
CONF_COUNTRY, CONF_COUNTRY,
CONF_GEOGRAPHIES, CONF_GEOGRAPHIES,
DATA_CLIENT, DATA_CLIENT,
DEFAULT_SCAN_INTERVAL,
DOMAIN, DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY,
INTEGRATION_TYPE_NODE_PRO,
LOGGER,
TOPIC_UPDATE, TOPIC_UPDATE,
) )
_LOGGER = logging.getLogger(__name__) PLATFORMS = ["air_quality", "sensor"]
DATA_LISTENER = "listener" DATA_LISTENER = "listener"
DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
DEFAULT_GEOGRAPHY_SCAN_INTERVAL = timedelta(minutes=10)
DEFAULT_NODE_PRO_SCAN_INTERVAL = timedelta(minutes=1)
DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True} DEFAULT_OPTIONS = {CONF_SHOW_ON_MAP: True}
GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema( GEOGRAPHY_COORDINATES_SCHEMA = vol.Schema(
@ -66,6 +78,9 @@ CONFIG_SCHEMA = vol.Schema({DOMAIN: CLOUD_API_SCHEMA}, extra=vol.ALLOW_EXTRA)
@callback @callback
def async_get_geography_id(geography_dict): def async_get_geography_id(geography_dict):
"""Generate a unique ID from a geography dict.""" """Generate a unique ID from a geography dict."""
if not geography_dict:
return
if CONF_CITY in geography_dict: if CONF_CITY in geography_dict:
return ", ".join( return ", ".join(
( (
@ -103,45 +118,58 @@ async def async_setup(hass, config):
return True return True
async def async_setup_entry(hass, config_entry): @callback
"""Set up AirVisual as config entry.""" def _standardize_geography_config_entry(hass, config_entry):
"""Ensure that geography observables have appropriate properties."""
entry_updates = {} entry_updates = {}
if not config_entry.unique_id: if not config_entry.unique_id:
# If the config entry doesn't already have a unique ID, set one: # If the config entry doesn't already have a unique ID, set one:
entry_updates["unique_id"] = config_entry.data[CONF_API_KEY] entry_updates["unique_id"] = config_entry.data[CONF_API_KEY]
if not config_entry.options: if not config_entry.options:
# If the config entry doesn't already have any options set, set defaults: # If the config entry doesn't already have any options set, set defaults:
entry_updates["options"] = DEFAULT_OPTIONS entry_updates["options"] = {CONF_SHOW_ON_MAP: True}
if entry_updates: if not entry_updates:
hass.config_entries.async_update_entry(config_entry, **entry_updates) return
hass.config_entries.async_update_entry(config_entry, **entry_updates)
async def async_setup_entry(hass, config_entry):
"""Set up AirVisual as config entry."""
websession = aiohttp_client.async_get_clientsession(hass) websession = aiohttp_client.async_get_clientsession(hass)
hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = AirVisualData( if CONF_API_KEY in config_entry.data:
hass, Client(websession, api_key=config_entry.data[CONF_API_KEY]), config_entry _standardize_geography_config_entry(hass, config_entry)
) airvisual = AirVisualGeographyData(
hass,
Client(websession, api_key=config_entry.data[CONF_API_KEY]),
config_entry,
)
try: # Only geography-based entries have options:
await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update() config_entry.add_update_listener(async_update_options)
except InvalidKeyError: else:
_LOGGER.error("Invalid API key provided") airvisual = AirVisualNodeProData(hass, Client(websession), config_entry)
raise ConfigEntryNotReady
hass.async_create_task( await airvisual.async_update()
hass.config_entries.async_forward_entry_setup(config_entry, "sensor")
) hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = airvisual
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(config_entry, component)
)
async def refresh(event_time): async def refresh(event_time):
"""Refresh data from AirVisual.""" """Refresh data from AirVisual."""
await hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id].async_update() await airvisual.async_update()
hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval( hass.data[DOMAIN][DATA_LISTENER][config_entry.entry_id] = async_track_time_interval(
hass, refresh, DEFAULT_SCAN_INTERVAL hass, refresh, airvisual.scan_interval
) )
config_entry.add_update_listener(async_update_options)
return True return True
@ -149,7 +177,7 @@ async def async_migrate_entry(hass, config_entry):
"""Migrate an old config entry.""" """Migrate an old config entry."""
version = config_entry.version version = config_entry.version
_LOGGER.debug("Migrating from version %s", version) LOGGER.debug("Migrating from version %s", version)
# 1 -> 2: One geography per config entry # 1 -> 2: One geography per config entry
if version == 1: if version == 1:
@ -178,21 +206,27 @@ async def async_migrate_entry(hass, config_entry):
) )
) )
_LOGGER.info("Migration to version %s successful", version) LOGGER.info("Migration to version %s successful", version)
return True return True
async def async_unload_entry(hass, config_entry): async def async_unload_entry(hass, config_entry):
"""Unload an AirVisual config entry.""" """Unload an AirVisual config entry."""
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(config_entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id)
remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id)
remove_listener()
remove_listener = hass.data[DOMAIN][DATA_LISTENER].pop(config_entry.entry_id) return unload_ok
remove_listener()
await hass.config_entries.async_forward_entry_unload(config_entry, "sensor")
return True
async def async_update_options(hass, config_entry): async def async_update_options(hass, config_entry):
@ -201,7 +235,53 @@ async def async_update_options(hass, config_entry):
airvisual.async_update_options(config_entry.options) airvisual.async_update_options(config_entry.options)
class AirVisualData: class AirVisualEntity(Entity):
"""Define a generic AirVisual entity."""
def __init__(self, airvisual):
"""Initialize."""
self._airvisual = airvisual
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
self._icon = None
self._unit = None
@property
def device_state_attributes(self):
"""Return the device state attributes."""
return self._attrs
@property
def icon(self):
"""Return the icon."""
return self._icon
@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.update_from_latest_data()
self.async_write_ha_state()
self.async_on_remove(
async_dispatcher_connect(self.hass, self._airvisual.topic_update, update)
)
self.update_from_latest_data()
@callback
def update_from_latest_data(self):
"""Update the entity from the latest data."""
raise NotImplementedError
class AirVisualGeographyData:
"""Define a class to manage data from the AirVisual cloud API.""" """Define a class to manage data from the AirVisual cloud API."""
def __init__(self, hass, client, config_entry): def __init__(self, hass, client, config_entry):
@ -211,7 +291,10 @@ class AirVisualData:
self.data = {} self.data = {}
self.geography_data = config_entry.data self.geography_data = config_entry.data
self.geography_id = config_entry.unique_id self.geography_id = config_entry.unique_id
self.integration_type = INTEGRATION_TYPE_GEOGRAPHY
self.options = config_entry.options self.options = config_entry.options
self.scan_interval = DEFAULT_GEOGRAPHY_SCAN_INTERVAL
self.topic_update = TOPIC_UPDATE.format(config_entry.unique_id)
async def async_update(self): async def async_update(self):
"""Get new data for all locations from the AirVisual cloud API.""" """Get new data for all locations from the AirVisual cloud API."""
@ -229,14 +312,43 @@ class AirVisualData:
try: try:
self.data[self.geography_id] = await api_coro self.data[self.geography_id] = await api_coro
except AirVisualError as err: except AirVisualError as err:
_LOGGER.error("Error while retrieving data: %s", err) LOGGER.error("Error while retrieving data: %s", err)
self.data[self.geography_id] = {} self.data[self.geography_id] = {}
_LOGGER.debug("Received new data") LOGGER.debug("Received new geography data")
async_dispatcher_send(self._hass, TOPIC_UPDATE) async_dispatcher_send(self._hass, self.topic_update)
@callback @callback
def async_update_options(self, options): def async_update_options(self, options):
"""Update the data manager's options.""" """Update the data manager's options."""
self.options = options self.options = options
async_dispatcher_send(self._hass, TOPIC_UPDATE) async_dispatcher_send(self._hass, self.topic_update)
class AirVisualNodeProData:
"""Define a class to manage data from an AirVisual Node/Pro."""
def __init__(self, hass, client, config_entry):
"""Initialize."""
self._client = client
self._hass = hass
self._password = config_entry.data[CONF_PASSWORD]
self.data = {}
self.integration_type = INTEGRATION_TYPE_NODE_PRO
self.ip_address = config_entry.data[CONF_IP_ADDRESS]
self.scan_interval = DEFAULT_NODE_PRO_SCAN_INTERVAL
self.topic_update = TOPIC_UPDATE.format(config_entry.data[CONF_IP_ADDRESS])
async def async_update(self):
"""Get new data from the Node/Pro."""
try:
self.data = await self._client.node.from_samba(
self.ip_address, self._password, include_history=False
)
except NodeProError as err:
LOGGER.error("Error while retrieving Node/Pro data: %s", err)
self.data = {}
return
LOGGER.debug("Received new Node/Pro data")
async_dispatcher_send(self._hass, self.topic_update)

View file

@ -0,0 +1,117 @@
"""Support for AirVisual Node/Pro units."""
from homeassistant.components.air_quality import AirQualityEntity
from homeassistant.const import CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
from homeassistant.core import callback
from homeassistant.util import slugify
from . import AirVisualEntity
from .const import DATA_CLIENT, DOMAIN, INTEGRATION_TYPE_GEOGRAPHY
ATTR_HUMIDITY = "humidity"
ATTR_SENSOR_LIFE = "{0}_sensor_life"
ATTR_TREND = "{0}_trend"
ATTR_VOC = "voc"
async def async_setup_entry(hass, config_entry, async_add_entities):
"""Set up AirVisual air quality entities based on a config entry."""
airvisual = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
# Geography-based AirVisual integrations don't utilize this platform:
if airvisual.integration_type == INTEGRATION_TYPE_GEOGRAPHY:
return
async_add_entities([AirVisualNodeProSensor(airvisual)], True)
class AirVisualNodeProSensor(AirVisualEntity, AirQualityEntity):
"""Define a sensor for a AirVisual Node/Pro."""
def __init__(self, airvisual):
"""Initialize."""
super().__init__(airvisual)
self._icon = "mdi:chemical-weapon"
self._unit = CONCENTRATION_MICROGRAMS_PER_CUBIC_METER
@property
def air_quality_index(self):
"""Return the Air Quality Index (AQI)."""
if self._airvisual.data["current"]["settings"]["is_aqi_usa"]:
return self._airvisual.data["current"]["measurements"]["aqi_us"]
return self._airvisual.data["current"]["measurements"]["aqi_cn"]
@property
def available(self):
"""Return True if entity is available."""
return bool(self._airvisual.data)
@property
def carbon_dioxide(self):
"""Return the CO2 (carbon dioxide) level."""
return self._airvisual.data["current"]["measurements"].get("co2_ppm")
@property
def device_info(self):
"""Return device registry information for this entity."""
return {
"identifiers": {(DOMAIN, self._airvisual.data["current"]["serial_number"])},
"name": self._airvisual.data["current"]["settings"]["node_name"],
"manufacturer": "AirVisual",
"model": f'{self._airvisual.data["current"]["status"]["model"]}',
"sw_version": (
f'Version {self._airvisual.data["current"]["status"]["system_version"]}'
f'{self._airvisual.data["current"]["status"]["app_version"]}'
),
}
@property
def name(self):
"""Return the name."""
node_name = self._airvisual.data["current"]["settings"]["node_name"]
return f"{node_name} Node/Pro: Air Quality"
@property
def particulate_matter_2_5(self):
"""Return the particulate matter 2.5 level."""
return self._airvisual.data["current"]["measurements"].get("pm2_5")
@property
def particulate_matter_10(self):
"""Return the particulate matter 10 level."""
return self._airvisual.data["current"]["measurements"].get("pm1_0")
@property
def particulate_matter_0_1(self):
"""Return the particulate matter 0.1 level."""
return self._airvisual.data["current"]["measurements"].get("pm0_1")
@property
def unique_id(self):
"""Return a unique, Home Assistant friendly identifier for this entity."""
return self._airvisual.data["current"]["serial_number"]
@callback
def update_from_latest_data(self):
"""Update from the Node/Pro's data."""
trends = {
ATTR_TREND.format(slugify(pollutant)): trend
for pollutant, trend in self._airvisual.data["trends"].items()
}
if self._airvisual.data["current"]["settings"]["is_aqi_usa"]:
trends.pop(ATTR_TREND.format("aqi_cn"))
else:
trends.pop(ATTR_TREND.format("aqi_us"))
self._attrs.update(
{
ATTR_VOC: self._airvisual.data["current"]["measurements"].get("voc"),
**{
ATTR_SENSOR_LIFE.format(pollutant): lifespan
for pollutant, lifespan in self._airvisual.data["current"][
"status"
]["sensor_life"].items()
},
**trends,
}
)

View file

@ -2,21 +2,29 @@
import asyncio import asyncio
from pyairvisual import Client from pyairvisual import Client
from pyairvisual.errors import InvalidKeyError from pyairvisual.errors import InvalidKeyError, NodeProError
import voluptuous as vol import voluptuous as vol
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_IP_ADDRESS,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_PASSWORD,
CONF_SHOW_ON_MAP, CONF_SHOW_ON_MAP,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers import aiohttp_client, config_validation as cv
from . import async_get_geography_id from . import async_get_geography_id
from .const import CONF_GEOGRAPHIES, DOMAIN # pylint: disable=unused-import from .const import ( # pylint: disable=unused-import
CONF_GEOGRAPHIES,
DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY,
INTEGRATION_TYPE_NODE_PRO,
LOGGER,
)
class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
@ -26,7 +34,7 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
@property @property
def cloud_api_schema(self): def geography_schema(self):
"""Return the data schema for the cloud API.""" """Return the data schema for the cloud API."""
return vol.Schema( return vol.Schema(
{ {
@ -40,38 +48,47 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
} }
) )
@property
def pick_integration_type_schema(self):
"""Return the data schema for picking the integration type."""
return vol.Schema(
{
vol.Required("type"): vol.In(
[INTEGRATION_TYPE_GEOGRAPHY, INTEGRATION_TYPE_NODE_PRO]
)
}
)
@property
def node_pro_schema(self):
"""Return the data schema for a Node/Pro."""
return vol.Schema(
{vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_PASSWORD): str}
)
async def _async_set_unique_id(self, unique_id): async def _async_set_unique_id(self, unique_id):
"""Set the unique ID of the config flow and abort if it already exists.""" """Set the unique ID of the config flow and abort if it already exists."""
await self.async_set_unique_id(unique_id) await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured() 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 {},
)
@staticmethod @staticmethod
@callback @callback
def async_get_options_flow(config_entry): def async_get_options_flow(config_entry):
"""Define the config flow to handle options.""" """Define the config flow to handle options."""
return AirVisualOptionsFlowHandler(config_entry) return AirVisualOptionsFlowHandler(config_entry)
async def async_step_import(self, import_config): async def async_step_geography(self, user_input=None):
"""Import a config entry from configuration.yaml.""" """Handle the initialization of the integration via the cloud API."""
return await self.async_step_user(import_config)
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
if not user_input: if not user_input:
return await self._show_form() return self.async_show_form(
step_id="geography", data_schema=self.geography_schema
)
geo_id = async_get_geography_id(user_input) geo_id = async_get_geography_id(user_input)
await self._async_set_unique_id(geo_id) await self._async_set_unique_id(geo_id)
self._abort_if_unique_id_configured() self._abort_if_unique_id_configured()
# Find older config entries without unique ID # Find older config entries without unique ID:
for entry in self._async_current_entries(): for entry in self._async_current_entries():
if entry.version != 1: if entry.version != 1:
continue continue
@ -97,8 +114,10 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
try: try:
await client.api.nearest_city() await client.api.nearest_city()
except InvalidKeyError: except InvalidKeyError:
return await self._show_form( return self.async_show_form(
errors={CONF_API_KEY: "invalid_api_key"} step_id="geography",
data_schema=self.geography_schema,
errors={CONF_API_KEY: "invalid_api_key"},
) )
checked_keys.add(user_input[CONF_API_KEY]) checked_keys.add(user_input[CONF_API_KEY])
@ -107,6 +126,49 @@ class AirVisualFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
title=f"Cloud API ({geo_id})", data=user_input title=f"Cloud API ({geo_id})", data=user_input
) )
async def async_step_import(self, import_config):
"""Import a config entry from configuration.yaml."""
return await self.async_step_geography(import_config)
async def async_step_node_pro(self, user_input=None):
"""Handle the initialization of the integration with a Node/Pro."""
if not user_input:
return self.async_show_form(
step_id="node_pro", data_schema=self.node_pro_schema
)
await self._async_set_unique_id(user_input[CONF_IP_ADDRESS])
websession = aiohttp_client.async_get_clientsession(self.hass)
client = Client(websession)
try:
await client.node.from_samba(
user_input[CONF_IP_ADDRESS], user_input[CONF_PASSWORD]
)
except NodeProError as err:
LOGGER.error("Error connecting to Node/Pro unit: %s", err)
return self.async_show_form(
step_id="node_pro",
data_schema=self.node_pro_schema,
errors={CONF_IP_ADDRESS: "unable_to_connect"},
)
return self.async_create_entry(
title=f"Node/Pro ({user_input[CONF_IP_ADDRESS]})", data=user_input
)
async def async_step_user(self, user_input=None):
"""Handle the start of the config flow."""
if not user_input:
return self.async_show_form(
step_id="user", data_schema=self.pick_integration_type_schema
)
if user_input["type"] == INTEGRATION_TYPE_GEOGRAPHY:
return await self.async_step_geography()
return await self.async_step_node_pro()
class AirVisualOptionsFlowHandler(config_entries.OptionsFlow): class AirVisualOptionsFlowHandler(config_entries.OptionsFlow):
"""Handle an AirVisual options flow.""" """Handle an AirVisual options flow."""

View file

@ -1,7 +1,11 @@
"""Define AirVisual constants.""" """Define AirVisual constants."""
from datetime import timedelta import logging
DOMAIN = "airvisual" DOMAIN = "airvisual"
LOGGER = logging.getLogger(__package__)
INTEGRATION_TYPE_GEOGRAPHY = "Geographical Location"
INTEGRATION_TYPE_NODE_PRO = "AirVisual Node/Pro"
CONF_CITY = "city" CONF_CITY = "city"
CONF_COUNTRY = "country" CONF_COUNTRY = "country"
@ -9,6 +13,4 @@ CONF_GEOGRAPHIES = "geographies"
DATA_CLIENT = "client" DATA_CLIENT = "client"
DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) TOPIC_UPDATE = f"airvisual_update_{0}"
TOPIC_UPDATE = f"{DOMAIN}_update"

View file

@ -3,6 +3,6 @@
"name": "AirVisual", "name": "AirVisual",
"config_flow": true, "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==4.3.0"],
"codeowners": ["@bachya"] "codeowners": ["@bachya"]
} }

View file

@ -2,7 +2,6 @@
from logging import getLogger from logging import getLogger
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_LATITUDE, ATTR_LATITUDE,
ATTR_LONGITUDE, ATTR_LONGITUDE,
ATTR_STATE, ATTR_STATE,
@ -13,12 +12,22 @@ from homeassistant.const import (
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_SHOW_ON_MAP, CONF_SHOW_ON_MAP,
CONF_STATE, CONF_STATE,
DEVICE_CLASS_BATTERY,
DEVICE_CLASS_HUMIDITY,
DEVICE_CLASS_TEMPERATURE,
TEMP_CELSIUS,
UNIT_PERCENTAGE,
) )
from homeassistant.core import callback from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
from .const import CONF_CITY, CONF_COUNTRY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE from . import AirVisualEntity
from .const import (
CONF_CITY,
CONF_COUNTRY,
DATA_CLIENT,
DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY,
)
_LOGGER = getLogger(__name__) _LOGGER = getLogger(__name__)
@ -28,8 +37,6 @@ ATTR_POLLUTANT_SYMBOL = "pollutant_symbol"
ATTR_POLLUTANT_UNIT = "pollutant_unit" ATTR_POLLUTANT_UNIT = "pollutant_unit"
ATTR_REGION = "region" ATTR_REGION = "region"
DEFAULT_ATTRIBUTION = "Data provided by AirVisual"
MASS_PARTS_PER_MILLION = "ppm" MASS_PARTS_PER_MILLION = "ppm"
MASS_PARTS_PER_BILLION = "ppb" MASS_PARTS_PER_BILLION = "ppb"
VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3" VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3"
@ -37,11 +44,22 @@ VOLUME_MICROGRAMS_PER_CUBIC_METER = "µg/m3"
SENSOR_KIND_LEVEL = "air_pollution_level" SENSOR_KIND_LEVEL = "air_pollution_level"
SENSOR_KIND_AQI = "air_quality_index" SENSOR_KIND_AQI = "air_quality_index"
SENSOR_KIND_POLLUTANT = "main_pollutant" SENSOR_KIND_POLLUTANT = "main_pollutant"
SENSORS = [ SENSOR_KIND_BATTERY_LEVEL = "battery_level"
SENSOR_KIND_HUMIDITY = "humidity"
SENSOR_KIND_TEMPERATURE = "temperature"
GEOGRAPHY_SENSORS = [
(SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None), (SENSOR_KIND_LEVEL, "Air Pollution Level", "mdi:gauge", None),
(SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"), (SENSOR_KIND_AQI, "Air Quality Index", "mdi:chart-line", "AQI"),
(SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None), (SENSOR_KIND_POLLUTANT, "Main Pollutant", "mdi:chemical-weapon", None),
] ]
GEOGRAPHY_SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."}
NODE_PRO_SENSORS = [
(SENSOR_KIND_BATTERY_LEVEL, "Battery", DEVICE_CLASS_BATTERY, UNIT_PERCENTAGE),
(SENSOR_KIND_HUMIDITY, "Humidity", DEVICE_CLASS_HUMIDITY, UNIT_PERCENTAGE),
(SENSOR_KIND_TEMPERATURE, "Temperature", DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS),
]
POLLUTANT_LEVEL_MAPPING = [ POLLUTANT_LEVEL_MAPPING = [
{"label": "Good", "icon": "mdi:emoticon-excited", "minimum": 0, "maximum": 50}, {"label": "Good", "icon": "mdi:emoticon-excited", "minimum": 0, "maximum": 50},
@ -71,44 +89,64 @@ POLLUTANT_MAPPING = {
"s2": {"label": "Sulfur Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION}, "s2": {"label": "Sulfur Dioxide", "unit": CONCENTRATION_PARTS_PER_BILLION},
} }
SENSOR_LOCALES = {"cn": "Chinese", "us": "U.S."}
async def async_setup_entry(hass, config_entry, async_add_entities):
async def async_setup_entry(hass, entry, async_add_entities):
"""Set up AirVisual sensors based on a config entry.""" """Set up AirVisual sensors based on a config entry."""
airvisual = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] airvisual = hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id]
async_add_entities( if airvisual.integration_type == INTEGRATION_TYPE_GEOGRAPHY:
[ sensors = [
AirVisualSensor(airvisual, kind, name, icon, unit, locale, geography_id) AirVisualGeographySensor(
airvisual, kind, name, icon, unit, locale, geography_id,
)
for geography_id in airvisual.data for geography_id in airvisual.data
for locale in SENSOR_LOCALES for locale in GEOGRAPHY_SENSOR_LOCALES
for kind, name, icon, unit in SENSORS for kind, name, icon, unit in GEOGRAPHY_SENSORS
], ]
True, else:
) sensors = [
AirVisualNodeProSensor(airvisual, kind, name, device_class, unit)
for kind, name, device_class, unit in NODE_PRO_SENSORS
]
async_add_entities(sensors, True)
class AirVisualSensor(Entity): class AirVisualSensor(AirVisualEntity):
"""Define an AirVisual sensor.""" """Define a generic AirVisual sensor."""
def __init__(self, airvisual, kind, name, icon, unit, locale, geography_id): def __init__(self, airvisual, kind, name, unit):
"""Initialize.""" """Initialize."""
self._airvisual = airvisual super().__init__(airvisual)
self._geography_id = geography_id
self._icon = icon
self._kind = kind self._kind = kind
self._locale = locale
self._name = name self._name = name
self._state = None self._state = None
self._unit = unit self._unit = unit
self._attrs = { @property
ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION, def state(self):
ATTR_CITY: airvisual.data[geography_id].get(CONF_CITY), """Return the state."""
ATTR_STATE: airvisual.data[geography_id].get(CONF_STATE), return self._state
ATTR_COUNTRY: airvisual.data[geography_id].get(CONF_COUNTRY),
}
class AirVisualGeographySensor(AirVisualSensor):
"""Define an AirVisual sensor related to geography data via the Cloud API."""
def __init__(self, airvisual, kind, name, icon, unit, locale, geography_id):
"""Initialize."""
super().__init__(airvisual, kind, name, unit)
self._attrs.update(
{
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),
}
)
self._geography_id = geography_id
self._icon = icon
self._locale = locale
@property @property
def available(self): def available(self):
@ -120,47 +158,18 @@ class AirVisualSensor(Entity):
except KeyError: except KeyError:
return False return False
@property
def device_state_attributes(self):
"""Return the device state attributes."""
return self._attrs
@property
def icon(self):
"""Return the icon."""
return self._icon
@property @property
def name(self): def name(self):
"""Return the name.""" """Return the name."""
return f"{SENSOR_LOCALES[self._locale]} {self._name}" return f"{GEOGRAPHY_SENSOR_LOCALES[self._locale]} {self._name}"
@property
def state(self):
"""Return the state."""
return self._state
@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._geography_id}_{self._locale}_{self._kind}" return f"{self._geography_id}_{self._locale}_{self._kind}"
@property @callback
def unit_of_measurement(self): def update_from_latest_data(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_on_remove(async_dispatcher_connect(self.hass, TOPIC_UPDATE, update))
async def async_update(self):
"""Update the sensor.""" """Update the sensor."""
try: try:
data = self._airvisual.data[self._geography_id]["current"]["pollution"] data = self._airvisual.data[self._geography_id]["current"]["pollution"]
@ -203,3 +212,62 @@ class AirVisualSensor(Entity):
self._attrs["long"] = self._airvisual.geography_data[CONF_LONGITUDE] self._attrs["long"] = self._airvisual.geography_data[CONF_LONGITUDE]
self._attrs.pop(ATTR_LATITUDE, None) self._attrs.pop(ATTR_LATITUDE, None)
self._attrs.pop(ATTR_LONGITUDE, None) self._attrs.pop(ATTR_LONGITUDE, None)
class AirVisualNodeProSensor(AirVisualSensor):
"""Define an AirVisual sensor related to a Node/Pro unit."""
def __init__(self, airvisual, kind, name, device_class, unit):
"""Initialize."""
super().__init__(airvisual, kind, name, unit)
self._device_class = device_class
@property
def available(self):
"""Return True if entity is available."""
return bool(self._airvisual.data)
@property
def device_class(self):
"""Return the device class."""
return self._device_class
@property
def device_info(self):
"""Return device registry information for this entity."""
return {
"identifiers": {(DOMAIN, self._airvisual.data["current"]["serial_number"])},
"name": self._airvisual.data["current"]["settings"]["node_name"],
"manufacturer": "AirVisual",
"model": f'{self._airvisual.data["current"]["status"]["model"]}',
"sw_version": (
f'Version {self._airvisual.data["current"]["status"]["system_version"]}'
f'{self._airvisual.data["current"]["status"]["app_version"]}'
),
}
@property
def name(self):
"""Return the name."""
node_name = self._airvisual.data["current"]["settings"]["node_name"]
return f"{node_name} Node/Pro: {self._name}"
@property
def unique_id(self):
"""Return a unique, Home Assistant friendly identifier for this entity."""
return f"{self._airvisual.data['current']['serial_number']}_{self._kind}"
@callback
def update_from_latest_data(self):
"""Update from the Node/Pro's data."""
if self._kind == SENSOR_KIND_BATTERY_LEVEL:
self._state = self._airvisual.data["current"]["status"]["battery"]
elif self._kind == SENSOR_KIND_HUMIDITY:
self._state = self._airvisual.data["current"]["measurements"].get(
"humidity"
)
elif self._kind == SENSOR_KIND_TEMPERATURE:
self._state = self._airvisual.data["current"]["measurements"].get(
"temperature_C"
)

View file

@ -1,27 +1,49 @@
{ {
"config": { "config": {
"step": { "step": {
"user": { "geography": {
"title": "Configure AirVisual", "title": "Configure a Geography",
"description": "Monitor air quality in a geographical location.", "description": "Use the AirVisual cloud API to monitor a geographical location.",
"data": { "data": {
"api_key": "API Key", "api_key": "API Key",
"latitude": "Latitude", "latitude": "Latitude",
"longitude": "Longitude" "longitude": "Longitude"
} }
},
"node_pro": {
"title": "Configure an AirVisual Node/Pro",
"description": "Monitor a personal AirVisual unit. The password can be retrieved from the unit's UI.",
"data": {
"ip_address": "Unit IP Address/Hostname",
"password": "Unit Password"
}
},
"user": {
"title": "Configure AirVisual",
"description": "Pick what type of AirVisual data you want to monitor.",
"data": {
"cloud_api": "Geographical Location",
"node_pro": "AirVisual Node Pro",
"type": "Integration Type"
}
} }
}, },
"error": { "invalid_api_key": "Invalid API key" }, "error": {
"general_error": "There was an unknown error.",
"invalid_api_key": "Invalid API key provided.",
"unable_to_connect": "Unable to connect to Node/Pro unit."
},
"abort": { "abort": {
"already_configured": "These coordinates have already been registered." "already_configured": "These coordinates or Node/Pro ID are already registered."
} }
}, },
"options": { "options": {
"step": { "step": {
"init": { "init": {
"title": "Configure AirVisual", "title": "Configure AirVisual",
"description": "Set various options for the AirVisual integration.", "data": {
"data": { "show_on_map": "Show monitored geography on the map" } "show_on_map": "Show monitored geography on the map"
}
} }
} }
} }

View file

@ -1,19 +1,38 @@
{ {
"config": { "config": {
"abort": { "abort": {
"already_configured": "These coordinates have already been registered." "already_configured": "These coordinates or Node/Pro ID are already registered."
}, },
"error": { "error": {
"invalid_api_key": "Invalid API key" "general_error": "There was an unknown error.",
"invalid_api_key": "Invalid API key provided.",
"unable_to_connect": "Unable to connect to Node/Pro unit."
}, },
"step": { "step": {
"user": { "geography": {
"data": { "data": {
"api_key": "API Key", "api_key": "API Key",
"latitude": "Latitude", "latitude": "Latitude",
"longitude": "Longitude" "longitude": "Longitude"
}, },
"description": "Monitor air quality in a geographical location.", "description": "Use the AirVisual cloud API to monitor a geographical location.",
"title": "Configure a Geography"
},
"node_pro": {
"data": {
"ip_address": "Unit IP Address/Hostname",
"password": "Unit Password"
},
"description": "Monitor a personal AirVisual unit. The password can be retrieved from the unit's UI.",
"title": "Configure an AirVisual Node/Pro"
},
"user": {
"data": {
"cloud_api": "Geographical Location",
"node_pro": "AirVisual Node Pro",
"type": "Integration Type"
},
"description": "Pick what type of AirVisual data you want to monitor.",
"title": "Configure AirVisual" "title": "Configure AirVisual"
} }
} }
@ -24,7 +43,6 @@
"data": { "data": {
"show_on_map": "Show monitored geography on the map" "show_on_map": "Show monitored geography on the map"
}, },
"description": "Set various options for the AirVisual integration.",
"title": "Configure AirVisual" "title": "Configure AirVisual"
} }
} }

View file

@ -1173,7 +1173,7 @@ pyaehw4a1==0.3.4
pyaftership==0.1.2 pyaftership==0.1.2
# homeassistant.components.airvisual # homeassistant.components.airvisual
pyairvisual==3.0.1 pyairvisual==4.3.0
# homeassistant.components.almond # homeassistant.components.almond
pyalmond==0.0.2 pyalmond==0.0.2

View file

@ -470,7 +470,7 @@ py_nextbusnext==0.1.4
pyaehw4a1==0.3.4 pyaehw4a1==0.3.4
# homeassistant.components.airvisual # homeassistant.components.airvisual
pyairvisual==3.0.1 pyairvisual==4.3.0
# homeassistant.components.almond # homeassistant.components.almond
pyalmond==0.0.2 pyalmond==0.0.2

View file

@ -1,14 +1,21 @@
"""Define tests for the AirVisual config flow.""" """Define tests for the AirVisual config flow."""
from asynctest import patch from asynctest import patch
from pyairvisual.errors import InvalidKeyError from pyairvisual.errors import InvalidKeyError, NodeProError
from homeassistant import data_entry_flow from homeassistant import data_entry_flow
from homeassistant.components.airvisual import CONF_GEOGRAPHIES, DOMAIN from homeassistant.components.airvisual import (
CONF_GEOGRAPHIES,
DOMAIN,
INTEGRATION_TYPE_GEOGRAPHY,
INTEGRATION_TYPE_NODE_PRO,
)
from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER
from homeassistant.const import ( from homeassistant.const import (
CONF_API_KEY, CONF_API_KEY,
CONF_IP_ADDRESS,
CONF_LATITUDE, CONF_LATITUDE,
CONF_LONGITUDE, CONF_LONGITUDE,
CONF_PASSWORD,
CONF_SHOW_ON_MAP, CONF_SHOW_ON_MAP,
) )
from homeassistant.setup import async_setup_component from homeassistant.setup import async_setup_component
@ -17,28 +24,43 @@ from tests.common import MockConfigEntry
async def test_duplicate_error(hass): async def test_duplicate_error(hass):
"""Test that errors are shown when duplicates are added.""" """Test that errors are shown when duplicate entries are added."""
conf = { geography_conf = {
CONF_API_KEY: "abcde12345", CONF_API_KEY: "abcde12345",
CONF_LATITUDE: 51.528308, CONF_LATITUDE: 51.528308,
CONF_LONGITUDE: -0.3817765, CONF_LONGITUDE: -0.3817765,
} }
node_pro_conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "12345"}
MockConfigEntry( MockConfigEntry(
domain=DOMAIN, unique_id="51.528308, -0.3817765", data=conf domain=DOMAIN, unique_id="51.528308, -0.3817765", data=geography_conf
).add_to_hass(hass) ).add_to_hass(hass)
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf DOMAIN, context={"source": SOURCE_IMPORT}, data=geography_conf
)
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured"
MockConfigEntry(
domain=DOMAIN, unique_id="192.168.1.100", data=node_pro_conf
).add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=node_pro_conf
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT
assert result["reason"] == "already_configured" assert result["reason"] == "already_configured"
async def test_invalid_api_key(hass): async def test_invalid_identifier(hass):
"""Test that invalid credentials throws an error.""" """Test that an invalid API key or Node/Pro ID throws an error."""
conf = { geography_conf = {
CONF_API_KEY: "abcde12345", CONF_API_KEY: "abcde12345",
CONF_LATITUDE: 51.528308, CONF_LATITUDE: 51.528308,
CONF_LONGITUDE: -0.3817765, CONF_LONGITUDE: -0.3817765,
@ -48,11 +70,29 @@ async def test_invalid_api_key(hass):
"pyairvisual.api.API.nearest_city", side_effect=InvalidKeyError, "pyairvisual.api.API.nearest_city", side_effect=InvalidKeyError,
): ):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf DOMAIN, context={"source": SOURCE_IMPORT}, data=geography_conf
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_API_KEY: "invalid_api_key"} assert result["errors"] == {CONF_API_KEY: "invalid_api_key"}
async def test_node_pro_error(hass):
"""Test that an invalid Node/Pro ID shows an error."""
node_pro_conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "my_password"}
with patch(
"pyairvisual.node.Node.from_samba", side_effect=NodeProError,
):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=node_pro_conf
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["errors"] == {CONF_IP_ADDRESS: "unable_to_connect"}
async def test_migration_1_2(hass): async def test_migration_1_2(hass):
"""Test migrating from version 1 to version 2.""" """Test migrating from version 1 to version 2."""
conf = { conf = {
@ -96,12 +136,16 @@ async def test_migration_1_2(hass):
async def test_options_flow(hass): async def test_options_flow(hass):
"""Test config flow options.""" """Test config flow options."""
conf = {CONF_API_KEY: "abcde12345"} geography_conf = {
CONF_API_KEY: "abcde12345",
CONF_LATITUDE: 51.528308,
CONF_LONGITUDE: -0.3817765,
}
config_entry = MockConfigEntry( config_entry = MockConfigEntry(
domain=DOMAIN, domain=DOMAIN,
unique_id="abcde12345", unique_id="51.528308, -0.3817765",
data=conf, data=geography_conf,
options={CONF_SHOW_ON_MAP: True}, options={CONF_SHOW_ON_MAP: True},
) )
config_entry.add_to_hass(hass) config_entry.add_to_hass(hass)
@ -122,18 +166,8 @@ async def test_options_flow(hass):
assert config_entry.options == {CONF_SHOW_ON_MAP: False} assert config_entry.options == {CONF_SHOW_ON_MAP: False}
async def test_show_form(hass): async def test_step_geography(hass):
"""Test that the form is served with no input.""" """Test the geograph (cloud API) step."""
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 = {
CONF_API_KEY: "abcde12345", CONF_API_KEY: "abcde12345",
CONF_LATITUDE: 51.528308, CONF_LATITUDE: 51.528308,
@ -146,6 +180,50 @@ async def test_step_import(hass):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=conf DOMAIN, context={"source": SOURCE_IMPORT}, data=conf
) )
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Cloud API (51.528308, -0.3817765)"
assert result["data"] == {
CONF_API_KEY: "abcde12345",
CONF_LATITUDE: 51.528308,
CONF_LONGITUDE: -0.3817765,
}
async def test_step_node_pro(hass):
"""Test the Node/Pro step."""
conf = {CONF_IP_ADDRESS: "192.168.1.100", CONF_PASSWORD: "my_password"}
with patch(
"homeassistant.components.airvisual.async_setup_entry", return_value=True
), patch("pyairvisual.node.Node.from_samba"):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data={"type": "AirVisual Node/Pro"}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input=conf
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Node/Pro (192.168.1.100)"
assert result["data"] == {
CONF_IP_ADDRESS: "192.168.1.100",
CONF_PASSWORD: "my_password",
}
async def test_step_import(hass):
"""Test the import step for both types of configuration."""
geography_conf = {
CONF_API_KEY: "abcde12345",
CONF_LATITUDE: 51.528308,
CONF_LONGITUDE: -0.3817765,
}
with patch(
"homeassistant.components.airvisual.async_setup_entry", return_value=True
), patch("pyairvisual.api.API.nearest_city"):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_IMPORT}, data=geography_conf
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == "Cloud API (51.528308, -0.3817765)" assert result["title"] == "Cloud API (51.528308, -0.3817765)"
@ -157,23 +235,28 @@ async def test_step_import(hass):
async def test_step_user(hass): async def test_step_user(hass):
"""Test that the user step works.""" """Test the user ("pick the integration type") step."""
conf = { result = await hass.config_entries.flow.async_init(
CONF_API_KEY: "abcde12345", DOMAIN, context={"source": SOURCE_USER}
CONF_LATITUDE: 32.87336, )
CONF_LONGITUDE: -117.22743,
}
with patch( assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
"homeassistant.components.airvisual.async_setup_entry", return_value=True assert result["step_id"] == "user"
), patch("pyairvisual.api.API.nearest_city"):
result = await hass.config_entries.flow.async_init( result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}, data=conf DOMAIN,
) context={"source": SOURCE_USER},
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY data={"type": INTEGRATION_TYPE_GEOGRAPHY},
assert result["title"] == "Cloud API (32.87336, -117.22743)" )
assert result["data"] == {
CONF_API_KEY: "abcde12345", assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
CONF_LATITUDE: 32.87336, assert result["step_id"] == "geography"
CONF_LONGITUDE: -117.22743,
} result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_USER},
data={"type": INTEGRATION_TYPE_NODE_PRO},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "node_pro"