Add config flow to WAQI (#98220)

* Migrate WAQI to aiowaqi library

* Migrate WAQI to aiowaqi library

* Migrate WAQI to aiowaqi library

* Add config flow to WAQI

* Finish config flow

* Add tests

* Add tests

* Fix ruff

* Add issues on failing to import

* Add issues on failing to import

* Add issues on failing to import

* Add importing issue

* Finish coverage

* Remove url from translation string

* Fix feedback

* Fix feedback
This commit is contained in:
Joost Lekkerkerker 2023-09-09 17:49:54 +02:00 committed by GitHub
parent fdddbd7363
commit 9be16d9d42
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 822 additions and 76 deletions

View file

@ -1391,7 +1391,8 @@ build.json @home-assistant/supervisor
/tests/components/wake_word/ @home-assistant/core @synesthesiam /tests/components/wake_word/ @home-assistant/core @synesthesiam
/homeassistant/components/wallbox/ @hesselonline /homeassistant/components/wallbox/ @hesselonline
/tests/components/wallbox/ @hesselonline /tests/components/wallbox/ @hesselonline
/homeassistant/components/waqi/ @andrey-git /homeassistant/components/waqi/ @joostlek
/tests/components/waqi/ @joostlek
/homeassistant/components/water_heater/ @home-assistant/core /homeassistant/components/water_heater/ @home-assistant/core
/tests/components/water_heater/ @home-assistant/core /tests/components/water_heater/ @home-assistant/core
/homeassistant/components/watson_tts/ @rutkai /homeassistant/components/watson_tts/ @rutkai

View file

@ -1 +1,37 @@
"""The waqi component.""" """The World Air Quality Index (WAQI) integration."""
from __future__ import annotations
from aiowaqi import WAQIClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
from .coordinator import WAQIDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up World Air Quality Index (WAQI) from a config entry."""
client = WAQIClient(session=async_get_clientsession(hass))
client.authenticate(entry.data[CONF_API_KEY])
waqi_coordinator = WAQIDataUpdateCoordinator(hass, client)
await waqi_coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = waqi_coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok

View file

@ -0,0 +1,135 @@
"""Config flow for World Air Quality Index (WAQI) integration."""
from __future__ import annotations
import logging
from typing import Any
from aiowaqi import (
WAQIAirQuality,
WAQIAuthenticationError,
WAQIClient,
WAQIConnectionError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_NAME,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN
from homeassistant.data_entry_flow import AbortFlow, FlowResult
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.selector import LocationSelector
from homeassistant.helpers.typing import ConfigType
from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER
_LOGGER = logging.getLogger(__name__)
class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for World Air Quality Index (WAQI)."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
async with WAQIClient(
session=async_get_clientsession(self.hass)
) as waqi_client:
waqi_client.authenticate(user_input[CONF_API_KEY])
location = user_input[CONF_LOCATION]
try:
measuring_station: WAQIAirQuality = (
await waqi_client.get_by_coordinates(
location[CONF_LATITUDE], location[CONF_LONGITUDE]
)
)
except WAQIAuthenticationError:
errors["base"] = "invalid_auth"
except WAQIConnectionError:
errors["base"] = "cannot_connect"
except Exception as exc: # pylint: disable=broad-except
_LOGGER.exception(exc)
errors["base"] = "unknown"
else:
await self.async_set_unique_id(str(measuring_station.station_id))
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=measuring_station.city.name,
data={
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_STATION_NUMBER: measuring_station.station_id,
},
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(
CONF_LOCATION,
): LocationSelector(),
}
),
user_input
or {
CONF_LOCATION: {
CONF_LATITUDE: self.hass.config.latitude,
CONF_LONGITUDE: self.hass.config.longitude,
}
},
),
errors=errors,
)
async def async_step_import(self, import_config: ConfigType) -> FlowResult:
"""Handle importing from yaml."""
await self.async_set_unique_id(str(import_config[CONF_STATION_NUMBER]))
try:
self._abort_if_unique_id_configured()
except AbortFlow as exc:
async_create_issue(
self.hass,
DOMAIN,
"deprecated_yaml_import_issue_already_configured",
breaks_in_ha_version="2024.4.0",
is_fixable=False,
severity=IssueSeverity.ERROR,
translation_key="deprecated_yaml_import_issue_already_configured",
translation_placeholders=ISSUE_PLACEHOLDER,
)
raise exc
async_create_issue(
self.hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2024.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "World Air Quality Index",
},
)
return self.async_create_entry(
title=import_config[CONF_NAME],
data={
CONF_API_KEY: import_config[CONF_API_KEY],
CONF_STATION_NUMBER: import_config[CONF_STATION_NUMBER],
},
)

View file

@ -0,0 +1,10 @@
"""Constants for the World Air Quality Index (WAQI) integration."""
import logging
DOMAIN = "waqi"
LOGGER = logging.getLogger(__package__)
CONF_STATION_NUMBER = "station_number"
ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=waqi"}

View file

@ -0,0 +1,36 @@
"""Coordinator for the World Air Quality Index (WAQI) integration."""
from __future__ import annotations
from datetime import timedelta
from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER
class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]):
"""The WAQI Data Update Coordinator."""
config_entry: ConfigEntry
def __init__(self, hass: HomeAssistant, client: WAQIClient) -> None:
"""Initialize the WAQI data coordinator."""
super().__init__(
hass,
LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
)
self._client = client
async def _async_update_data(self) -> WAQIAirQuality:
try:
return await self._client.get_by_station_number(
self.config_entry.data[CONF_STATION_NUMBER]
)
except WAQIError as exc:
raise UpdateFailed from exc

View file

@ -1,7 +1,8 @@
{ {
"domain": "waqi", "domain": "waqi",
"name": "World Air Quality Index (WAQI)", "name": "World Air Quality Index (WAQI)",
"codeowners": ["@andrey-git"], "codeowners": ["@joostlek"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/waqi", "documentation": "https://www.home-assistant.io/integrations/waqi",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["waqiasync"], "loggers": ["waqiasync"],

View file

@ -1,10 +1,9 @@
"""Support for the World Air Quality Index service.""" """Support for the World Air Quality Index service."""
from __future__ import annotations from __future__ import annotations
from datetime import timedelta
import logging import logging
from aiowaqi import WAQIAirQuality, WAQIClient, WAQIConnectionError, WAQISearchResult from aiowaqi import WAQIAuthenticationError, WAQIClient, WAQIConnectionError
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import ( from homeassistant.components.sensor import (
@ -12,10 +11,13 @@ from homeassistant.components.sensor import (
SensorEntity, SensorEntity,
SensorStateClass, SensorStateClass,
) )
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_ATTRIBUTION,
ATTR_TEMPERATURE, ATTR_TEMPERATURE,
ATTR_TIME, ATTR_TIME,
CONF_API_KEY,
CONF_NAME,
CONF_TOKEN, CONF_TOKEN,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
@ -23,7 +25,12 @@ from homeassistant.exceptions import PlatformNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.aiohttp_client import async_get_clientsession
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER
from .coordinator import WAQIDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
@ -43,8 +50,6 @@ ATTR_ICON = "mdi:cloud"
CONF_LOCATIONS = "locations" CONF_LOCATIONS = "locations"
CONF_STATIONS = "stations" CONF_STATIONS = "stations"
SCAN_INTERVAL = timedelta(minutes=5)
TIMEOUT = 10 TIMEOUT = 10
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend(
@ -70,102 +75,126 @@ async def async_setup_platform(
client = WAQIClient(session=async_get_clientsession(hass), request_timeout=TIMEOUT) client = WAQIClient(session=async_get_clientsession(hass), request_timeout=TIMEOUT)
client.authenticate(token) client.authenticate(token)
dev = [] station_count = 0
try: try:
for location_name in locations: for location_name in locations:
stations = await client.search(location_name) stations = await client.search(location_name)
_LOGGER.debug("The following stations were returned: %s", stations) _LOGGER.debug("The following stations were returned: %s", stations)
for station in stations: for station in stations:
waqi_sensor = WaqiSensor(client, station) station_count = station_count + 1
if not station_filter or { if not station_filter or {
waqi_sensor.uid, station.station_id,
waqi_sensor.url, station.station.external_url,
waqi_sensor.station_name, station.station.name,
} & set(station_filter): } & set(station_filter):
dev.append(waqi_sensor) hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_STATION_NUMBER: station.station_id,
CONF_NAME: station.station.name,
CONF_API_KEY: config[CONF_TOKEN],
},
)
)
except WAQIAuthenticationError as err:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_invalid_auth",
breaks_in_ha_version="2024.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_invalid_auth",
translation_placeholders=ISSUE_PLACEHOLDER,
)
_LOGGER.exception("Could not authenticate with WAQI")
raise PlatformNotReady from err
except WAQIConnectionError as err: except WAQIConnectionError as err:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_cannot_connect",
breaks_in_ha_version="2024.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_cannot_connect",
translation_placeholders=ISSUE_PLACEHOLDER,
)
_LOGGER.exception("Failed to connect to WAQI servers") _LOGGER.exception("Failed to connect to WAQI servers")
raise PlatformNotReady from err raise PlatformNotReady from err
async_add_entities(dev, True) if station_count == 0:
async_create_issue(
hass,
DOMAIN,
"deprecated_yaml_import_issue_none_found",
breaks_in_ha_version="2024.4.0",
is_fixable=False,
issue_domain=DOMAIN,
severity=IssueSeverity.WARNING,
translation_key="deprecated_yaml_import_issue_none_found",
translation_placeholders=ISSUE_PLACEHOLDER,
)
class WaqiSensor(SensorEntity): async def async_setup_entry(
hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback
) -> None:
"""Set up the WAQI sensor."""
coordinator: WAQIDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
async_add_entities([WaqiSensor(coordinator)])
class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity):
"""Implementation of a WAQI sensor.""" """Implementation of a WAQI sensor."""
_attr_icon = ATTR_ICON _attr_icon = ATTR_ICON
_attr_device_class = SensorDeviceClass.AQI _attr_device_class = SensorDeviceClass.AQI
_attr_state_class = SensorStateClass.MEASUREMENT _attr_state_class = SensorStateClass.MEASUREMENT
_data: WAQIAirQuality | None = None def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None:
def __init__(self, client: WAQIClient, search_result: WAQISearchResult) -> None:
"""Initialize the sensor.""" """Initialize the sensor."""
self._client = client super().__init__(coordinator)
self.uid = search_result.station_id self._attr_name = f"WAQI {self.coordinator.data.city.name}"
self.url = search_result.station.external_url self._attr_unique_id = str(coordinator.data.station_id)
self.station_name = search_result.station.name
@property
def name(self):
"""Return the name of the sensor."""
if self.station_name:
return f"WAQI {self.station_name}"
return f"WAQI {self.url if self.url else self.uid}"
@property @property
def native_value(self) -> int | None: def native_value(self) -> int | None:
"""Return the state of the device.""" """Return the state of the device."""
assert self._data return self.coordinator.data.air_quality_index
return self._data.air_quality_index
@property
def available(self):
"""Return sensor availability."""
return self._data is not None
@property
def unique_id(self):
"""Return unique ID."""
return self.uid
@property @property
def extra_state_attributes(self): def extra_state_attributes(self):
"""Return the state attributes of the last update.""" """Return the state attributes of the last update."""
attrs = {} attrs = {}
try:
attrs[ATTR_ATTRIBUTION] = " and ".join(
[ATTRIBUTION]
+ [
attribution.name
for attribution in self.coordinator.data.attributions
]
)
if self._data is not None: attrs[ATTR_TIME] = self.coordinator.data.measured_at
try: attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant
attrs[ATTR_ATTRIBUTION] = " and ".join(
[ATTRIBUTION]
+ [attribution.name for attribution in self._data.attributions]
)
attrs[ATTR_TIME] = self._data.measured_at iaqi = self.coordinator.data.extended_air_quality
attrs[ATTR_DOMINENTPOL] = self._data.dominant_pollutant
iaqi = self._data.extended_air_quality attribute = {
ATTR_PM2_5: iaqi.pm25,
attribute = { ATTR_PM10: iaqi.pm10,
ATTR_PM2_5: iaqi.pm25, ATTR_HUMIDITY: iaqi.humidity,
ATTR_PM10: iaqi.pm10, ATTR_PRESSURE: iaqi.pressure,
ATTR_HUMIDITY: iaqi.humidity, ATTR_TEMPERATURE: iaqi.temperature,
ATTR_PRESSURE: iaqi.pressure, ATTR_OZONE: iaqi.ozone,
ATTR_TEMPERATURE: iaqi.temperature, ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide,
ATTR_OZONE: iaqi.ozone, ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide,
ATTR_NITROGEN_DIOXIDE: iaqi.nitrogen_dioxide, }
ATTR_SULFUR_DIOXIDE: iaqi.sulfur_dioxide, res_attributes = {k: v for k, v in attribute.items() if v is not None}
} return {**attrs, **res_attributes}
res_attributes = {k: v for k, v in attribute.items() if v is not None} except (IndexError, KeyError):
return {**attrs, **res_attributes} return {ATTR_ATTRIBUTION: ATTRIBUTION}
except (IndexError, KeyError):
return {ATTR_ATTRIBUTION: ATTRIBUTION}
async def async_update(self) -> None:
"""Get the latest data and updates the states."""
if self.uid:
result = await self._client.get_by_station_number(self.uid)
elif self.url:
result = await self._client.get_by_name(self.url)
else:
result = None
self._data = result

View file

@ -0,0 +1,39 @@
{
"config": {
"step": {
"user": {
"description": "Select a location to get the closest measuring station.",
"data": {
"location": "[%key:common::config_flow::data::location%]",
"api_key": "[%key:common::config_flow::data::api_key%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},
"issues": {
"deprecated_yaml_import_issue_invalid_auth": {
"title": "The World Air Quality Index YAML configuration import failed",
"description": "Configuring World Air Quality Index using YAML is being removed but there was an authentication error importing your YAML configuration.\n\nCorrect the YAML configuration and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "The WAQI YAML configuration import failed",
"description": "Configuring World Air Quality Index using YAML is being removed but there was an connection error importing your YAML configuration.\n\nEnsure connection to WAQI works and restart Home Assistant to try again or remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_yaml_import_issue_already_configured": {
"title": "The WAQI YAML configuration import failed",
"description": "Configuring World Air Quality Index using YAML is being removed but the measuring station was already imported when trying to import the YAML configuration.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
},
"deprecated_yaml_import_issue_none_found": {
"title": "The WAQI YAML configuration import failed",
"description": "Configuring World Air Quality Index using YAML is being removed but there weren't any stations imported because they couldn't be found.\n\nEnsure the imported configuration is correct and remove the World Air Quality Index YAML configuration from your configuration.yaml file and continue to [set up the integration]({url}) manually."
}
}
}

View file

@ -516,6 +516,7 @@ FLOWS = {
"volvooncall", "volvooncall",
"vulcan", "vulcan",
"wallbox", "wallbox",
"waqi",
"watttime", "watttime",
"waze_travel_time", "waze_travel_time",
"webostv", "webostv",

View file

@ -6288,7 +6288,7 @@
"waqi": { "waqi": {
"name": "World Air Quality Index (WAQI)", "name": "World Air Quality Index (WAQI)",
"integration_type": "hub", "integration_type": "hub",
"config_flow": false, "config_flow": true,
"iot_class": "cloud_polling" "iot_class": "cloud_polling"
}, },
"waterfurnace": { "waterfurnace": {

View file

@ -347,6 +347,9 @@ aiovlc==0.1.0
# homeassistant.components.vodafone_station # homeassistant.components.vodafone_station
aiovodafone==0.1.0 aiovodafone==0.1.0
# homeassistant.components.waqi
aiowaqi==0.2.1
# homeassistant.components.watttime # homeassistant.components.watttime
aiowatttime==0.1.1 aiowatttime==0.1.1

View file

@ -0,0 +1 @@
"""Tests for the World Air Quality Index (WAQI) integration."""

View file

@ -0,0 +1,30 @@
"""Common fixtures for the World Air Quality Index (WAQI) tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN
from homeassistant.const import CONF_API_KEY
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.waqi.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id="4584",
title="de Jongweg, Utrecht",
data={CONF_API_KEY: "asd", CONF_STATION_NUMBER: 4584},
)

View file

@ -0,0 +1,160 @@
{
"aqi": 29,
"idx": 4584,
"attributions": [
{
"url": "http://www.luchtmeetnet.nl/",
"name": "RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit",
"logo": "Netherland-RIVM.png"
},
{
"url": "https://waqi.info/",
"name": "World Air Quality Index Project"
}
],
"city": {
"geo": [52.105031, 5.124464],
"name": "de Jongweg, Utrecht",
"url": "https://aqicn.org/city/netherland/utrecht/de-jongweg",
"location": ""
},
"dominentpol": "o3",
"iaqi": {
"h": {
"v": 80
},
"no2": {
"v": 2.3
},
"o3": {
"v": 29.4
},
"p": {
"v": 1008.8
},
"pm10": {
"v": 12
},
"pm25": {
"v": 17
},
"t": {
"v": 16
},
"w": {
"v": 1.4
},
"wg": {
"v": 2.4
}
},
"time": {
"s": "2023-08-07 17:00:00",
"tz": "+02:00",
"v": 1691427600,
"iso": "2023-08-07T17:00:00+02:00"
},
"forecast": {
"daily": {
"o3": [
{
"avg": 28,
"day": "2023-08-07",
"max": 34,
"min": 25
},
{
"avg": 22,
"day": "2023-08-08",
"max": 29,
"min": 19
},
{
"avg": 23,
"day": "2023-08-09",
"max": 35,
"min": 9
},
{
"avg": 18,
"day": "2023-08-10",
"max": 38,
"min": 3
},
{
"avg": 17,
"day": "2023-08-11",
"max": 17,
"min": 11
}
],
"pm10": [
{
"avg": 8,
"day": "2023-08-07",
"max": 10,
"min": 6
},
{
"avg": 9,
"day": "2023-08-08",
"max": 12,
"min": 6
},
{
"avg": 9,
"day": "2023-08-09",
"max": 13,
"min": 6
},
{
"avg": 23,
"day": "2023-08-10",
"max": 33,
"min": 10
},
{
"avg": 27,
"day": "2023-08-11",
"max": 34,
"min": 27
}
],
"pm25": [
{
"avg": 19,
"day": "2023-08-07",
"max": 29,
"min": 11
},
{
"avg": 25,
"day": "2023-08-08",
"max": 37,
"min": 19
},
{
"avg": 27,
"day": "2023-08-09",
"max": 45,
"min": 19
},
{
"avg": 64,
"day": "2023-08-10",
"max": 86,
"min": 33
},
{
"avg": 72,
"day": "2023-08-11",
"max": 89,
"min": 72
}
]
}
},
"debug": {
"sync": "2023-08-08T01:29:52+09:00"
}
}

View file

@ -0,0 +1,32 @@
[
{
"uid": 6332,
"aqi": "27",
"time": {
"tz": "+02:00",
"stime": "2023-08-08 15:00:00",
"vtime": 1691499600
},
"station": {
"name": "Griftpark, Utrecht",
"geo": [52.101308, 5.128183],
"url": "netherland/utrecht/griftpark",
"country": "NL"
}
},
{
"uid": 4584,
"aqi": "27",
"time": {
"tz": "+02:00",
"stime": "2023-08-08 15:00:00",
"vtime": 1691499600
},
"station": {
"name": "de Jongweg, Utrecht",
"geo": [52.105031, 5.124464],
"url": "netherland/utrecht/de-jongweg",
"country": "NL"
}
}
]

View file

@ -0,0 +1,108 @@
"""Test the World Air Quality Index (WAQI) config flow."""
import json
from unittest.mock import AsyncMock, patch
from aiowaqi import WAQIAirQuality, WAQIAuthenticationError, WAQIConnectionError
import pytest
from homeassistant import config_entries
from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
)
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import load_fixture
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == FlowResultType.FORM
with patch(
"aiowaqi.WAQIClient.authenticate",
), patch(
"aiowaqi.WAQIClient.get_by_coordinates",
return_value=WAQIAirQuality.parse_obj(
json.loads(load_fixture("waqi/air_quality_sensor.json"))
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0},
CONF_API_KEY: "asd",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY
assert result["title"] == "de Jongweg, Utrecht"
assert result["data"] == {
CONF_API_KEY: "asd",
CONF_STATION_NUMBER: 4584,
}
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "error"),
[
(WAQIAuthenticationError(), "invalid_auth"),
(WAQIConnectionError(), "cannot_connect"),
(Exception(), "unknown"),
],
)
async def test_flow_errors(
hass: HomeAssistant, exception: Exception, error: str
) -> None:
"""Test we handle errors during configuration."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"aiowaqi.WAQIClient.authenticate",
), patch(
"aiowaqi.WAQIClient.get_by_coordinates",
side_effect=exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0},
CONF_API_KEY: "asd",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.FORM
assert result["errors"] == {"base": error}
with patch(
"aiowaqi.WAQIClient.authenticate",
), patch(
"aiowaqi.WAQIClient.get_by_coordinates",
return_value=WAQIAirQuality.parse_obj(
json.loads(load_fixture("waqi/air_quality_sensor.json"))
),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_LOCATION: {CONF_LATITUDE: 50.0, CONF_LONGITUDE: 10.0},
CONF_API_KEY: "asd",
},
)
await hass.async_block_till_done()
assert result["type"] == FlowResultType.CREATE_ENTRY

View file

@ -0,0 +1,124 @@
"""Test the World Air Quality Index (WAQI) sensor."""
import json
from unittest.mock import patch
from aiowaqi import WAQIAirQuality, WAQIError, WAQISearchResult
from homeassistant.components.waqi.const import CONF_STATION_NUMBER, DOMAIN
from homeassistant.components.waqi.sensor import CONF_LOCATIONS, CONF_STATIONS
from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState
from homeassistant.const import (
CONF_API_KEY,
CONF_NAME,
CONF_PLATFORM,
CONF_TOKEN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import issue_registry as ir
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, load_fixture
LEGACY_CONFIG = {
Platform.SENSOR: [
{
CONF_PLATFORM: DOMAIN,
CONF_TOKEN: "asd",
CONF_LOCATIONS: ["utrecht"],
CONF_STATIONS: [6332],
}
]
}
async def test_legacy_migration(hass: HomeAssistant) -> None:
"""Test migration from yaml to config flow."""
search_result_json = json.loads(load_fixture("waqi/search_result.json"))
search_results = [
WAQISearchResult.parse_obj(search_result)
for search_result in search_result_json
]
with patch(
"aiowaqi.WAQIClient.search",
return_value=search_results,
), patch(
"aiowaqi.WAQIClient.get_by_station_number",
return_value=WAQIAirQuality.parse_obj(
json.loads(load_fixture("waqi/air_quality_sensor.json"))
),
):
assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
async def test_legacy_migration_already_imported(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test migration from yaml to config flow after already imported."""
mock_config_entry.add_to_hass(hass)
with patch(
"aiowaqi.WAQIClient.get_by_station_number",
return_value=WAQIAirQuality.parse_obj(
json.loads(load_fixture("waqi/air_quality_sensor.json"))
),
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
state = hass.states.get("sensor.waqi_de_jongweg_utrecht")
assert state.state == "29"
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={
CONF_STATION_NUMBER: 4584,
CONF_NAME: "xyz",
CONF_API_KEY: "asd",
},
)
)
await hass.async_block_till_done()
entries = hass.config_entries.async_entries(DOMAIN)
assert len(entries) == 1
assert entries[0].state is ConfigEntryState.LOADED
issue_registry = ir.async_get(hass)
assert len(issue_registry.issues) == 1
async def test_sensor(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Test failed update."""
mock_config_entry.add_to_hass(hass)
with patch(
"aiowaqi.WAQIClient.get_by_station_number",
return_value=WAQIAirQuality.parse_obj(
json.loads(load_fixture("waqi/air_quality_sensor.json"))
),
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
state = hass.states.get("sensor.waqi_de_jongweg_utrecht")
assert state.state == "29"
async def test_updating_failed(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test failed update."""
mock_config_entry.add_to_hass(hass)
with patch(
"aiowaqi.WAQIClient.get_by_station_number",
side_effect=WAQIError(),
):
assert await async_setup_component(hass, DOMAIN, {})
await hass.async_block_till_done()
assert mock_config_entry.state == ConfigEntryState.SETUP_RETRY