diff --git a/CODEOWNERS b/CODEOWNERS index 8729873a1d0..0162683a939 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -46,7 +46,7 @@ homeassistant/components/auth/* @home-assistant/core homeassistant/components/automation/* @home-assistant/core homeassistant/components/avea/* @pattyland homeassistant/components/avri/* @timvancann -homeassistant/components/awair/* @danielsjf +homeassistant/components/awair/* @ahayworth @danielsjf homeassistant/components/aws/* @awarecan @robbiet480 homeassistant/components/axis/* @Kane610 homeassistant/components/azure_event_hub/* @eavanvalkenburg diff --git a/homeassistant/components/awair/__init__.py b/homeassistant/components/awair/__init__.py index c9a08cb40b5..c002693d6e9 100644 --- a/homeassistant/components/awair/__init__.py +++ b/homeassistant/components/awair/__init__.py @@ -1 +1,112 @@ """The awair component.""" + +from asyncio import gather +from typing import Any, Optional + +from async_timeout import timeout +from python_awair import Awair +from python_awair.exceptions import AuthError + +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.core import Config, HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import API_TIMEOUT, DOMAIN, LOGGER, UPDATE_INTERVAL, AwairResult + +PLATFORMS = ["sensor"] + + +async def async_setup(hass: HomeAssistant, config: Config) -> bool: + """Set up Awair integration.""" + return True + + +async def async_setup_entry(hass, config_entry) -> bool: + """Set up Awair integration from a config entry.""" + session = async_get_clientsession(hass) + coordinator = AwairDataUpdateCoordinator(hass, config_entry, session) + + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data.setdefault(DOMAIN, {}) + hass.data[DOMAIN][config_entry.entry_id] = coordinator + + for platform in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(config_entry, platform) + ) + + return True + + +async def async_unload_entry(hass, config_entry) -> bool: + """Unload Awair configuration.""" + tasks = [] + for platform in PLATFORMS: + tasks.append( + hass.config_entries.async_forward_entry_unload(config_entry, platform) + ) + + unload_ok = all(await gather(*tasks)) + if unload_ok: + hass.data[DOMAIN].pop(config_entry.entry_id) + + return unload_ok + + +class AwairDataUpdateCoordinator(DataUpdateCoordinator): + """Define a wrapper class to update Awair data.""" + + def __init__(self, hass, config_entry, session) -> None: + """Set up the AwairDataUpdateCoordinator class.""" + access_token = config_entry.data[CONF_ACCESS_TOKEN] + self._awair = Awair(access_token=access_token, session=session) + self._config_entry = config_entry + + super().__init__(hass, LOGGER, name=DOMAIN, update_interval=UPDATE_INTERVAL) + + async def _async_update_data(self) -> Optional[Any]: + """Update data via Awair client library.""" + with timeout(API_TIMEOUT): + try: + LOGGER.debug("Fetching users and devices") + user = await self._awair.user() + devices = await user.devices() + results = await gather( + *[self._fetch_air_data(device) for device in devices] + ) + return {result.device.uuid: result for result in results} + except AuthError as err: + flow_context = { + "source": "reauth", + "unique_id": self._config_entry.unique_id, + } + + matching_flows = [ + flow + for flow in self.hass.config_entries.flow.async_progress() + if flow["context"] == flow_context + ] + + if not matching_flows: + self.hass.async_create_task( + self.hass.config_entries.flow.async_init( + DOMAIN, context=flow_context, data=self._config_entry.data, + ) + ) + + raise UpdateFailed(err) + except Exception as err: + raise UpdateFailed(err) + + async def _fetch_air_data(self, device): + """Fetch latest air quality data.""" + LOGGER.debug("Fetching data for %s", device.uuid) + air_data = await device.air_data_latest() + LOGGER.debug(air_data) + return AwairResult(device=device, air_data=air_data) diff --git a/homeassistant/components/awair/config_flow.py b/homeassistant/components/awair/config_flow.py new file mode 100644 index 00000000000..886a51342c5 --- /dev/null +++ b/homeassistant/components/awair/config_flow.py @@ -0,0 +1,109 @@ +"""Config flow for Awair.""" + +from typing import Optional + +from python_awair import Awair +from python_awair.exceptions import AuthError, AwairError +import voluptuous as vol + +from homeassistant.config_entries import CONN_CLASS_CLOUD_POLL, ConfigFlow +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN, LOGGER # pylint: disable=unused-import + + +class AwairFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow for Awair.""" + + VERSION = 1 + CONNECTION_CLASS = CONN_CLASS_CLOUD_POLL + + async def async_step_import(self, conf: dict): + """Import a configuration from config.yaml.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason="already_setup") + + user, error = await self._check_connection(conf[CONF_ACCESS_TOKEN]) + if error is not None: + return self.async_abort(reason=error) + + await self.async_set_unique_id(user.email) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=f"{user.email} ({user.user_id})", + data={CONF_ACCESS_TOKEN: conf[CONF_ACCESS_TOKEN]}, + ) + + async def async_step_user(self, user_input: Optional[dict] = None): + """Handle a flow initialized by the user.""" + errors = {} + + if user_input is not None: + user, error = await self._check_connection(user_input[CONF_ACCESS_TOKEN]) + + if user is not None: + await self.async_set_unique_id(user.email) + self._abort_if_unique_id_configured() + + title = f"{user.email} ({user.user_id})" + return self.async_create_entry(title=title, data=user_input) + + if error != "auth": + return self.async_abort(reason=error) + + errors = {CONF_ACCESS_TOKEN: "auth"} + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}), + errors=errors, + ) + + async def async_step_reauth(self, user_input: Optional[dict] = None): + """Handle re-auth if token invalid.""" + errors = {} + + if user_input is not None: + access_token = user_input[CONF_ACCESS_TOKEN] + _, error = await self._check_connection(access_token) + + if error is None: + for entry in self._async_current_entries(): + if entry.unique_id == self.unique_id: + self.hass.config_entries.async_update_entry( + entry, data=user_input + ) + + return self.async_abort(reason="reauth_successful") + + if error != "auth": + return self.async_abort(reason=error) + + errors = {CONF_ACCESS_TOKEN: error} + + return self.async_show_form( + step_id="reauth", + data_schema=vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str}), + errors=errors, + ) + + async def _check_connection(self, access_token: str): + """Check the access token is valid.""" + session = async_get_clientsession(self.hass) + awair = Awair(access_token=access_token, session=session) + + try: + user = await awair.user() + devices = await user.devices() + if not devices: + return (None, "no_devices") + + return (user, None) + + except AuthError: + return (None, "auth") + except AwairError as err: + LOGGER.error("Unexpected API error: %s", err) + return (None, "unknown") diff --git a/homeassistant/components/awair/const.py b/homeassistant/components/awair/const.py new file mode 100644 index 00000000000..5735078eee5 --- /dev/null +++ b/homeassistant/components/awair/const.py @@ -0,0 +1,120 @@ +"""Constants for the Awair component.""" + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from python_awair.devices import AwairDevice + +from homeassistant.const import ( + ATTR_DEVICE_CLASS, + CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + CONCENTRATION_PARTS_PER_BILLION, + CONCENTRATION_PARTS_PER_MILLION, + DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, + DEVICE_CLASS_TEMPERATURE, + TEMP_CELSIUS, + UNIT_PERCENTAGE, +) + +API_CO2 = "carbon_dioxide" +API_DUST = "dust" +API_HUMID = "humidity" +API_LUX = "illuminance" +API_PM10 = "particulate_matter_10" +API_PM25 = "particulate_matter_2_5" +API_SCORE = "score" +API_SPL_A = "sound_pressure_level" +API_TEMP = "temperature" +API_TIMEOUT = 20 +API_VOC = "volatile_organic_compounds" + +ATTRIBUTION = "Awair air quality sensor" + +ATTR_ICON = "icon" +ATTR_LABEL = "label" +ATTR_UNIT = "unit" +ATTR_UNIQUE_ID = "unique_id" + +DOMAIN = "awair" + +DUST_ALIASES = [API_PM25, API_PM10] + +LOGGER = logging.getLogger(__package__) + +UPDATE_INTERVAL = timedelta(minutes=5) + +SENSOR_TYPES = { + API_SCORE: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_LABEL: "Awair score", + ATTR_UNIQUE_ID: "score", # matches legacy format + }, + API_HUMID: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_HUMIDITY, + ATTR_ICON: None, + ATTR_UNIT: UNIT_PERCENTAGE, + ATTR_LABEL: "Humidity", + ATTR_UNIQUE_ID: "HUMID", # matches legacy format + }, + API_LUX: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_ILLUMINANCE, + ATTR_ICON: None, + ATTR_UNIT: "lx", + ATTR_LABEL: "Illuminance", + ATTR_UNIQUE_ID: "illuminance", + }, + API_SPL_A: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:ear-hearing", + ATTR_UNIT: "dBa", + ATTR_LABEL: "Sound level", + ATTR_UNIQUE_ID: "sound_level", + }, + API_VOC: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:cloud", + ATTR_UNIT: CONCENTRATION_PARTS_PER_BILLION, + ATTR_LABEL: "Volatile organic compounds", + ATTR_UNIQUE_ID: "VOC", # matches legacy format + }, + API_TEMP: { + ATTR_DEVICE_CLASS: DEVICE_CLASS_TEMPERATURE, + ATTR_ICON: None, + ATTR_UNIT: TEMP_CELSIUS, + ATTR_LABEL: "Temperature", + ATTR_UNIQUE_ID: "TEMP", # matches legacy format + }, + API_PM25: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_LABEL: "PM2.5", + ATTR_UNIQUE_ID: "PM25", # matches legacy format + }, + API_PM10: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:blur", + ATTR_UNIT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + ATTR_LABEL: "PM10", + ATTR_UNIQUE_ID: "PM10", # matches legacy format + }, + API_CO2: { + ATTR_DEVICE_CLASS: None, + ATTR_ICON: "mdi:cloud", + ATTR_UNIT: CONCENTRATION_PARTS_PER_MILLION, + ATTR_LABEL: "Carbon dioxide", + ATTR_UNIQUE_ID: "CO2", # matches legacy format + }, +} + + +@dataclass +class AwairResult: + """Wrapper class to hold an awair device and set of air data.""" + + device: AwairDevice + air_data: dict diff --git a/homeassistant/components/awair/manifest.json b/homeassistant/components/awair/manifest.json index 2ead58c0fe8..8ae89951442 100644 --- a/homeassistant/components/awair/manifest.json +++ b/homeassistant/components/awair/manifest.json @@ -2,6 +2,7 @@ "domain": "awair", "name": "Awair", "documentation": "https://www.home-assistant.io/integrations/awair", - "requirements": ["python_awair==0.0.4"], - "codeowners": ["@danielsjf"] + "requirements": ["python_awair==0.1.1"], + "codeowners": ["@ahayworth", "@danielsjf"], + "config_flow": true } diff --git a/homeassistant/components/awair/sensor.py b/homeassistant/components/awair/sensor.py index 301055c7e61..e4e2f3fbbd6 100644 --- a/homeassistant/components/awair/sensor.py +++ b/homeassistant/components/awair/sensor.py @@ -1,248 +1,245 @@ -"""Support for the Awair indoor air quality monitor.""" +"""Support for Awair sensors.""" -from datetime import timedelta -import logging -import math +from typing import Callable, List, Optional -from python_awair import AwairClient +from python_awair.devices import AwairDevice import voluptuous as vol -from homeassistant.const import ( - CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - CONCENTRATION_PARTS_PER_BILLION, - CONCENTRATION_PARTS_PER_MILLION, - CONF_ACCESS_TOKEN, - CONF_DEVICES, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, - UNIT_PERCENTAGE, -) -from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.components.awair import AwairDataUpdateCoordinator, AwairResult +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ATTR_ATTRIBUTION, ATTR_DEVICE_CLASS, CONF_ACCESS_TOKEN +from homeassistant.helpers import device_registry as dr import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle, dt +from homeassistant.helpers.typing import ConfigType, HomeAssistantType -_LOGGER = logging.getLogger(__name__) +from .const import ( + API_DUST, + API_PM25, + API_SCORE, + API_TEMP, + API_VOC, + ATTR_ICON, + ATTR_LABEL, + ATTR_UNIQUE_ID, + ATTR_UNIT, + ATTRIBUTION, + DOMAIN, + DUST_ALIASES, + LOGGER, + SENSOR_TYPES, +) -ATTR_SCORE = "score" -ATTR_TIMESTAMP = "timestamp" -ATTR_LAST_API_UPDATE = "last_api_update" -ATTR_COMPONENT = "component" -ATTR_VALUE = "value" -ATTR_SENSORS = "sensors" - -CONF_UUID = "uuid" - -DEVICE_CLASS_PM2_5 = "PM2.5" -DEVICE_CLASS_PM10 = "PM10" -DEVICE_CLASS_CARBON_DIOXIDE = "CO2" -DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS = "VOC" -DEVICE_CLASS_SCORE = "score" - -SENSOR_TYPES = { - "TEMP": { - "device_class": DEVICE_CLASS_TEMPERATURE, - "unit_of_measurement": TEMP_CELSIUS, - "icon": "mdi:thermometer", - }, - "HUMID": { - "device_class": DEVICE_CLASS_HUMIDITY, - "unit_of_measurement": UNIT_PERCENTAGE, - "icon": "mdi:water-percent", - }, - "CO2": { - "device_class": DEVICE_CLASS_CARBON_DIOXIDE, - "unit_of_measurement": CONCENTRATION_PARTS_PER_MILLION, - "icon": "mdi:periodic-table-co2", - }, - "VOC": { - "device_class": DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, - "unit_of_measurement": CONCENTRATION_PARTS_PER_BILLION, - "icon": "mdi:cloud", - }, - # Awair docs don't actually specify the size they measure for 'dust', - # but 2.5 allows the sensor to show up in HomeKit - "DUST": { - "device_class": DEVICE_CLASS_PM2_5, - "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - "icon": "mdi:cloud", - }, - "PM25": { - "device_class": DEVICE_CLASS_PM2_5, - "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - "icon": "mdi:cloud", - }, - "PM10": { - "device_class": DEVICE_CLASS_PM10, - "unit_of_measurement": CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, - "icon": "mdi:cloud", - }, - "score": { - "device_class": DEVICE_CLASS_SCORE, - "unit_of_measurement": UNIT_PERCENTAGE, - "icon": "mdi:percent", - }, -} - -AWAIR_QUOTA = 300 - -# This is the minimum time between throttled update calls. -# Don't bother asking us for state more often than that. -SCAN_INTERVAL = timedelta(minutes=5) - -AWAIR_DEVICE_SCHEMA = vol.Schema({vol.Required(CONF_UUID): cv.string}) - -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_ACCESS_TOKEN): cv.string, - vol.Optional(CONF_DEVICES): vol.All(cv.ensure_list, [AWAIR_DEVICE_SCHEMA]), - } +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( + {vol.Required(CONF_ACCESS_TOKEN): cv.string}, extra=vol.ALLOW_EXTRA, ) -# Awair *heavily* throttles calls that get user information, -# and calls that get the list of user-owned devices - they -# allow 30 per DAY. So, we permit a user to provide a static -# list of devices, and they may provide the same set of information -# that the devices() call would return. However, the only thing -# used at this time is the `uuid` value. async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): - """Connect to the Awair API and find devices.""" + """Import Awair configuration from YAML.""" + LOGGER.warning( + "Loading Awair via platform setup is deprecated. Please remove it from your configuration." + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config, + ) + ) - token = config[CONF_ACCESS_TOKEN] - client = AwairClient(token, session=async_get_clientsession(hass)) - try: - all_devices = [] - devices = config.get(CONF_DEVICES, await client.devices()) +async def async_setup_entry( + hass: HomeAssistantType, + config_entry: ConfigType, + async_add_entities: Callable[[List[Entity], bool], None], +): + """Set up Awair sensor entity based on a config entry.""" + coordinator = hass.data[DOMAIN][config_entry.entry_id] + sensors = [] - # Try to throttle dynamically based on quota and number of devices. - throttle_minutes = math.ceil(60 / ((AWAIR_QUOTA / len(devices)) / 24)) - throttle = timedelta(minutes=throttle_minutes) + data: List[AwairResult] = coordinator.data.values() + for result in data: + if result.air_data: + sensors.append(AwairSensor(API_SCORE, result.device, coordinator)) + device_sensors = result.air_data.sensors.keys() + for sensor in device_sensors: + if sensor in SENSOR_TYPES: + sensors.append(AwairSensor(sensor, result.device, coordinator)) - for device in devices: - _LOGGER.debug("Found awair device: %s", device) - awair_data = AwairData(client, device[CONF_UUID], throttle) - await awair_data.async_update() - for sensor in SENSOR_TYPES: - if sensor in awair_data.data: - awair_sensor = AwairSensor(awair_data, device, sensor, throttle) - all_devices.append(awair_sensor) + # The "DUST" sensor for Awair is a combo pm2.5/pm10 sensor only + # present on first-gen devices in lieu of separate pm2.5/pm10 sensors. + # We handle that by creating fake pm2.5/pm10 sensors that will always + # report identical values, and we let users decide how they want to use + # that data - because we can't really tell what kind of particles the + # "DUST" sensor actually detected. However, it's still useful data. + if API_DUST in device_sensors: + for alias_kind in DUST_ALIASES: + sensors.append(AwairSensor(alias_kind, result.device, coordinator)) - async_add_entities(all_devices, True) - return - except AwairClient.AuthError: - _LOGGER.error("Awair API access_token invalid") - except AwairClient.RatelimitError: - _LOGGER.error("Awair API ratelimit exceeded.") - except ( - AwairClient.QueryError, - AwairClient.NotFoundError, - AwairClient.GenericError, - ) as error: - _LOGGER.error("Unexpected Awair API error: %s", error) - - raise PlatformNotReady + async_add_entities(sensors) class AwairSensor(Entity): - """Implementation of an Awair device.""" + """Defines an Awair sensor entity.""" - def __init__(self, data, device, sensor_type, throttle): - """Initialize the sensor.""" - self._uuid = device[CONF_UUID] - self._device_class = SENSOR_TYPES[sensor_type]["device_class"] - self._name = f"Awair {self._device_class}" - unit = SENSOR_TYPES[sensor_type]["unit_of_measurement"] - self._unit_of_measurement = unit - self._data = data - self._type = sensor_type - self._throttle = throttle + def __init__( + self, kind: str, device: AwairDevice, coordinator: AwairDataUpdateCoordinator, + ) -> None: + """Set up an individual AwairSensor.""" + self._kind = kind + self._device = device + self._coordinator = coordinator @property - def name(self): + def should_poll(self) -> bool: + """Return the polling requirement of the entity.""" + return False + + @property + def name(self) -> str: """Return the name of the sensor.""" - return self._name + name = SENSOR_TYPES[self._kind][ATTR_LABEL] + if self._device.name: + name = f"{self._device.name} {name}" + + return name @property - def device_class(self): - """Return the device class.""" - return self._device_class + def unique_id(self) -> str: + """Return the uuid as the unique_id.""" + unique_id_tag = SENSOR_TYPES[self._kind][ATTR_UNIQUE_ID] + + # This integration used to create a sensor that was labelled as a "PM2.5" + # sensor for first-gen Awair devices, but its unique_id reflected the truth: + # under the hood, it was a "DUST" sensor. So we preserve that specific unique_id + # for users with first-gen devices that are upgrading. + if self._kind == API_PM25 and API_DUST in self._air_data.sensors: + unique_id_tag = "DUST" + + return f"{self._device.uuid}_{unique_id_tag}" @property - def icon(self): - """Icon to use in the frontend.""" - return SENSOR_TYPES[self._type]["icon"] + def available(self) -> bool: + """Determine if the sensor is available based on API results.""" + # If the last update was successful... + if self._coordinator.last_update_success and self._air_data: + # and the results included our sensor type... + if self._kind in self._air_data.sensors: + # then we are available. + return True + + # or, we're a dust alias + if self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + return True + + # or we are API_SCORE + if self._kind == API_SCORE: + # then we are available. + return True + + # Otherwise, we are not. + return False @property - def state(self): - """Return the state of the device.""" - return self._data.data[self._type] + def state(self) -> float: + """Return the state, rounding off to reasonable values.""" + state: float + + # Special-case for "SCORE", which we treat as the AQI + if self._kind == API_SCORE: + state = self._air_data.score + elif self._kind in DUST_ALIASES and API_DUST in self._air_data.sensors: + state = self._air_data.sensors.dust + else: + state = self._air_data.sensors[self._kind] + + if self._kind == API_VOC or self._kind == API_SCORE: + return round(state) + + if self._kind == API_TEMP: + return round(state, 1) + + return round(state, 2) @property - def device_state_attributes(self): - """Return additional attributes.""" - return self._data.attrs - - # The Awair device should be reporting metrics in quite regularly. - # Based on the raw data from the API, it looks like every ~10 seconds - # is normal. Here we assert that the device is not available if the - # last known API timestamp is more than (3 * throttle) minutes in the - # past. It implies that either hass is somehow unable to query the API - # for new data or that the device is not checking in. Either condition - # fits the definition for 'not available'. We pick (3 * throttle) minutes - # to allow for transient errors to correct themselves. - @property - def available(self): - """Device availability based on the last update timestamp.""" - if ATTR_LAST_API_UPDATE not in self.device_state_attributes: - return False - - last_api_data = self.device_state_attributes[ATTR_LAST_API_UPDATE] - return (dt.utcnow() - last_api_data) < (3 * self._throttle) + def icon(self) -> str: + """Return the icon.""" + return SENSOR_TYPES[self._kind][ATTR_ICON] @property - def unique_id(self): - """Return the unique id of this entity.""" - return f"{self._uuid}_{self._type}" + def device_class(self) -> str: + """Return the device_class.""" + return SENSOR_TYPES[self._kind][ATTR_DEVICE_CLASS] @property - def unit_of_measurement(self): - """Return the unit of measurement of this entity.""" - return self._unit_of_measurement + def unit_of_measurement(self) -> str: + """Return the unit the value is expressed in.""" + return SENSOR_TYPES[self._kind][ATTR_UNIT] - async def async_update(self): - """Get the latest data.""" - await self._data.async_update() + @property + def device_state_attributes(self) -> dict: + """Return the Awair Index alongside state attributes. + The Awair Index is a subjective score ranging from 0-4 (inclusive) that + is is used by the Awair app when displaying the relative "safety" of a + given measurement. Each value is mapped to a color indicating the safety: -class AwairData: - """Get data from Awair API.""" + 0: green + 1: yellow + 2: light-orange + 3: orange + 4: red - def __init__(self, client, uuid, throttle): - """Initialize the data object.""" - self._client = client - self._uuid = uuid - self.data = {} - self.attrs = {} - self.async_update = Throttle(throttle)(self._async_update) + The API indicates that both positive and negative values may be returned, + but the negative values are mapped to identical colors as the positive values. + Knowing that, we just return the absolute value of a given index so that + users don't have to handle positive/negative values that ultimately "mean" + the same thing. - async def _async_update(self): - """Get the data from Awair API.""" - resp = await self._client.air_data_latest(self._uuid) + https://docs.developer.getawair.com/?version=latest#awair-score-and-index + """ + attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + if self._kind in self._air_data.indices: + attrs["awair_index"] = abs(self._air_data.indices[self._kind]) + elif self._kind in DUST_ALIASES and API_DUST in self._air_data.indices: + attrs["awair_index"] = abs(self._air_data.indices.dust) - if not resp: - return + return attrs - timestamp = dt.parse_datetime(resp[0][ATTR_TIMESTAMP]) - self.attrs[ATTR_LAST_API_UPDATE] = timestamp - self.data[ATTR_SCORE] = resp[0][ATTR_SCORE] + @property + def device_info(self) -> dict: + """Device information.""" + info = { + "identifiers": {(DOMAIN, self._device.uuid)}, + "manufacturer": "Awair", + "model": self._device.model, + } - # The air_data_latest call only returns one item, so this should - # be safe to only process one entry. - for sensor in resp[0][ATTR_SENSORS]: - self.data[sensor[ATTR_COMPONENT]] = round(sensor[ATTR_VALUE], 1) + if self._device.name: + info["name"] = self._device.name - _LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data) + if self._device.mac_address: + info["connections"] = { + (dr.CONNECTION_NETWORK_MAC, self._device.mac_address) + } + + return info + + async def async_added_to_hass(self) -> None: + """Connect to dispatcher listening for entity data notifications.""" + self.async_on_remove( + self._coordinator.async_add_listener(self.async_write_ha_state) + ) + + async def async_update(self) -> None: + """Update Awair entity.""" + await self._coordinator.async_request_refresh() + + @property + def _air_data(self) -> Optional[AwairResult]: + """Return the latest data for our device, or None.""" + result: Optional[AwairResult] = self._coordinator.data.get(self._device.uuid) + if result: + return result.air_data + + return None diff --git a/homeassistant/components/awair/strings.json b/homeassistant/components/awair/strings.json new file mode 100644 index 00000000000..1351cbd2db0 --- /dev/null +++ b/homeassistant/components/awair/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "description": "You must register for an Awair developer access token at: https://developer.getawair.com/onboard/login", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]", + "email": "[%key:common::config_flow::data::email%]" + } + }, + "reauth": { + "description": "Please re-enter your Awair developer access token.", + "data": { + "access_token": "[%key:common::config_flow::data::access_token%]", + "email": "[%key:common::config_flow::data::email%]" + } + } + }, + "error": { + "auth": "[%key:common::config_flow::error::invalid_access_token%]", + "unknown": "Unknown Awair API error." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "no_devices": "[%key:common::config_flow::abort::no_devices_found%]", + "reauth_successful": "[%key:common::config_flow::data::access_token%] updated successfully" + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index aa4e71c18f5..1b40ec9e5b1 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -19,6 +19,7 @@ FLOWS = [ "atag", "august", "avri", + "awair", "axis", "blebox", "blink", diff --git a/requirements_all.txt b/requirements_all.txt index 41be4d3c5c2..91b2a038f79 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1772,7 +1772,7 @@ python-whois==0.7.2 python-wink==1.10.5 # homeassistant.components.awair -python_awair==0.0.4 +python_awair==0.1.1 # homeassistant.components.swiss_public_transport python_opendata_transport==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b250164138e..adaadc4ef68 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -760,7 +760,7 @@ python-twitch-client==0.6.0 python-velbus==2.0.43 # homeassistant.components.awair -python_awair==0.0.4 +python_awair==0.1.1 # homeassistant.components.tile pytile==3.0.6 diff --git a/tests/components/awair/const.py b/tests/components/awair/const.py new file mode 100644 index 00000000000..94c07e9e9fd --- /dev/null +++ b/tests/components/awair/const.py @@ -0,0 +1,20 @@ +"""Constants used in Awair tests.""" + +import json + +from homeassistant.const import CONF_ACCESS_TOKEN + +from tests.common import load_fixture + +AWAIR_UUID = "awair_24947" +CONFIG = {CONF_ACCESS_TOKEN: "12345"} +UNIQUE_ID = "foo@bar.com" +DEVICES_FIXTURE = json.loads(load_fixture("awair/devices.json")) +GEN1_DATA_FIXTURE = json.loads(load_fixture("awair/awair.json")) +GEN2_DATA_FIXTURE = json.loads(load_fixture("awair/awair-r2.json")) +GLOW_DATA_FIXTURE = json.loads(load_fixture("awair/glow.json")) +MINT_DATA_FIXTURE = json.loads(load_fixture("awair/mint.json")) +NO_DEVICES_FIXTURE = json.loads(load_fixture("awair/no_devices.json")) +OFFLINE_FIXTURE = json.loads(load_fixture("awair/awair-offline.json")) +OMNI_DATA_FIXTURE = json.loads(load_fixture("awair/omni.json")) +USER_FIXTURE = json.loads(load_fixture("awair/user.json")) diff --git a/tests/components/awair/test_config_flow.py b/tests/components/awair/test_config_flow.py new file mode 100644 index 00000000000..bbd37bda075 --- /dev/null +++ b/tests/components/awair/test_config_flow.py @@ -0,0 +1,190 @@ +"""Define tests for the Awair config flow.""" + +from asynctest import patch +from python_awair.exceptions import AuthError, AwairError + +from homeassistant import data_entry_flow +from homeassistant.components.awair.const import DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_ACCESS_TOKEN + +from .const import CONFIG, DEVICES_FIXTURE, NO_DEVICES_FIXTURE, UNIQUE_ID, USER_FIXTURE + +from tests.common import MockConfigEntry + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_FORM + assert result["step_id"] == SOURCE_USER + + +async def test_invalid_access_token(hass): + """Test that errors are shown when the access token is invalid.""" + + with patch("python_awair.AwairClient.query", side_effect=AuthError()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["errors"] == {CONF_ACCESS_TOKEN: "auth"} + + +async def test_unexpected_api_error(hass): + """Test that we abort on generic errors.""" + + with patch("python_awair.AwairClient.query", side_effect=AwairError()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + + +async def test_duplicate_error(hass): + """Test that errors are shown when adding a duplicate config.""" + + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] + ), patch( + "homeassistant.components.awair.sensor.async_setup_entry", return_value=True, + ): + MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_configured" + + +async def test_no_devices_error(hass): + """Test that errors are shown when the API returns no devices.""" + + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, NO_DEVICES_FIXTURE] + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == "abort" + assert result["reason"] == "no_devices" + + +async def test_import(hass): + """Test config.yaml import.""" + + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] + ), patch( + "homeassistant.components.awair.sensor.async_setup_entry", return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_ACCESS_TOKEN: CONFIG[CONF_ACCESS_TOKEN]}, + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "foo@bar.com (32406)" + assert result["data"][CONF_ACCESS_TOKEN] == CONFIG[CONF_ACCESS_TOKEN] + assert result["result"].unique_id == UNIQUE_ID + + +async def test_import_aborts_on_api_error(hass): + """Test config.yaml imports on api error.""" + + with patch("python_awair.AwairClient.query", side_effect=AwairError()): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_ACCESS_TOKEN: CONFIG[CONF_ACCESS_TOKEN]}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + + +async def test_import_aborts_if_configured(hass): + """Test config import doesn't re-import unnecessarily.""" + + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] + ), patch( + "homeassistant.components.awair.sensor.async_setup_entry", return_value=True, + ): + MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG).add_to_hass( + hass + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_ACCESS_TOKEN: CONFIG[CONF_ACCESS_TOKEN]}, + ) + + assert result["type"] == "abort" + assert result["reason"] == "already_setup" + + +async def test_reauth(hass): + """Test reauth flow.""" + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] + ), patch( + "homeassistant.components.awair.sensor.async_setup_entry", return_value=True, + ): + mock_config = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG) + mock_config.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config, data={**CONFIG, CONF_ACCESS_TOKEN: "blah"} + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG, + ) + + assert result["type"] == "abort" + assert result["reason"] == "reauth_successful" + + with patch("python_awair.AwairClient.query", side_effect=AuthError()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG, + ) + + assert result["errors"] == {CONF_ACCESS_TOKEN: "auth"} + + with patch("python_awair.AwairClient.query", side_effect=AwairError()): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": "reauth", "unique_id": UNIQUE_ID}, data=CONFIG, + ) + + assert result["type"] == "abort" + assert result["reason"] == "unknown" + + +async def test_create_entry(hass): + """Test overall flow.""" + + with patch( + "python_awair.AwairClient.query", side_effect=[USER_FIXTURE, DEVICES_FIXTURE] + ), patch( + "homeassistant.components.awair.sensor.async_setup_entry", return_value=True, + ): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "foo@bar.com (32406)" + assert result["data"][CONF_ACCESS_TOKEN] == CONFIG[CONF_ACCESS_TOKEN] + assert result["result"].unique_id == UNIQUE_ID diff --git a/tests/components/awair/test_sensor.py b/tests/components/awair/test_sensor.py index d1a3b933d05..00c469e3747 100644 --- a/tests/components/awair/test_sensor.py +++ b/tests/components/awair/test_sensor.py @@ -1,312 +1,342 @@ """Tests for the Awair sensor platform.""" -from contextlib import contextmanager -from datetime import timedelta -import json -import logging - -from homeassistant.components.awair.sensor import ( - ATTR_LAST_API_UPDATE, - ATTR_TIMESTAMP, - DEVICE_CLASS_CARBON_DIOXIDE, - DEVICE_CLASS_PM2_5, - DEVICE_CLASS_SCORE, - DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, +from homeassistant.components.awair.const import ( + API_CO2, + API_HUMID, + API_LUX, + API_PM10, + API_PM25, + API_SCORE, + API_SPL_A, + API_TEMP, + API_VOC, + ATTR_UNIQUE_ID, + DOMAIN, + SENSOR_TYPES, ) -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.const import ( + ATTR_ICON, + ATTR_UNIT_OF_MEASUREMENT, CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, CONCENTRATION_PARTS_PER_BILLION, CONCENTRATION_PARTS_PER_MILLION, - DEVICE_CLASS_HUMIDITY, - DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, TEMP_CELSIUS, UNIT_PERCENTAGE, ) -from homeassistant.setup import async_setup_component -from homeassistant.util.dt import parse_datetime, utcnow + +from .const import ( + AWAIR_UUID, + CONFIG, + DEVICES_FIXTURE, + GEN1_DATA_FIXTURE, + GEN2_DATA_FIXTURE, + GLOW_DATA_FIXTURE, + MINT_DATA_FIXTURE, + OFFLINE_FIXTURE, + OMNI_DATA_FIXTURE, + UNIQUE_ID, + USER_FIXTURE, +) from tests.async_mock import patch -from tests.common import async_fire_time_changed, load_fixture - -DISCOVERY_CONFIG = {"sensor": {"platform": "awair", "access_token": "qwerty"}} - -MANUAL_CONFIG = { - "sensor": { - "platform": "awair", - "access_token": "qwerty", - "devices": [{"uuid": "awair_foo"}], - } -} - -_LOGGER = logging.getLogger(__name__) - -NOW = utcnow() -AIR_DATA_FIXTURE = json.loads(load_fixture("awair_air_data_latest.json")) -AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP] = str(NOW) -AIR_DATA_FIXTURE_UPDATED = json.loads( - load_fixture("awair_air_data_latest_updated.json") -) -AIR_DATA_FIXTURE_UPDATED[0][ATTR_TIMESTAMP] = str(NOW + timedelta(minutes=5)) -AIR_DATA_FIXTURE_EMPTY = [] +from tests.common import MockConfigEntry -@contextmanager -def alter_time(retval): - """Manage multiple time mocks.""" - patch_one = patch("homeassistant.util.dt.utcnow", return_value=retval) - patch_two = patch("homeassistant.util.utcnow", return_value=retval) - patch_three = patch( - "homeassistant.components.awair.sensor.dt.utcnow", return_value=retval - ) +async def setup_awair(hass, fixtures): + """Add Awair devices to hass, using specified fixtures for data.""" - with patch_one, patch_two, patch_three: - yield - - -async def setup_awair(hass, config=None, data_fixture=AIR_DATA_FIXTURE): - """Load the Awair platform.""" - devices_json = json.loads(load_fixture("awair_devices.json")) - devices_mock = devices_json - devices_patch = patch("python_awair.AwairClient.devices", return_value=devices_mock) - air_data_mock = data_fixture - air_data_patch = patch( - "python_awair.AwairClient.air_data_latest", return_value=air_data_mock - ) - - if config is None: - config = DISCOVERY_CONFIG - - with devices_patch, air_data_patch, alter_time(NOW): - assert await async_setup_component(hass, SENSOR_DOMAIN, config) + entry = MockConfigEntry(domain=DOMAIN, unique_id=UNIQUE_ID, data=CONFIG) + with patch("python_awair.AwairClient.query", side_effect=fixtures): + entry.add_to_hass(hass) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() -async def test_platform_manually_configured(hass): - """Test that we can manually configure devices.""" - await setup_awair(hass, MANUAL_CONFIG) +def assert_expected_properties( + hass, registry, name, unique_id, state_value, attributes +): + """Assert expected properties from a dict.""" - assert len(hass.states.async_all()) == 6 - - # Ensure that we loaded the device with uuid 'awair_foo', not the - # 'awair_12345' device that we stub out for API device discovery - entity = hass.data[SENSOR_DOMAIN].get_entity("sensor.awair_co2") - assert entity.unique_id == "awair_foo_CO2" + entry = registry.async_get(name) + assert entry.unique_id == unique_id + state = hass.states.get(name) + assert state + assert state.state == state_value + for attr, value in attributes.items(): + assert state.attributes.get(attr) == value -async def test_platform_automatically_configured(hass): - """Test that we can discover devices from the API.""" - await setup_awair(hass) +async def test_awair_gen1_sensors(hass): + """Test expected sensors on a 1st gen Awair.""" - assert len(hass.states.async_all()) == 6 + fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN1_DATA_FIXTURE] + await setup_awair(hass, fixtures) + registry = await hass.helpers.entity_registry.async_get_registry() - # Ensure that we loaded the device with uuid 'awair_12345', which is - # the device that we stub out for API device discovery - entity = hass.data[SENSOR_DOMAIN].get_entity("sensor.awair_co2") - assert entity.unique_id == "awair_12345_CO2" - - -async def test_bad_platform_setup(hass): - """Tests that we throw correct exceptions when setting up Awair.""" - from python_awair import AwairClient - - auth_patch = patch( - "python_awair.AwairClient.devices", side_effect=AwairClient.AuthError - ) - rate_patch = patch( - "python_awair.AwairClient.devices", side_effect=AwairClient.RatelimitError - ) - generic_patch = patch( - "python_awair.AwairClient.devices", side_effect=AwairClient.GenericError + assert_expected_properties( + hass, + registry, + "sensor.living_room_awair_score", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + "88", + {ATTR_ICON: "mdi:blur"}, ) - with auth_patch: - assert await async_setup_component(hass, SENSOR_DOMAIN, DISCOVERY_CONFIG) - assert not hass.states.async_all() + assert_expected_properties( + hass, + registry, + "sensor.living_room_temperature", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_TEMP][ATTR_UNIQUE_ID]}", + "21.8", + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS, "awair_index": 1.0}, + ) - with rate_patch: - assert await async_setup_component(hass, SENSOR_DOMAIN, DISCOVERY_CONFIG) - assert not hass.states.async_all() + assert_expected_properties( + hass, + registry, + "sensor.living_room_humidity", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_HUMID][ATTR_UNIQUE_ID]}", + "41.59", + {ATTR_UNIT_OF_MEASUREMENT: UNIT_PERCENTAGE, "awair_index": 0.0}, + ) - with generic_patch: - assert await async_setup_component(hass, SENSOR_DOMAIN, DISCOVERY_CONFIG) - assert not hass.states.async_all() + assert_expected_properties( + hass, + registry, + "sensor.living_room_carbon_dioxide", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_CO2][ATTR_UNIQUE_ID]}", + "654.0", + { + ATTR_ICON: "mdi:cloud", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_MILLION, + "awair_index": 0.0, + }, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_volatile_organic_compounds", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_VOC][ATTR_UNIQUE_ID]}", + "366", + { + ATTR_ICON: "mdi:cloud", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_PARTS_PER_BILLION, + "awair_index": 1.0, + }, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_pm2_5", + # gen1 unique_id should be awair_12345-DUST, which matches old integration behavior + f"{AWAIR_UUID}_DUST", + "14.3", + { + ATTR_ICON: "mdi:blur", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "awair_index": 1.0, + }, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_pm10", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM10][ATTR_UNIQUE_ID]}", + "14.3", + { + ATTR_ICON: "mdi:blur", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "awair_index": 1.0, + }, + ) + + # We should not have a dust sensor; it's aliased as pm2.5 + # and pm10 sensors. + assert hass.states.get("sensor.living_room_dust") is None + + # We should not have sound or lux sensors. + assert hass.states.get("sensor.living_room_sound_level") is None + assert hass.states.get("sensor.living_room_illuminance") is None -async def test_awair_setup_no_data(hass): - """Ensure that we do not crash during setup when no data is returned.""" - await setup_awair(hass, data_fixture=AIR_DATA_FIXTURE_EMPTY) - assert not hass.states.async_all() +async def test_awair_gen2_sensors(hass): + """Test expected sensors on a 2nd gen Awair.""" + + fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN2_DATA_FIXTURE] + await setup_awair(hass, fixtures) + registry = await hass.helpers.entity_registry.async_get_registry() + + assert_expected_properties( + hass, + registry, + "sensor.living_room_awair_score", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + "97", + {ATTR_ICON: "mdi:blur"}, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_pm2_5", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + "2.0", + { + ATTR_ICON: "mdi:blur", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "awair_index": 0.0, + }, + ) + + # The Awair 2nd gen reports specifically a pm2.5 sensor, + # and so we don't alias anything. Make sure we didn't do that. + assert hass.states.get("sensor.living_room_pm10") is None -async def test_awair_misc_attributes(hass): - """Test that desired attributes are set.""" - await setup_awair(hass) +async def test_awair_mint_sensors(hass): + """Test expected sensors on an Awair mint.""" - attributes = hass.states.get("sensor.awair_co2").attributes - assert attributes[ATTR_LAST_API_UPDATE] == parse_datetime( - AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP] + fixtures = [USER_FIXTURE, DEVICES_FIXTURE, MINT_DATA_FIXTURE] + await setup_awair(hass, fixtures) + registry = await hass.helpers.entity_registry.async_get_registry() + + assert_expected_properties( + hass, + registry, + "sensor.living_room_awair_score", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + "98", + {ATTR_ICON: "mdi:blur"}, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_pm2_5", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_PM25][ATTR_UNIQUE_ID]}", + "1.0", + { + ATTR_ICON: "mdi:blur", + ATTR_UNIT_OF_MEASUREMENT: CONCENTRATION_MICROGRAMS_PER_CUBIC_METER, + "awair_index": 0.0, + }, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_illuminance", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + "441.7", + {ATTR_UNIT_OF_MEASUREMENT: "lx"}, + ) + + # The Mint does not have a CO2 sensor. + assert hass.states.get("sensor.living_room_carbon_dioxide") is None + + +async def test_awair_glow_sensors(hass): + """Test expected sensors on an Awair glow.""" + + fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GLOW_DATA_FIXTURE] + await setup_awair(hass, fixtures) + registry = await hass.helpers.entity_registry.async_get_registry() + + assert_expected_properties( + hass, + registry, + "sensor.living_room_awair_score", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + "93", + {ATTR_ICON: "mdi:blur"}, + ) + + # The glow does not have a particle sensor + assert hass.states.get("sensor.living_room_pm2_5") is None + + +async def test_awair_omni_sensors(hass): + """Test expected sensors on an Awair omni.""" + + fixtures = [USER_FIXTURE, DEVICES_FIXTURE, OMNI_DATA_FIXTURE] + await setup_awair(hass, fixtures) + registry = await hass.helpers.entity_registry.async_get_registry() + + assert_expected_properties( + hass, + registry, + "sensor.living_room_awair_score", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + "99", + {ATTR_ICON: "mdi:blur"}, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_sound_level", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SPL_A][ATTR_UNIQUE_ID]}", + "47.0", + {ATTR_ICON: "mdi:ear-hearing", ATTR_UNIT_OF_MEASUREMENT: "dBa"}, + ) + + assert_expected_properties( + hass, + registry, + "sensor.living_room_illuminance", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_LUX][ATTR_UNIQUE_ID]}", + "804.9", + {ATTR_UNIT_OF_MEASUREMENT: "lx"}, ) -async def test_awair_score(hass): - """Test that we create a sensor for the 'Awair score'.""" - await setup_awair(hass) +async def test_awair_offline(hass): + """Test expected behavior when an Awair is offline.""" - sensor = hass.states.get("sensor.awair_score") - assert sensor.state == "78" - assert sensor.attributes["device_class"] == DEVICE_CLASS_SCORE - assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE + fixtures = [USER_FIXTURE, DEVICES_FIXTURE, OFFLINE_FIXTURE] + await setup_awair(hass, fixtures) + + # The expected behavior is that we won't have any sensors + # if the device is not online when we set it up. python_awair + # does not make any assumptions about what sensors a device + # might have - they are created dynamically. + + # We check for the absence of the "awair score", which every + # device *should* have if it's online. If we don't see it, + # then we probably didn't set anything up. Which is correct, + # in this case. + assert hass.states.get("sensor.living_room_awair_score") is None -async def test_awair_temp(hass): - """Test that we create a temperature sensor.""" - await setup_awair(hass) +async def test_awair_unavailable(hass): + """Test expected behavior when an Awair becomes offline later.""" - sensor = hass.states.get("sensor.awair_temperature") - assert sensor.state == "22.4" - assert sensor.attributes["device_class"] == DEVICE_CLASS_TEMPERATURE - assert sensor.attributes["unit_of_measurement"] == TEMP_CELSIUS + fixtures = [USER_FIXTURE, DEVICES_FIXTURE, GEN1_DATA_FIXTURE] + await setup_awair(hass, fixtures) + registry = await hass.helpers.entity_registry.async_get_registry() - -async def test_awair_humid(hass): - """Test that we create a humidity sensor.""" - await setup_awair(hass) - - sensor = hass.states.get("sensor.awair_humidity") - assert sensor.state == "32.7" - assert sensor.attributes["device_class"] == DEVICE_CLASS_HUMIDITY - assert sensor.attributes["unit_of_measurement"] == UNIT_PERCENTAGE - - -async def test_awair_co2(hass): - """Test that we create a CO2 sensor.""" - await setup_awair(hass) - - sensor = hass.states.get("sensor.awair_co2") - assert sensor.state == "612" - assert sensor.attributes["device_class"] == DEVICE_CLASS_CARBON_DIOXIDE - assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_MILLION - - -async def test_awair_voc(hass): - """Test that we create a CO2 sensor.""" - await setup_awair(hass) - - sensor = hass.states.get("sensor.awair_voc") - assert sensor.state == "1012" - assert sensor.attributes["device_class"] == DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS - assert sensor.attributes["unit_of_measurement"] == CONCENTRATION_PARTS_PER_BILLION - - -async def test_awair_dust(hass): - """Test that we create a pm25 sensor.""" - await setup_awair(hass) - - # The Awair Gen1 that we mock actually returns 'DUST', but that - # is mapped to pm25 internally so that it shows up in Homekit - sensor = hass.states.get("sensor.awair_pm2_5") - assert sensor.state == "6.2" - assert sensor.attributes["device_class"] == DEVICE_CLASS_PM2_5 - assert ( - sensor.attributes["unit_of_measurement"] - == CONCENTRATION_MICROGRAMS_PER_CUBIC_METER + assert_expected_properties( + hass, + registry, + "sensor.living_room_awair_score", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + "88", + {ATTR_ICON: "mdi:blur"}, ) - -async def test_awair_unsupported_sensors(hass): - """Ensure we don't create sensors the stubbed device doesn't support.""" - await setup_awair(hass) - - # Our tests mock an Awair Gen 1 device, which should never return - # PM10 sensor readings. Assert that we didn't create a pm10 sensor, - # which could happen if someone were ever to refactor incorrectly. - assert hass.states.get("sensor.awair_pm10") is None - - -async def test_availability(hass): - """Ensure that we mark the component available/unavailable correctly.""" - await setup_awair(hass) - - assert hass.states.get("sensor.awair_score").state == "78" - - future = NOW + timedelta(minutes=30) - data_patch = patch( - "python_awair.AwairClient.air_data_latest", return_value=AIR_DATA_FIXTURE, - ) - - with data_patch, alter_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.awair_score").state == STATE_UNAVAILABLE - - future = NOW + timedelta(hours=1) - fixture = AIR_DATA_FIXTURE_UPDATED - fixture[0][ATTR_TIMESTAMP] = str(future) - data_patch = patch("python_awair.AwairClient.air_data_latest", return_value=fixture) - - with data_patch, alter_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.awair_score").state == "79" - - future = NOW + timedelta(minutes=90) - fixture = AIR_DATA_FIXTURE_EMPTY - data_patch = patch("python_awair.AwairClient.air_data_latest", return_value=fixture) - - with data_patch, alter_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.awair_score").state == STATE_UNAVAILABLE - - -async def test_async_update(hass): - """Ensure we can update sensors.""" - await setup_awair(hass) - - future = NOW + timedelta(minutes=10) - data_patch = patch( - "python_awair.AwairClient.air_data_latest", - return_value=AIR_DATA_FIXTURE_UPDATED, - ) - - with data_patch, alter_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - score_sensor = hass.states.get("sensor.awair_score") - assert score_sensor.state == "79" - - assert hass.states.get("sensor.awair_temperature").state == "23.4" - assert hass.states.get("sensor.awair_humidity").state == "33.7" - assert hass.states.get("sensor.awair_co2").state == "613" - assert hass.states.get("sensor.awair_voc").state == "1013" - assert hass.states.get("sensor.awair_pm2_5").state == "7.2" - - -async def test_throttle_async_update(hass): - """Ensure we throttle updates.""" - await setup_awair(hass) - - future = NOW + timedelta(minutes=1) - data_patch = patch( - "python_awair.AwairClient.air_data_latest", - return_value=AIR_DATA_FIXTURE_UPDATED, - ) - - with data_patch, alter_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.awair_score").state == "78" - - future = NOW + timedelta(minutes=15) - with data_patch, alter_time(future): - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert hass.states.get("sensor.awair_score").state == "79" + with patch("python_awair.AwairClient.query", side_effect=OFFLINE_FIXTURE): + await hass.helpers.entity_component.async_update_entity( + "sensor.living_room_awair_score" + ) + assert_expected_properties( + hass, + registry, + "sensor.living_room_awair_score", + f"{AWAIR_UUID}_{SENSOR_TYPES[API_SCORE][ATTR_UNIQUE_ID]}", + STATE_UNAVAILABLE, + {ATTR_ICON: "mdi:blur"}, + ) diff --git a/tests/fixtures/awair/awair-offline.json b/tests/fixtures/awair/awair-offline.json new file mode 100644 index 00000000000..f93ccdf4b7b --- /dev/null +++ b/tests/fixtures/awair/awair-offline.json @@ -0,0 +1 @@ +{"data":[]} diff --git a/tests/fixtures/awair/awair-r2.json b/tests/fixtures/awair/awair-r2.json new file mode 100644 index 00000000000..e0150eed54f --- /dev/null +++ b/tests/fixtures/awair/awair-r2.json @@ -0,0 +1 @@ +{"data":[{"timestamp":"2020-04-10T16:41:57.771Z","score":97.0,"sensors":[{"comp":"temp","value":18.829999923706055},{"comp":"humid","value":50.52000045776367},{"comp":"co2","value":431.0},{"comp":"voc","value":57.0},{"comp":"pm25","value":2.0}],"indices":[{"comp":"temp","value":0.0},{"comp":"humid","value":1.0},{"comp":"co2","value":0.0},{"comp":"voc","value":0.0},{"comp":"pm25","value":0.0}]}]} diff --git a/tests/fixtures/awair/awair.json b/tests/fixtures/awair/awair.json new file mode 100644 index 00000000000..590c4a08642 --- /dev/null +++ b/tests/fixtures/awair/awair.json @@ -0,0 +1 @@ +{"data":[{"timestamp":"2020-04-10T15:38:24.111Z","score":88.0,"sensors":[{"comp":"temp","value":21.770000457763672},{"comp":"humid","value":41.59000015258789},{"comp":"co2","value":654.0},{"comp":"voc","value":366.0},{"comp":"dust","value":14.300000190734863}],"indices":[{"comp":"temp","value":-1.0},{"comp":"humid","value":0.0},{"comp":"co2","value":0.0},{"comp":"voc","value":1.0},{"comp":"dust","value":1.0}]}]} diff --git a/tests/fixtures/awair/devices.json b/tests/fixtures/awair/devices.json new file mode 100644 index 00000000000..413d488c634 --- /dev/null +++ b/tests/fixtures/awair/devices.json @@ -0,0 +1 @@ +{"devices":[{"name":"Living Room","macAddress":"70886B104941","latitude":0.0,"preference":"GENERAL","timezone":"","roomType":"LIVING_ROOM","deviceType":"awair","longitude":0.0,"spaceType":"HOME","deviceUUID":"awair_24947","deviceId":24947,"locationName":"Chicago, IL"}]} diff --git a/tests/fixtures/awair/glow.json b/tests/fixtures/awair/glow.json new file mode 100644 index 00000000000..2274905afc7 --- /dev/null +++ b/tests/fixtures/awair/glow.json @@ -0,0 +1 @@ +{"data":[{"timestamp":"2020-04-10T16:46:15.486Z","score":93.0,"sensors":[{"comp":"temp","value":21.93000030517578},{"comp":"humid","value":42.31999969482422},{"comp":"co2","value":429.0},{"comp":"voc","value":288.0}],"indices":[{"comp":"temp","value":-1.0},{"comp":"humid","value":0.0},{"comp":"co2","value":0.0},{"comp":"voc","value":0.0}]}]} diff --git a/tests/fixtures/awair/mint.json b/tests/fixtures/awair/mint.json new file mode 100644 index 00000000000..2a7cefa8ad7 --- /dev/null +++ b/tests/fixtures/awair/mint.json @@ -0,0 +1 @@ +{"data":[{"timestamp":"2020-04-10T16:25:03.606Z","score":98.0,"sensors":[{"comp":"temp","value":20.639999389648438},{"comp":"humid","value":45.04999923706055},{"comp":"voc","value":269.0},{"comp":"pm25","value":1.0},{"comp":"lux","value":441.70001220703125}],"indices":[{"comp":"temp","value":0.0},{"comp":"humid","value":0.0},{"comp":"voc","value":0.0},{"comp":"pm25","value":0.0}]}]} diff --git a/tests/fixtures/awair/no_devices.json b/tests/fixtures/awair/no_devices.json new file mode 100644 index 00000000000..f5732d79e1e --- /dev/null +++ b/tests/fixtures/awair/no_devices.json @@ -0,0 +1 @@ +{"devices":[]} diff --git a/tests/fixtures/awair/omni.json b/tests/fixtures/awair/omni.json new file mode 100644 index 00000000000..9a3dc3dd063 --- /dev/null +++ b/tests/fixtures/awair/omni.json @@ -0,0 +1 @@ +{"data":[{"timestamp":"2020-04-10T16:18:10.298Z","score":99.0,"sensors":[{"comp":"temp","value":21.40999984741211},{"comp":"humid","value":42.7400016784668},{"comp":"co2","value":436.0},{"comp":"voc","value":171.0},{"comp":"pm25","value":0.0},{"comp":"lux","value":804.9000244140625},{"comp":"spl_a","value":47.0}],"indices":[{"comp":"temp","value":0.0},{"comp":"humid","value":0.0},{"comp":"co2","value":0.0},{"comp":"voc","value":0.0},{"comp":"pm25","value":0.0}]}]} diff --git a/tests/fixtures/awair/user.json b/tests/fixtures/awair/user.json new file mode 100644 index 00000000000..f0fe94caf6d --- /dev/null +++ b/tests/fixtures/awair/user.json @@ -0,0 +1 @@ + {"dobDay":8,"usages":[{"scope":"API_USAGE","usage":302},{"scope":"USER_DEVICE_LIST","usage":50},{"scope":"USER_INFO","usage":80}],"tier":"Large_developer","email":"foo@bar.com","dobYear":2020,"permissions":[{"scope":"USER_DEVICE_LIST","quota":2147483647},{"scope":"USER_INFO","quota":2147483647},{"scope":"FIFTEEN_MIN","quota":30000},{"scope":"FIVE_MIN","quota":30000},{"scope":"RAW","quota":30000},{"scope":"LATEST","quota":30000},{"scope":"PUT_PREFERENCE","quota":30000},{"scope":"PUT_DISPLAY_MODE","quota":30000},{"scope":"PUT_LED_MODE","quota":30000},{"scope":"PUT_KNOCKING_MODE","quota":30000},{"scope":"PUT_TIMEZONE","quota":30000},{"scope":"PUT_DEVICE_NAME","quota":30000},{"scope":"PUT_LOCATION","quota":30000},{"scope":"PUT_ROOM_TYPE","quota":30000},{"scope":"PUT_SPACE_TYPE","quota":30000},{"scope":"GET_DISPLAY_MODE","quota":30000},{"scope":"GET_LED_MODE","quota":30000},{"scope":"GET_KNOCKING_MODE","quota":30000},{"scope":"GET_POWER_STATUS","quota":30000},{"scope":"GET_TIMEZONE","quota":30000}],"dobMonth":4,"sex":"MALE","lastName":"Hayworth","firstName":"Andrew","id":"32406"} diff --git a/tests/fixtures/awair_air_data_latest.json b/tests/fixtures/awair_air_data_latest.json deleted file mode 100644 index 674c0662197..00000000000 --- a/tests/fixtures/awair_air_data_latest.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "timestamp": "2018-11-21T15:46:16.346Z", - "score": 78, - "sensors": [ - { - "component": "TEMP", - "value": 22.4 - }, - { - "component": "HUMID", - "value": 32.73 - }, - { - "component": "CO2", - "value": 612 - }, - { - "component": "VOC", - "value": 1012 - }, - { - "component": "DUST", - "value": 6.2 - } - ], - "indices": [ - { - "component": "TEMP", - "value": 0 - }, - { - "component": "HUMID", - "value": -2 - }, - { - "component": "CO2", - "value": 0 - }, - { - "component": "VOC", - "value": 2 - }, - { - "component": "DUST", - "value": 0 - } - ] - } -] diff --git a/tests/fixtures/awair_air_data_latest_updated.json b/tests/fixtures/awair_air_data_latest_updated.json deleted file mode 100644 index 05ad8371232..00000000000 --- a/tests/fixtures/awair_air_data_latest_updated.json +++ /dev/null @@ -1,50 +0,0 @@ -[ - { - "timestamp": "2018-11-21T15:46:16.346Z", - "score": 79, - "sensors": [ - { - "component": "TEMP", - "value": 23.4 - }, - { - "component": "HUMID", - "value": 33.73 - }, - { - "component": "CO2", - "value": 613 - }, - { - "component": "VOC", - "value": 1013 - }, - { - "component": "DUST", - "value": 7.2 - } - ], - "indices": [ - { - "component": "TEMP", - "value": 0 - }, - { - "component": "HUMID", - "value": -2 - }, - { - "component": "CO2", - "value": 0 - }, - { - "component": "VOC", - "value": 2 - }, - { - "component": "DUST", - "value": 0 - } - ] - } -] diff --git a/tests/fixtures/awair_devices.json b/tests/fixtures/awair_devices.json deleted file mode 100644 index 899ad4eed72..00000000000 --- a/tests/fixtures/awair_devices.json +++ /dev/null @@ -1,25 +0,0 @@ -[ - { - "uuid": "awair_12345", - "deviceType": "awair", - "deviceId": "12345", - "name": "Awair", - "preference": "GENERAL", - "macAddress": "FFFFFFFFFFFF", - "room": { - "id": "ffffffff-ffff-ffff-ffff-ffffffffffff", - "name": "My Room", - "kind": "LIVING_ROOM", - "Space": { - "id": "ffffffff-ffff-ffff-ffff-ffffffffffff", - "kind": "HOME", - "location": { - "name": "Chicago, IL", - "timezone": "", - "lat": 0, - "lon": -0 - } - } - } - } -]