From 9be16d9d42a05409c8fd4db6fcc5456fb9ee5312 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Sat, 9 Sep 2023 17:49:54 +0200 Subject: [PATCH] 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 --- CODEOWNERS | 3 +- homeassistant/components/waqi/__init__.py | 38 +++- homeassistant/components/waqi/config_flow.py | 135 +++++++++++++ homeassistant/components/waqi/const.py | 10 + homeassistant/components/waqi/coordinator.py | 36 ++++ homeassistant/components/waqi/manifest.json | 3 +- homeassistant/components/waqi/sensor.py | 179 ++++++++++-------- homeassistant/components/waqi/strings.json | 39 ++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- requirements_test_all.txt | 3 + tests/components/waqi/__init__.py | 1 + tests/components/waqi/conftest.py | 30 +++ .../waqi/fixtures/air_quality_sensor.json | 160 ++++++++++++++++ .../waqi/fixtures/search_result.json | 32 ++++ tests/components/waqi/test_config_flow.py | 108 +++++++++++ tests/components/waqi/test_sensor.py | 124 ++++++++++++ 17 files changed, 825 insertions(+), 79 deletions(-) create mode 100644 homeassistant/components/waqi/config_flow.py create mode 100644 homeassistant/components/waqi/const.py create mode 100644 homeassistant/components/waqi/coordinator.py create mode 100644 homeassistant/components/waqi/strings.json create mode 100644 tests/components/waqi/__init__.py create mode 100644 tests/components/waqi/conftest.py create mode 100644 tests/components/waqi/fixtures/air_quality_sensor.json create mode 100644 tests/components/waqi/fixtures/search_result.json create mode 100644 tests/components/waqi/test_config_flow.py create mode 100644 tests/components/waqi/test_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 0cb1bef619189..ba792b07183d5 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1391,7 +1391,8 @@ build.json @home-assistant/supervisor /tests/components/wake_word/ @home-assistant/core @synesthesiam /homeassistant/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 /tests/components/water_heater/ @home-assistant/core /homeassistant/components/watson_tts/ @rutkai diff --git a/homeassistant/components/waqi/__init__.py b/homeassistant/components/waqi/__init__.py index 5cacd9e5e1be2..bc51a91364ce2 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -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 diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py new file mode 100644 index 0000000000000..b5f3a18b223e0 --- /dev/null +++ b/homeassistant/components/waqi/config_flow.py @@ -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], + }, + ) diff --git a/homeassistant/components/waqi/const.py b/homeassistant/components/waqi/const.py new file mode 100644 index 0000000000000..2847a29b8add8 --- /dev/null +++ b/homeassistant/components/waqi/const.py @@ -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"} diff --git a/homeassistant/components/waqi/coordinator.py b/homeassistant/components/waqi/coordinator.py new file mode 100644 index 0000000000000..b7beef8fda903 --- /dev/null +++ b/homeassistant/components/waqi/coordinator.py @@ -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 diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index 2022558a5006b..bf31fb570a8d5 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -1,7 +1,8 @@ { "domain": "waqi", "name": "World Air Quality Index (WAQI)", - "codeowners": ["@andrey-git"], + "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", "iot_class": "cloud_polling", "loggers": ["waqiasync"], diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index 51b9acb8e59a8..0ad295ca5af9a 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,10 +1,9 @@ """Support for the World Air Quality Index service.""" from __future__ import annotations -from datetime import timedelta import logging -from aiowaqi import WAQIAirQuality, WAQIClient, WAQIConnectionError, WAQISearchResult +from aiowaqi import WAQIAuthenticationError, WAQIClient, WAQIConnectionError import voluptuous as vol from homeassistant.components.sensor import ( @@ -12,10 +11,13 @@ SensorEntity, SensorStateClass, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_TEMPERATURE, ATTR_TIME, + CONF_API_KEY, + CONF_NAME, CONF_TOKEN, ) from homeassistant.core import HomeAssistant @@ -23,7 +25,12 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv 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.update_coordinator import CoordinatorEntity + +from .const import CONF_STATION_NUMBER, DOMAIN, ISSUE_PLACEHOLDER +from .coordinator import WAQIDataUpdateCoordinator _LOGGER = logging.getLogger(__name__) @@ -43,8 +50,6 @@ CONF_LOCATIONS = "locations" CONF_STATIONS = "stations" -SCAN_INTERVAL = timedelta(minutes=5) - TIMEOUT = 10 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.authenticate(token) - dev = [] + station_count = 0 try: for location_name in locations: stations = await client.search(location_name) _LOGGER.debug("The following stations were returned: %s", stations) for station in stations: - waqi_sensor = WaqiSensor(client, station) + station_count = station_count + 1 if not station_filter or { - waqi_sensor.uid, - waqi_sensor.url, - waqi_sensor.station_name, + station.station_id, + station.station.external_url, + station.station.name, } & 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: + 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") 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, + ) + + +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(SensorEntity): +class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity): """Implementation of a WAQI sensor.""" _attr_icon = ATTR_ICON _attr_device_class = SensorDeviceClass.AQI _attr_state_class = SensorStateClass.MEASUREMENT - _data: WAQIAirQuality | None = None - - def __init__(self, client: WAQIClient, search_result: WAQISearchResult) -> None: + def __init__(self, coordinator: WAQIDataUpdateCoordinator) -> None: """Initialize the sensor.""" - self._client = client - self.uid = search_result.station_id - self.url = search_result.station.external_url - 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}" + super().__init__(coordinator) + self._attr_name = f"WAQI {self.coordinator.data.city.name}" + self._attr_unique_id = str(coordinator.data.station_id) @property def native_value(self) -> int | None: """Return the state of the device.""" - assert self._data - 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 + return self.coordinator.data.air_quality_index @property def extra_state_attributes(self): """Return the state attributes of the last update.""" attrs = {} - - if self._data is not None: - try: - attrs[ATTR_ATTRIBUTION] = " and ".join( - [ATTRIBUTION] - + [attribution.name for attribution in self._data.attributions] - ) - - attrs[ATTR_TIME] = self._data.measured_at - attrs[ATTR_DOMINENTPOL] = self._data.dominant_pollutant - - iaqi = self._data.extended_air_quality - - attribute = { - ATTR_PM2_5: iaqi.pm25, - ATTR_PM10: iaqi.pm10, - ATTR_HUMIDITY: iaqi.humidity, - ATTR_PRESSURE: iaqi.pressure, - ATTR_TEMPERATURE: iaqi.temperature, - ATTR_OZONE: iaqi.ozone, - 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} - 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 + try: + attrs[ATTR_ATTRIBUTION] = " and ".join( + [ATTRIBUTION] + + [ + attribution.name + for attribution in self.coordinator.data.attributions + ] + ) + + attrs[ATTR_TIME] = self.coordinator.data.measured_at + attrs[ATTR_DOMINENTPOL] = self.coordinator.data.dominant_pollutant + + iaqi = self.coordinator.data.extended_air_quality + + attribute = { + ATTR_PM2_5: iaqi.pm25, + ATTR_PM10: iaqi.pm10, + ATTR_HUMIDITY: iaqi.humidity, + ATTR_PRESSURE: iaqi.pressure, + ATTR_TEMPERATURE: iaqi.temperature, + ATTR_OZONE: iaqi.ozone, + 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} + except (IndexError, KeyError): + return {ATTR_ATTRIBUTION: ATTRIBUTION} diff --git a/homeassistant/components/waqi/strings.json b/homeassistant/components/waqi/strings.json new file mode 100644 index 0000000000000..4ceb911de9e94 --- /dev/null +++ b/homeassistant/components/waqi/strings.json @@ -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." + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 6c992fd4b5e4b..0f55df7cc99b9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -516,6 +516,7 @@ "volvooncall", "vulcan", "wallbox", + "waqi", "watttime", "waze_travel_time", "webostv", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 379dd11267224..5eaf1b8d0a4d1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6288,7 +6288,7 @@ "waqi": { "name": "World Air Quality Index (WAQI)", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "waterfurnace": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 94a48d0793ee0..9ea3661450ca0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -347,6 +347,9 @@ aiovlc==0.1.0 # homeassistant.components.vodafone_station aiovodafone==0.1.0 +# homeassistant.components.waqi +aiowaqi==0.2.1 + # homeassistant.components.watttime aiowatttime==0.1.1 diff --git a/tests/components/waqi/__init__.py b/tests/components/waqi/__init__.py new file mode 100644 index 0000000000000..b6f36680ee368 --- /dev/null +++ b/tests/components/waqi/__init__.py @@ -0,0 +1 @@ +"""Tests for the World Air Quality Index (WAQI) integration.""" diff --git a/tests/components/waqi/conftest.py b/tests/components/waqi/conftest.py new file mode 100644 index 0000000000000..176c1e27d8f78 --- /dev/null +++ b/tests/components/waqi/conftest.py @@ -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}, + ) diff --git a/tests/components/waqi/fixtures/air_quality_sensor.json b/tests/components/waqi/fixtures/air_quality_sensor.json new file mode 100644 index 0000000000000..49f1184822fe7 --- /dev/null +++ b/tests/components/waqi/fixtures/air_quality_sensor.json @@ -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" + } +} diff --git a/tests/components/waqi/fixtures/search_result.json b/tests/components/waqi/fixtures/search_result.json new file mode 100644 index 0000000000000..65da5abc09a5a --- /dev/null +++ b/tests/components/waqi/fixtures/search_result.json @@ -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" + } + } +] diff --git a/tests/components/waqi/test_config_flow.py b/tests/components/waqi/test_config_flow.py new file mode 100644 index 0000000000000..3901ffad550ee --- /dev/null +++ b/tests/components/waqi/test_config_flow.py @@ -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 diff --git a/tests/components/waqi/test_sensor.py b/tests/components/waqi/test_sensor.py new file mode 100644 index 0000000000000..18f77028a29d1 --- /dev/null +++ b/tests/components/waqi/test_sensor.py @@ -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