diff --git a/.coveragerc b/.coveragerc index ee82681f0fe..8bf6cafa139 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1003,6 +1003,9 @@ omit = homeassistant/components/proxmoxve/* homeassistant/components/proxy/camera.py homeassistant/components/pulseaudio_loopback/switch.py + homeassistant/components/purpleair/__init__.py + homeassistant/components/purpleair/coordinator.py + homeassistant/components/purpleair/sensor.py homeassistant/components/pushbullet/api.py homeassistant/components/pushbullet/notify.py homeassistant/components/pushbullet/sensor.py diff --git a/.strict-typing b/.strict-typing index e35d5439844..63b3159e8fb 100644 --- a/.strict-typing +++ b/.strict-typing @@ -225,6 +225,7 @@ homeassistant.components.powerwall.* homeassistant.components.proximity.* homeassistant.components.prusalink.* homeassistant.components.pure_energie.* +homeassistant.components.purpleair.* homeassistant.components.pvoutput.* homeassistant.components.qnap_qsw.* homeassistant.components.radarr.* diff --git a/CODEOWNERS b/CODEOWNERS index 6897bbba741..9c407a88fbf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -894,6 +894,8 @@ build.json @home-assistant/supervisor /tests/components/ps4/ @ktnrg45 /homeassistant/components/pure_energie/ @klaasnicolaas /tests/components/pure_energie/ @klaasnicolaas +/homeassistant/components/purpleair/ @bachya +/tests/components/purpleair/ @bachya /homeassistant/components/push/ @dgomes /tests/components/push/ @dgomes /homeassistant/components/pushbullet/ @engrbm87 diff --git a/homeassistant/components/purpleair/__init__.py b/homeassistant/components/purpleair/__init__.py new file mode 100644 index 00000000000..40139c94286 --- /dev/null +++ b/homeassistant/components/purpleair/__init__.py @@ -0,0 +1,70 @@ +"""The PurpleAir integration.""" +from __future__ import annotations + +from aiopurpleair.models.sensors import SensorModel + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ATTR_LATITUDE, ATTR_LONGITUDE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import PurpleAirDataUpdateCoordinator + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up PurpleAir from a config entry.""" + coordinator = PurpleAirDataUpdateCoordinator(hass, entry) + await coordinator.async_config_entry_first_refresh() + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = 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 + + +class PurpleAirEntity(CoordinatorEntity[PurpleAirDataUpdateCoordinator]): + """Define a base PurpleAir entity.""" + + _attr_has_entity_name = True + + def __init__( + self, + coordinator: PurpleAirDataUpdateCoordinator, + entry: ConfigEntry, + sensor_index: int, + ) -> None: + """Initialize.""" + super().__init__(coordinator) + + self._sensor_index = sensor_index + + self._attr_device_info = DeviceInfo( + configuration_url=self.coordinator.async_get_map_url(sensor_index), + hw_version=self.sensor_data.hardware, + identifiers={(DOMAIN, str(self._sensor_index))}, + manufacturer="PurpleAir, Inc.", + model=self.sensor_data.model, + name=self.sensor_data.name, + sw_version=self.sensor_data.firmware_version, + ) + self._attr_extra_state_attributes = { + ATTR_LATITUDE: self.sensor_data.latitude, + ATTR_LONGITUDE: self.sensor_data.longitude, + } + + @property + def sensor_data(self) -> SensorModel: + """Define a property to get this entity's SensorModel object.""" + return self.coordinator.data.data[self._sensor_index] diff --git a/homeassistant/components/purpleair/config_flow.py b/homeassistant/components/purpleair/config_flow.py new file mode 100644 index 00000000000..fa0025f0ee8 --- /dev/null +++ b/homeassistant/components/purpleair/config_flow.py @@ -0,0 +1,220 @@ +"""Config flow for PurpleAir integration.""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Any + +from aiopurpleair import API +from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.core import HomeAssistant, callback +from homeassistant.data_entry_flow import FlowResult +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_SENSOR_INDICES, DOMAIN, LOGGER + +CONF_DISTANCE = "distance" +CONF_NEARBY_SENSOR_OPTIONS = "nearby_sensor_options" +CONF_SENSOR_INDEX = "sensor_index" + +DEFAULT_DISTANCE = 5 + +API_KEY_SCHEMA = vol.Schema( + { + vol.Required(CONF_API_KEY): cv.string, + } +) + + +@callback +def async_get_api(hass: HomeAssistant, api_key: str) -> API: + """Get an aiopurpleair API object.""" + session = aiohttp_client.async_get_clientsession(hass) + return API(api_key, session=session) + + +@callback +def async_get_coordinates_schema(hass: HomeAssistant) -> vol.Schema: + """Define a schema for searching for sensors near a coordinate pair.""" + return vol.Schema( + { + vol.Inclusive( + CONF_LATITUDE, "coords", default=hass.config.latitude + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, "coords", default=hass.config.longitude + ): cv.longitude, + vol.Optional(CONF_DISTANCE, default=DEFAULT_DISTANCE): cv.positive_int, + } + ) + + +@callback +def async_get_nearby_sensors_schema(options: list[SelectOptionDict]) -> vol.Schema: + """Define a schema for selecting a sensor from a list.""" + return vol.Schema( + { + vol.Required(CONF_SENSOR_INDEX): SelectSelector( + SelectSelectorConfig(options=options, mode=SelectSelectorMode.DROPDOWN) + ) + } + ) + + +@dataclass +class ValidationResult: + """Define a validation result.""" + + data: Any = None + errors: dict[str, Any] = field(default_factory=dict) + + +async def async_validate_api_key(hass: HomeAssistant, api_key: str) -> ValidationResult: + """Validate an API key. + + This method returns a dictionary of errors (if appropriate). + """ + api = async_get_api(hass, api_key) + errors = {} + + try: + await api.async_check_api_key() + except InvalidApiKeyError: + errors["base"] = "invalid_api_key" + except PurpleAirError as err: + LOGGER.error("PurpleAir error while checking API key: %s", err) + errors["base"] = "unknown" + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception while checking API key: %s", err) + errors["base"] = "unknown" + + if errors: + return ValidationResult(errors=errors) + + return ValidationResult(data=None) + + +async def async_validate_coordinates( + hass: HomeAssistant, + api_key: str, + latitude: float, + longitude: float, + distance: float, +) -> ValidationResult: + """Validate coordinates.""" + api = async_get_api(hass, api_key) + errors = {} + + try: + nearby_sensor_results = await api.sensors.async_get_nearby_sensors( + ["name"], latitude, longitude, distance, limit_results=5 + ) + except PurpleAirError as err: + LOGGER.error("PurpleAir error while getting nearby sensors: %s", err) + errors["base"] = "unknown" + except Exception as err: # pylint: disable=broad-except + LOGGER.exception("Unexpected exception while getting nearby sensors: %s", err) + errors["base"] = "unknown" + else: + if not nearby_sensor_results: + errors["base"] = "no_sensors_near_coordinates" + + if errors: + return ValidationResult(errors=errors) + + return ValidationResult(data=nearby_sensor_results) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for PurpleAir.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize.""" + self._flow_data: dict[str, Any] = {} + + async def async_step_by_coordinates( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the discovery of sensors near a latitude/longitude.""" + if user_input is None: + return self.async_show_form( + step_id="by_coordinates", + data_schema=async_get_coordinates_schema(self.hass), + ) + + validation = await async_validate_coordinates( + self.hass, + self._flow_data[CONF_API_KEY], + user_input[CONF_LATITUDE], + user_input[CONF_LONGITUDE], + user_input[CONF_DISTANCE], + ) + + if validation.errors: + return self.async_show_form( + step_id="by_coordinates", + data_schema=async_get_coordinates_schema(self.hass), + errors=validation.errors, + ) + + self._flow_data[CONF_NEARBY_SENSOR_OPTIONS] = [ + SelectOptionDict( + value=str(result.sensor.sensor_index), + label=f"{result.sensor.name} ({round(result.distance, 1)} km away)", + ) + for result in validation.data + ] + + return await self.async_step_choose_sensor() + + async def async_step_choose_sensor( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the selection of a sensor.""" + if user_input is None: + options = self._flow_data.pop(CONF_NEARBY_SENSOR_OPTIONS) + return self.async_show_form( + step_id="choose_sensor", + data_schema=async_get_nearby_sensors_schema(options), + ) + + return self.async_create_entry( + title=self._flow_data[CONF_API_KEY][:5], + data=self._flow_data, + # Note that we store the sensor indices in options so that later on, we can + # add/remove additional sensors via an options flow: + options={CONF_SENSOR_INDICES: [int(user_input[CONF_SENSOR_INDEX])]}, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle the initial step.""" + if user_input is None: + return self.async_show_form(step_id="user", data_schema=API_KEY_SCHEMA) + + api_key = user_input[CONF_API_KEY] + + await self.async_set_unique_id(api_key) + self._abort_if_unique_id_configured() + + validation = await async_validate_api_key(self.hass, api_key) + + if validation.errors: + return self.async_show_form( + step_id="user", data_schema=API_KEY_SCHEMA, errors=validation.errors + ) + + self._flow_data = {CONF_API_KEY: api_key} + return await self.async_step_by_coordinates() diff --git a/homeassistant/components/purpleair/const.py b/homeassistant/components/purpleair/const.py new file mode 100644 index 00000000000..60f51a9e7dd --- /dev/null +++ b/homeassistant/components/purpleair/const.py @@ -0,0 +1,9 @@ +"""Constants for the PurpleAir integration.""" +import logging + +DOMAIN = "purpleair" + +LOGGER = logging.getLogger(__package__) + +CONF_READ_KEY = "read_key" +CONF_SENSOR_INDICES = "sensor_indices" diff --git a/homeassistant/components/purpleair/coordinator.py b/homeassistant/components/purpleair/coordinator.py new file mode 100644 index 00000000000..d0e258a2d9c --- /dev/null +++ b/homeassistant/components/purpleair/coordinator.py @@ -0,0 +1,75 @@ +"""Define a PurpleAir DataUpdateCoordinator.""" +from __future__ import annotations + +from datetime import timedelta + +from aiopurpleair import API +from aiopurpleair.errors import PurpleAirError +from aiopurpleair.models.sensors import GetSensorsResponse + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONF_SENSOR_INDICES, LOGGER + +SENSOR_FIELDS_TO_RETRIEVE = [ + "0.3_um_count", + "0.5_um_count", + "1.0_um_count", + "10.0_um_count", + "2.5_um_count", + "5.0_um_count", + "altitude", + "firmware_version", + "hardware", + "humidity", + "latitude", + "location_type", + "longitude", + "model", + "name", + "pm1.0", + "pm10.0", + "pm2.5", + "pressure", + "rssi", + "temperature", + "uptime", + "voc", +] + +UPDATE_INTERVAL = timedelta(minutes=2) + + +class PurpleAirDataUpdateCoordinator(DataUpdateCoordinator[GetSensorsResponse]): + """Define a PurpleAir-specific coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize.""" + self._entry = entry + self._api = API( + entry.data[CONF_API_KEY], + session=aiohttp_client.async_get_clientsession(hass), + ) + + super().__init__( + hass, LOGGER, name=entry.title, update_interval=UPDATE_INTERVAL + ) + + async def _async_update_data(self) -> GetSensorsResponse: + """Get the latest sensor information.""" + try: + return await self._api.sensors.async_get_sensors( + SENSOR_FIELDS_TO_RETRIEVE, + sensor_indices=self._entry.options[CONF_SENSOR_INDICES], + ) + except PurpleAirError as err: + raise UpdateFailed(f"Error while fetching data: {err}") from err + + @callback + def async_get_map_url(self, sensor_index: int) -> str: + """Get the map URL for a sensor index.""" + return self._api.get_map_url(sensor_index) diff --git a/homeassistant/components/purpleair/manifest.json b/homeassistant/components/purpleair/manifest.json new file mode 100644 index 00000000000..e796404a142 --- /dev/null +++ b/homeassistant/components/purpleair/manifest.json @@ -0,0 +1,9 @@ +{ + "domain": "purpleair", + "name": "PurpleAir", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/purpleair", + "requirements": ["aiopurpleair==2022.12.1"], + "codeowners": ["@bachya"], + "iot_class": "cloud_polling" +} diff --git a/homeassistant/components/purpleair/sensor.py b/homeassistant/components/purpleair/sensor.py new file mode 100644 index 00000000000..9037ece5470 --- /dev/null +++ b/homeassistant/components/purpleair/sensor.py @@ -0,0 +1,220 @@ +"""Support for PurpleAir sensors.""" +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass + +from aiopurpleair.models.sensors import SensorModel + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + PERCENTAGE, + SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + UnitOfPressure, + UnitOfTemperature, + UnitOfTime, + UnitOfVolume, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import EntityCategory +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import PurpleAirEntity +from .const import CONF_SENSOR_INDICES, DOMAIN +from .coordinator import PurpleAirDataUpdateCoordinator + +CONCENTRATION_IAQ = "iaq" +CONCENTRATION_PARTICLES_PER_100_MILLILITERS = f"particles/100{UnitOfVolume.MILLILITERS}" + + +@dataclass +class PurpleAirSensorEntityDescriptionMixin: + """Define a description mixin for PurpleAir sensor entities.""" + + value_fn: Callable[[SensorModel], float | str | None] + + +@dataclass +class PurpleAirSensorEntityDescription( + SensorEntityDescription, PurpleAirSensorEntityDescriptionMixin +): + """Define an object to describe PurpleAir sensor entities.""" + + +SENSOR_DESCRIPTIONS = [ + PurpleAirSensorEntityDescription( + key="humidity", + name="Humidity", + device_class=SensorDeviceClass.HUMIDITY, + native_unit_of_measurement=PERCENTAGE, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.humidity, + ), + PurpleAirSensorEntityDescription( + key="pm0.3_count_concentration", + name="PM0.3 count concentration", + entity_registry_enabled_default=False, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm0_3_um_count, + ), + PurpleAirSensorEntityDescription( + key="pm0.5_count_concentration", + name="PM0.5 count concentration", + entity_registry_enabled_default=False, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm0_5_um_count, + ), + PurpleAirSensorEntityDescription( + key="pm1.0_count_concentration", + name="PM1.0 count concentration", + entity_registry_enabled_default=False, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm1_0_um_count, + ), + PurpleAirSensorEntityDescription( + key="pm1.0_mass_concentration", + name="PM1.0 mass concentration", + device_class=SensorDeviceClass.PM1, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm1_0, + ), + PurpleAirSensorEntityDescription( + key="pm10.0_count_concentration", + name="PM10.0 count concentration", + entity_registry_enabled_default=False, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm10_0_um_count, + ), + PurpleAirSensorEntityDescription( + key="pm10.0_mass_concentration", + name="PM10.0 mass concentration", + device_class=SensorDeviceClass.PM10, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm10_0, + ), + PurpleAirSensorEntityDescription( + key="pm2.5_count_concentration", + name="PM2.5 count concentration", + entity_registry_enabled_default=False, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm2_5_um_count, + ), + PurpleAirSensorEntityDescription( + key="pm2.5_mass_concentration", + name="PM2.5 mass concentration", + device_class=SensorDeviceClass.PM25, + native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm2_5, + ), + PurpleAirSensorEntityDescription( + key="pm5.0_count_concentration", + name="PM5.0 count concentration", + entity_registry_enabled_default=False, + icon="mdi:blur", + native_unit_of_measurement=CONCENTRATION_PARTICLES_PER_100_MILLILITERS, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pm5_0_um_count, + ), + PurpleAirSensorEntityDescription( + key="pressure", + name="Pressure", + device_class=SensorDeviceClass.PRESSURE, + native_unit_of_measurement=UnitOfPressure.MBAR, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pressure, + ), + PurpleAirSensorEntityDescription( + key="rssi", + name="RSSI", + device_class=SensorDeviceClass.SIGNAL_STRENGTH, + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.pressure, + ), + PurpleAirSensorEntityDescription( + key="temperature", + name="Temperature", + device_class=SensorDeviceClass.TEMPERATURE, + native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.temperature, + ), + PurpleAirSensorEntityDescription( + key="uptime", + name="Uptime", + entity_category=EntityCategory.DIAGNOSTIC, + entity_registry_enabled_default=False, + icon="mdi:timer", + native_unit_of_measurement=UnitOfTime.MINUTES, + state_class=SensorStateClass.TOTAL_INCREASING, + value_fn=lambda sensor: sensor.uptime, + ), + PurpleAirSensorEntityDescription( + key="voc", + name="VOC", + device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS, + native_unit_of_measurement=CONCENTRATION_IAQ, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda sensor: sensor.voc, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up PurpleAir sensors based on a config entry.""" + coordinator: PurpleAirDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id] + async_add_entities( + PurpleAirSensorEntity(coordinator, entry, sensor_index, description) + for sensor_index in entry.options[CONF_SENSOR_INDICES] + for description in SENSOR_DESCRIPTIONS + ) + + +class PurpleAirSensorEntity(PurpleAirEntity, SensorEntity): + """Define a representation of a PurpleAir sensor.""" + + entity_description: PurpleAirSensorEntityDescription + + def __init__( + self, + coordinator: PurpleAirDataUpdateCoordinator, + entry: ConfigEntry, + sensor_index: int, + description: PurpleAirSensorEntityDescription, + ) -> None: + """Initialize.""" + super().__init__(coordinator, entry, sensor_index) + + self._attr_unique_id = f"{self._sensor_index}-{description.key}" + self.entity_description = description + + @property + def native_value(self) -> float | str | None: + """Return the sensor value.""" + return self.entity_description.value_fn(self.sensor_data) diff --git a/homeassistant/components/purpleair/strings.json b/homeassistant/components/purpleair/strings.json new file mode 100644 index 00000000000..8fb867fa969 --- /dev/null +++ b/homeassistant/components/purpleair/strings.json @@ -0,0 +1,44 @@ +{ + "config": { + "step": { + "by_coordinates": { + "description": "Search for a PurpleAir sensor within a certain distance of a latitude/longitude.", + "data": { + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "distance": "Search Radius" + }, + "data_description": { + "latitude": "The latitude around which to search for sensors", + "longitude": "The longitude around which to search for sensors", + "distance": "The radius (in kilometers) of the circle to search within" + } + }, + "choose_sensor": { + "description": "Which of the nearby sensors would you like to track?", + "data": { + "sensor_index": "Sensor" + }, + "data_description": { + "sensor_index": "The sensor to track" + } + }, + "user": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "Your PurpleAir API key (if you have both read and write keys, use the read key)" + } + } + }, + "error": { + "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", + "no_sensors_near_coordinates": "No sensors found near coordinates (within distance)", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/purpleair/translations/en.json b/homeassistant/components/purpleair/translations/en.json new file mode 100644 index 00000000000..d3e7ea6d63c --- /dev/null +++ b/homeassistant/components/purpleair/translations/en.json @@ -0,0 +1,44 @@ +{ + "config": { + "abort": { + "already_configured": "Device is already configured" + }, + "error": { + "invalid_api_key": "Invalid API key", + "no_sensors_near_coordinates": "No sensors found near coordinates (within distance)", + "unknown": "Unexpected error" + }, + "step": { + "by_coordinates": { + "data": { + "distance": "Search Radius", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "data_description": { + "distance": "The radius (in kilometers) of the circle to search within", + "latitude": "The latitude around which to search for sensors", + "longitude": "The longitude around which to search for sensors" + }, + "description": "Search for a PurpleAir sensor within a certain distance of a latitude/longitude." + }, + "choose_sensor": { + "data": { + "sensor_index": "Sensor" + }, + "data_description": { + "sensor_index": "The sensor to track" + }, + "description": "Which of the nearby sensors would you like to track?" + }, + "user": { + "data": { + "api_key": "API Key" + }, + "data_description": { + "api_key": "Your PurpleAir API key (if you have both read and write keys, use the read key)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index ef22ba649fa..c728d1dd7b5 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -320,6 +320,7 @@ FLOWS = { "prusalink", "ps4", "pure_energie", + "purpleair", "pushbullet", "pushover", "pvoutput", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 250c1d515e7..42a6dac3631 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4182,6 +4182,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "purpleair": { + "name": "PurpleAir", + "integration_type": "hub", + "config_flow": true, + "iot_class": "cloud_polling" + }, "push": { "name": "Push", "integration_type": "hub", diff --git a/mypy.ini b/mypy.ini index 9f2c0e6fbd6..f9db50bf38a 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2004,6 +2004,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.purpleair.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.pvoutput.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index 712065b27c3..7d0531acf4d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -237,6 +237,9 @@ aioopenexchangerates==0.4.0 # homeassistant.components.acmeda aiopulse==0.4.3 +# homeassistant.components.purpleair +aiopurpleair==2022.12.1 + # homeassistant.components.hunterdouglas_powerview aiopvapi==2.0.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 542e0719aa8..da94c974efe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -212,6 +212,9 @@ aioopenexchangerates==0.4.0 # homeassistant.components.acmeda aiopulse==0.4.3 +# homeassistant.components.purpleair +aiopurpleair==2022.12.1 + # homeassistant.components.hunterdouglas_powerview aiopvapi==2.0.4 diff --git a/tests/components/purpleair/__init__.py b/tests/components/purpleair/__init__.py new file mode 100644 index 00000000000..67883dabbe8 --- /dev/null +++ b/tests/components/purpleair/__init__.py @@ -0,0 +1 @@ +"""Tests for the PurpleAir integration.""" diff --git a/tests/components/purpleair/conftest.py b/tests/components/purpleair/conftest.py new file mode 100644 index 00000000000..ee484fd641d --- /dev/null +++ b/tests/components/purpleair/conftest.py @@ -0,0 +1,96 @@ +"""Define fixtures for PurpleAir tests.""" +from unittest.mock import AsyncMock, Mock, patch + +from aiopurpleair.endpoints.sensors import NearbySensorResult +from aiopurpleair.models.sensors import GetSensorsResponse +import pytest + +from homeassistant.components.purpleair import DOMAIN +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry, load_fixture + + +@pytest.fixture(name="api") +def api_fixture(check_api_key, get_nearby_sensors, get_sensors): + """Define a fixture to return a mocked aiopurple API object.""" + api = Mock(async_check_api_key=check_api_key) + api.sensors.async_get_nearby_sensors = get_nearby_sensors + api.sensors.async_get_sensors = get_sensors + return api + + +@pytest.fixture(name="check_api_key") +def check_api_key_fixture(): + """Define a fixture to mock the method to check an API key's validity.""" + return AsyncMock() + + +@pytest.fixture(name="config_entry") +def config_entry_fixture(hass, config_entry_data, config_entry_options): + """Define a config entry fixture.""" + entry = MockConfigEntry( + domain=DOMAIN, + title="abcde", + unique_id="abcde12345", + data=config_entry_data, + options=config_entry_options, + ) + entry.add_to_hass(hass) + return entry + + +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture(): + """Define a config entry data fixture.""" + return { + "api_key": "abcde12345", + } + + +@pytest.fixture(name="config_entry_options") +def config_entry_options_fixture(): + """Define a config entry options fixture.""" + return { + "sensor_indices": [123456], + } + + +@pytest.fixture(name="get_nearby_sensors") +def get_nearby_sensors_fixture(get_sensors_response): + """Define a mocked API.sensors.async_get_nearby_sensors.""" + return AsyncMock( + return_value=[ + NearbySensorResult(sensor=sensor, distance=1.0) + for sensor in get_sensors_response.data.values() + ] + ) + + +@pytest.fixture(name="get_sensors") +def get_sensors_fixture(get_sensors_response): + """Define a mocked API.sensors.async_get_sensors.""" + return AsyncMock(return_value=get_sensors_response) + + +@pytest.fixture(name="get_sensors_response", scope="package") +def get_sensors_response_fixture(): + """Define a fixture to mock an aiopurpleair GetSensorsResponse object.""" + return GetSensorsResponse.parse_raw( + load_fixture("get_sensors_response.json", "purpleair") + ) + + +@pytest.fixture(name="setup_purpleair") +async def setup_purpleair_fixture(hass, api, config_entry_data): + """Define a fixture to set up PurpleAir.""" + with patch( + "homeassistant.components.purpleair.config_flow.API", return_value=api + ), patch( + "homeassistant.components.purpleair.coordinator.API", return_value=api + ), patch( + "homeassistant.components.purpleair.PLATFORMS", [] + ): + assert await async_setup_component(hass, DOMAIN, config_entry_data) + await hass.async_block_till_done() + yield diff --git a/tests/components/purpleair/fixtures/__init__.py b/tests/components/purpleair/fixtures/__init__.py new file mode 100644 index 00000000000..08cb2cb5102 --- /dev/null +++ b/tests/components/purpleair/fixtures/__init__.py @@ -0,0 +1 @@ +"""Define data fixtures.""" diff --git a/tests/components/purpleair/fixtures/get_sensors_response.json b/tests/components/purpleair/fixtures/get_sensors_response.json new file mode 100644 index 00000000000..21f72d687f2 --- /dev/null +++ b/tests/components/purpleair/fixtures/get_sensors_response.json @@ -0,0 +1,62 @@ +{ + "api_version": "V1.0.11-0.0.41", + "time_stamp": 1668985817, + "data_time_stamp": 1668985800, + "max_age": 604800, + "firmware_default_version": "7.02", + "fields": [ + "sensor_index", + "name", + "location_type", + "model", + "hardware", + "firmware_version", + "rssi", + "uptime", + "latitude", + "longitude", + "altitude", + "humidity", + "temperature", + "pressure", + "voc", + "pm1.0", + "pm2.5", + "pm10.0", + "0.3_um_count", + "0.5_um_count", + "1.0_um_count", + "2.5_um_count", + "5.0_um_count", + "10.0_um_count" + ], + "location_types": ["outside", "inside"], + "data": [ + [ + 123456, + "Test Sensor", + 0, + "PA-II", + "2.0+BME280+PMSX003-B+PMSX003-A", + "7.02", + -69, + 13788, + 51.5285582, + -0.2416796, + 569, + 13, + 82, + 1000.74, + null, + 0.0, + 0.0, + 0.0, + 76, + 68, + 0, + 0, + 0, + 0 + ] + ] +} diff --git a/tests/components/purpleair/test_config_flow.py b/tests/components/purpleair/test_config_flow.py new file mode 100644 index 00000000000..8fcd2a7c2bb --- /dev/null +++ b/tests/components/purpleair/test_config_flow.py @@ -0,0 +1,104 @@ +"""Define tests for the PurpleAir config flow.""" +from unittest.mock import AsyncMock, patch + +from aiopurpleair.errors import InvalidApiKeyError, PurpleAirError +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.purpleair import DOMAIN +from homeassistant.config_entries import SOURCE_USER + + +async def test_duplicate_error(hass, config_entry, setup_purpleair): + """Test that the proper error is shown when adding a duplicate config entry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={"api_key": "abcde12345"} + ) + assert result["type"] == data_entry_flow.FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +@pytest.mark.parametrize( + "check_api_key_mock,check_api_key_errors", + [ + (AsyncMock(side_effect=Exception), {"base": "unknown"}), + (AsyncMock(side_effect=InvalidApiKeyError), {"base": "invalid_api_key"}), + (AsyncMock(side_effect=PurpleAirError), {"base": "unknown"}), + ], +) +@pytest.mark.parametrize( + "get_nearby_sensors_mock,get_nearby_sensors_errors", + [ + (AsyncMock(return_value=[]), {"base": "no_sensors_near_coordinates"}), + (AsyncMock(side_effect=Exception), {"base": "unknown"}), + (AsyncMock(side_effect=PurpleAirError), {"base": "unknown"}), + ], +) +async def test_create_entry_by_coordinates( + hass, + api, + check_api_key_errors, + check_api_key_mock, + get_nearby_sensors_errors, + get_nearby_sensors_mock, + setup_purpleair, +): + """Test creating an entry by entering a latitude/longitude (including errors).""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "user" + + # Test errors that can arise when checking the API key: + with patch.object(api, "async_check_api_key", check_api_key_mock): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"api_key": "abcde12345"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == check_api_key_errors + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={"api_key": "abcde12345"} + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "by_coordinates" + + # Test errors that can arise when searching for nearby sensors: + with patch.object(api.sensors, "async_get_nearby_sensors", get_nearby_sensors_mock): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "latitude": 51.5285582, + "longitude": -0.2416796, + "distance": 5, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["errors"] == get_nearby_sensors_errors + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "latitude": 51.5285582, + "longitude": -0.2416796, + "distance": 5, + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.FORM + assert result["step_id"] == "choose_sensor" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + "sensor_index": "123456", + }, + ) + assert result["type"] == data_entry_flow.FlowResultType.CREATE_ENTRY + assert result["title"] == "abcde" + assert result["data"] == { + "api_key": "abcde12345", + } + assert result["options"] == { + "sensor_indices": [123456], + }