diff --git a/.coveragerc b/.coveragerc index 62083486c03c17..5e47ecadaaf231 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1486,6 +1486,8 @@ omit = homeassistant/components/vulcan/calendar.py homeassistant/components/vulcan/fetch_data.py homeassistant/components/w800rf32/* + homeassistant/components/waqi/__init__.py + homeassistant/components/waqi/const.py homeassistant/components/waqi/sensor.py homeassistant/components/waterfurnace/* homeassistant/components/watson_iot/* diff --git a/CODEOWNERS b/CODEOWNERS index 2d9bcf41db8bf2..91c8fee264ee36 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1249,7 +1249,7 @@ build.json @home-assistant/supervisor /tests/components/wake_on_lan/ @ntilley905 /homeassistant/components/wallbox/ @hesselonline /tests/components/wallbox/ @hesselonline -/homeassistant/components/waqi/ @andrey-git +/homeassistant/components/waqi/ @sbach /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 5cacd9e5e1be2c..9b09c1728b53d2 100644 --- a/homeassistant/components/waqi/__init__.py +++ b/homeassistant/components/waqi/__init__.py @@ -1 +1,72 @@ -"""The waqi component.""" +from __future__ import annotations + +from datetime import timedelta + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from waqi_client_async import WAQIClient + +from .const import ( + CONF_API_TOKEN, + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + LOGGER, +) + +PLATFORMS: list[Platform] = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + + client = WAQIClient( + token=entry.options[CONF_API_TOKEN], + session=async_get_clientsession(hass), + ) + + async def coordinator_async_get(): + try: + return await client.feed(f"@{entry.unique_id}") + except e: + raise UpdateFailed(e) from e + + coordinator = DataUpdateCoordinator( + hass, + LOGGER, + name="WAQI", + update_method=coordinator_async_get, + update_interval=timedelta(seconds=entry.options[CONF_UPDATE_INTERVAL]), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][entry.entry_id] = { + "coordinator": coordinator, + } + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + entry.async_on_unload(entry.add_update_listener(async_reload_entry)) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + hass.data[DOMAIN].pop(entry.entry_id) + + return unload_ok + + +async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Handle an options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/waqi/config_flow.py b/homeassistant/components/waqi/config_flow.py new file mode 100644 index 00000000000000..3727a6d17428c8 --- /dev/null +++ b/homeassistant/components/waqi/config_flow.py @@ -0,0 +1,146 @@ +from __future__ import annotations + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_TOKEN +from homeassistant.core import callback +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +import waqi_client_async as waqi + +from .const import ( + CONF_KEYWORD, + CONF_STATION, + CONF_UPDATE_INTERVAL, + DEFAULT_UPDATE_INTERVAL, + DOMAIN, + LOGGER, +) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + _api_token: str + _stations: dict[str, str] + _update_interval: int + + 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: + + try: + client = waqi.WAQIClient( + user_input[CONF_API_TOKEN], async_get_clientsession(self.hass) + ) + found = await client.search(user_input[CONF_KEYWORD]) + if not found: + errors[CONF_KEYWORD] = "no_matching_stations_found" + except waqi.OverQuota: + errors[CONF_API_TOKEN] = "api_over_quota" + except waqi.InvalidToken: + errors[CONF_API_TOKEN] = "api_token_invalid" + except: + return self.async_abort(reason="unknown") + else: + if found: + self._stations = {} + for station in found: + unique_id = station["uid"] + self._stations[unique_id] = station["station"]["name"] + + self._api_token = user_input[CONF_API_TOKEN] + self._update_interval = user_input[CONF_UPDATE_INTERVAL] + + return await self.async_step_pick_station() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Required(CONF_KEYWORD): str, + vol.Optional( + CONF_UPDATE_INTERVAL, + default=DEFAULT_UPDATE_INTERVAL, + ): int, + } + ), + errors=errors, + ) + + async def async_step_pick_station( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the station selection step.""" + errors: dict[str, str] = {} + + if user_input is not None: + unique_id = user_input[CONF_STATION] + + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._stations[unique_id], + data={}, + options={ + CONF_API_TOKEN: self._api_token, + CONF_UPDATE_INTERVAL: self._update_interval, + }, + ) + + return self.async_show_form( + step_id="pick_station", + data_schema=vol.Schema( + {vol.Required(CONF_STATION): vol.In(self._stations)} + ), + errors=errors, + ) + + @staticmethod + @callback + def async_get_options_flow(config_entry: config_entries.ConfigEntry) -> OptionsFlow: + """Get the options flow.""" + return OptionsFlow(config_entry) + + +class OptionsFlow(config_entries.OptionsFlow): + """Handle an options flow.""" + + def __init__(self, config_entry: config_entries.ConfigEntry) -> None: + """Initialize the options flow.""" + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the options configuration step.""" + errors: dict[str, str] = {} + + if user_input is not None: + return self.async_create_entry(title="", data=user_input) + + options = self._config_entry.options + options_schema = vol.Schema( + { + vol.Required(CONF_API_TOKEN): str, + vol.Optional( + CONF_UPDATE_INTERVAL, + default=self._config_entry.options.get( + CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL + ), + ): int, + } + ) + + return self.async_show_form( + step_id="init", data_schema=options_schema, errors=errors + ) diff --git a/homeassistant/components/waqi/const.py b/homeassistant/components/waqi/const.py new file mode 100644 index 00000000000000..f3384590400c3a --- /dev/null +++ b/homeassistant/components/waqi/const.py @@ -0,0 +1,11 @@ +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) + +DOMAIN = "_waqi" + +CONF_KEYWORD = "keyword" +CONF_STATION = "station" +CONF_UPDATE_INTERVAL = "update_interval" + +DEFAULT_UPDATE_INTERVAL = 900 diff --git a/homeassistant/components/waqi/manifest.json b/homeassistant/components/waqi/manifest.json index d4818d44626861..495869adc4a937 100644 --- a/homeassistant/components/waqi/manifest.json +++ b/homeassistant/components/waqi/manifest.json @@ -1,9 +1,9 @@ { "domain": "waqi", - "name": "World Air Quality Index (WAQI)", + "name": "World's Air Quality Index (WAQI)", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/waqi", - "requirements": ["waqiasync==1.0.0"], - "codeowners": ["@andrey-git"], + "requirements": ["waqi-client-async==1.0.0"], + "codeowners": ["@sbach"], "iot_class": "cloud_polling", - "loggers": ["waqiasync"] } diff --git a/homeassistant/components/waqi/sensor.py b/homeassistant/components/waqi/sensor.py index c9cc527387a80e..52515756f53870 100644 --- a/homeassistant/components/waqi/sensor.py +++ b/homeassistant/components/waqi/sensor.py @@ -1,194 +1,183 @@ -"""Support for the World Air Quality Index service.""" -from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any -import asyncio -from datetime import timedelta -import logging - -import aiohttp -import voluptuous as vol -from waqiasync import WaqiClient +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.typing import StateType +from homeassistant.helpers.update_coordinator import CoordinatorEntity +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator from homeassistant.components.sensor import ( SensorDeviceClass, SensorEntity, + SensorEntityDescription, SensorStateClass, ) from homeassistant.const import ( - ATTR_ATTRIBUTION, - ATTR_TEMPERATURE, - ATTR_TIME, - CONF_TOKEN, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + PERCENTAGE, + PRESSURE_HPA, + TEMP_CELSIUS, ) -from homeassistant.core import HomeAssistant -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA -from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType - -_LOGGER = logging.getLogger(__name__) - -ATTR_DOMINENTPOL = "dominentpol" -ATTR_HUMIDITY = "humidity" -ATTR_NITROGEN_DIOXIDE = "nitrogen_dioxide" -ATTR_OZONE = "ozone" -ATTR_PM10 = "pm_10" -ATTR_PM2_5 = "pm_2_5" -ATTR_PRESSURE = "pressure" -ATTR_SULFUR_DIOXIDE = "sulfur_dioxide" - -KEY_TO_ATTR = { - "pm25": ATTR_PM2_5, - "pm10": ATTR_PM10, - "h": ATTR_HUMIDITY, - "p": ATTR_PRESSURE, - "t": ATTR_TEMPERATURE, - "o3": ATTR_OZONE, - "no2": ATTR_NITROGEN_DIOXIDE, - "so2": ATTR_SULFUR_DIOXIDE, -} - -ATTRIBUTION = "Data provided by the World Air Quality Index project" - -ATTR_ICON = "mdi:cloud" -ATTR_UNIT = "AQI" - -CONF_LOCATIONS = "locations" -CONF_STATIONS = "stations" - -SCAN_INTERVAL = timedelta(minutes=5) - -TIMEOUT = 10 - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Optional(CONF_STATIONS): cv.ensure_list, - vol.Required(CONF_TOKEN): cv.string, - vol.Required(CONF_LOCATIONS): cv.ensure_list, - } + +from .const import DOMAIN + + +@dataclass +class EntityDescriptionMixin: + """Mixin used to handle data for the WAQI sensors.""" + + found_fn: Callable[[dict[str, Any]], StateType] + value_fn: Callable[[dict[str, Any]], StateType] + + +@dataclass +class WAQISensorEntityDescription(SensorEntityDescription, EntityDescriptionMixin): + """Class describing WAQI sensor entities.""" + + +SENSOR_DESCRIPTIONS: tuple[WAQISensorEntityDescription, ...] = ( + WAQISensorEntityDescription( + key="aqi", + icon="mdi:air-filter", + name="AQI", + native_unit_of_measurement="AQI", + found_fn=lambda data: "aqi" in data, + value_fn=lambda data: data["aqi"], + ), + WAQISensorEntityDescription( + key="pm25", + device_class=SensorDeviceClass.PM25, + name="PM2.5", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "pm25" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["pm25"]["v"], + ), + WAQISensorEntityDescription( + key="pm10", + device_class=SensorDeviceClass.PM10, + name="PM10", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "pm10" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["pm10"]["v"], + ), + WAQISensorEntityDescription( + key="humidity", + device_class=SensorDeviceClass.HUMIDITY, + name="Humidity", + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "h" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["h"]["v"], + ), + WAQISensorEntityDescription( + key="pressure", + device_class=SensorDeviceClass.PRESSURE, + name="Pressure", + native_unit_of_measurement=PRESSURE_HPA, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "p" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["p"]["v"], + ), + WAQISensorEntityDescription( + key="temperature", + device_class=SensorDeviceClass.TEMPERATURE, + name="Temperature", + native_unit_of_measurement=TEMP_CELSIUS, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "t" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["t"]["v"], + ), + WAQISensorEntityDescription( + key="co", + name="CO", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "co" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["co"]["v"], + ), + WAQISensorEntityDescription( + key="no2", + device_class=SensorDeviceClass.NITROGEN_DIOXIDE, + name="NO2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "no2" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["no2"]["v"], + ), + WAQISensorEntityDescription( + key="so2", + device_class=SensorDeviceClass.SULPHUR_DIOXIDE, + name="SO2", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "so2" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["so2"]["v"], + ), + WAQISensorEntityDescription( + key="o3", + device_class=SensorDeviceClass.OZONE, + name="O3", + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + found_fn=lambda data: "o3" in data["iaqi"], + value_fn=lambda data: data["iaqi"]["o3"]["v"], + ), ) -async def async_setup_platform( +async def async_setup_entry( hass: HomeAssistant, - config: ConfigType, + entry: ConfigEntry, async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the requested World Air Quality Index locations.""" - - token = config[CONF_TOKEN] - station_filter = config.get(CONF_STATIONS) - locations = config[CONF_LOCATIONS] - - client = WaqiClient(token, async_get_clientsession(hass), timeout=TIMEOUT) - dev = [] - 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) - if not station_filter or { - waqi_sensor.uid, - waqi_sensor.url, - waqi_sensor.station_name, - } & set(station_filter): - dev.append(waqi_sensor) - except ( - aiohttp.client_exceptions.ClientConnectorError, - asyncio.TimeoutError, - ) as err: - _LOGGER.exception("Failed to connect to WAQI servers") - raise PlatformNotReady from err - async_add_entities(dev, True) - - -class WaqiSensor(SensorEntity): - """Implementation of a WAQI sensor.""" - - _attr_icon = ATTR_ICON - _attr_native_unit_of_measurement = ATTR_UNIT - _attr_device_class = SensorDeviceClass.AQI - _attr_state_class = SensorStateClass.MEASUREMENT - - def __init__(self, client, station): + """Set up sensor based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id]["coordinator"] + + async_add_entities( + ( + WAQISensor(coordinator, description, entry.unique_id, entry.title) + for description in SENSOR_DESCRIPTIONS + if description.found_fn(coordinator.data) + ), + ) + + +class WAQISensor(CoordinatorEntity[DataUpdateCoordinator], SensorEntity): + """Defines a WAQI sensor entity.""" + + _attr_attribution = "Data provided by the World Air Quality Index project." + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DataUpdateCoordinator, + entity_description: WAQISensorEntityDescription, + unique_id: str, + name: str, + ) -> None: """Initialize the sensor.""" - self._client = client - try: - self.uid = station["uid"] - except (KeyError, TypeError): - self.uid = None + super().__init__(coordinator=coordinator) - try: - self.url = station["station"]["url"] - except (KeyError, TypeError): - self.url = None + self.entity_description = entity_description - try: - self.station_name = station["station"]["name"] - except (KeyError, TypeError): - self.station_name = None + self._attrs = {} - self._data = None + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, unique_id)}, + name=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 - def native_value(self): - """Return the state of the device.""" - if self._data is not None: - return self._data.get("aqi") - return None - - @property - def available(self): - """Return sensor availability.""" - return self._data is not None - - @property - def unique_id(self): - """Return unique ID.""" - return self.uid + self._attr_unique_id = f"{DOMAIN}-{unique_id}-{entity_description.key}".lower() @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] - + [v["name"] for v in self._data.get("attributions", [])] - ) - - attrs[ATTR_TIME] = self._data["time"]["s"] - attrs[ATTR_DOMINENTPOL] = self._data.get("dominentpol") - - iaqi = self._data["iaqi"] - for key in iaqi: - if key in KEY_TO_ATTR: - attrs[KEY_TO_ATTR[key]] = iaqi[key]["v"] - else: - attrs[key] = iaqi[key]["v"] - return attrs - 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_station_by_number(self.uid) - elif self.url: - result = await self._client.get_station_by_name(self.url) - else: - result = None - self._data = result + def native_value(self) -> StateType: + """Return the state of the sensor.""" + return self.entity_description.value_fn(self.coordinator.data) diff --git a/requirements_all.txt b/requirements_all.txt index dd1a8e532704bd..0e2bc1be5981c0 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2515,7 +2515,7 @@ wakeonlan==2.1.0 wallbox==0.4.10 # homeassistant.components.waqi -waqiasync==1.0.0 +waqi-client-async==1.0.0 # homeassistant.components.folder_watcher watchdog==2.1.9