From fed662532462497674e35b8dc9178acf40e038d7 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Sun, 21 Jun 2020 14:46:07 -0500 Subject: [PATCH] Refactor / update Awair integration (#34394) * Refactor / update Awair integration This commit does a few things, all in service of making the Awair integration more modern and reliable. Specifically we do the following: - Update to python_awair 0.1.1 - Begin using config entries / flow for setting up the integration. - YAML support is completely removed. - The integration now allows adding multiple Awair accounts, should a user wish to do so (I found it _very_ useful in development). - Group various Awair sensors into devices, using the device registry. - Renames various sensors and treats the "dust" sensor as a particulate sensor. - Device update rate-limits are no longer dynamically calculated; the Awair API now separates rate-limits on a per-device basis. - Supports sound pressure and illuminance sensors found on some Awair devices. - We report the "awair index" for certain sensors as part of device_state_attributes. The "index" is a subjective measure of whether or not a sensor reading is "good" or "bad" (and to what extent). It's a component of the Awair score, and it is useful on its own as an input for those who wish to do things like "display this color if the value is 'bad'". This is a breaking change, and requires updates to documentation and a warning in the README. The breaking changes in detail, are: - Support for all YAML configuration is removed, and users will need to re-add the integration via the UI. - We no longer support overriding device discovery via manual configuration of device UUIDs. This was previously supported because the Awair API had severe limits on the device list endpoints; however those have since been removed. - Gen 1 devices no longer show a "dust" sensor; rather we create a PM2.5 sensor and a PM10 sensor and just keep the values in sync. This better reflects the sensor capabilities: it can detect particles in a range from 2.5 -> 10, but cannot differentiate between sizes. - Sensors are renamed as follows: - "sensor.devicename_co2" -> "sensor.devicename_carbon_dioxide" - "sensor.devicename_voc" -> "sensor.devicename_volatile_organic_compounds" - "sensor.devicename_score" -> "sensor.devicename_air_quality_index" - I've chosen to call the "Awair Score" an "air quality index" sensor, because fundamentally the "Awair Score" and other air quality indices (such as CAQI) do the same thing: they calculate a value based on a variety of other inputs. Under the hood, the integration has seen some improvements: - We use the DataUpdateCoordinator class to handle updates, rather than rolling our own update class. - The code no longer tracks availability based on a timestamp returned from the Awair service; we assert that if we have received a response and the response has data for our device, then we are available (and otherwise, not available). We don't need to test the actual Awair API so heavily. - Test coverage has been expanded to handle a variety of products that Awair produces, not just the one I happen to own. - Test coverage no longer concerns itself with testing behavior that is now handled by the DataUpdateCoordinator; nor is it concerned with ensuring that the overall component sets up and registers properly. These are assumed to be well-tested functionaity of the core and not things we need to re-test ourselves. Finally - between library updates and integration updates, this integration is well-positioned to support future updates. I have a proof-of-concept patch for device automations, and the underlying library now supports subclassing authentication (which clears the way for us to use OAuth authentication for Awair). * Wrap test fixture in mock_coro Truthfully I'm not sure why this was passing on my local dev environment, but I was developing with python 3.8 before. After installing python 3.7, I was able to reproduce the CI failures and fix them. * Fix broken tests after #34901 and/or #34989 * Do not rename sensors so broadly We're going to keep the sensors named as they were before, pending the outcome of any decisions around the air_quality component and what names should be standardized for air-quality-like devices. If standardized names are selected (which does seem likely), then we will update this integration to match them - at which point, it would be a breaking change. But for now, we'll keep names mostly identical to what users had before. Notable in this commit is that we generate the entity_id ourselves, rather than just allowing it to be auto-generated from the name attribute. This allows us to provide more human friendly names, while keeping the old format for entity ids. For example, given an Awair device called "Living Room", we'll generate an entity id of "sensor.living_room_voc" but show set the name of the device to "Living Room Volatile organic compounds". * Support import from config.yaml We'll create a config entry from config.yaml the first time we're loaded, and then defer to it from then on. We ignore all keys other than the access_token, since we no longer need to deal with per-account rate-limits (rather, everything is per-device now). * Add myself to CODEOWNERS Since I wrote the initial integration, and now this re-write, it feels appropriate for me to take care of the integration along with `danielsjf`. * Remove name mangling * Update homeassistant/components/awair/manifest.json Co-authored-by: Martin Hjelmare * Update homeassistant/components/awair/config_flow.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/awair/sensor.py Co-authored-by: Martin Hjelmare * Update homeassistant/components/awair/sensor.py Co-authored-by: Martin Hjelmare * Address some review feedback * Set up reauth flow in a job, rather than awaiting * Remove unnecessary title string * Remove unnecessary config schema checking As pointed out in review, because this comes in via import from `configuration.yaml`, we can rely on the `PLATFORM_SCHEMA` validation instead. * Fix tests * Set unique_id appropriately for legacy devices For users who have had this integration already installed (and who have updated their home assistant installation sometime in recent history), we want to ensure that unique_id's are set to the same thing as before, to facilitate the upgrade process. To do that, we add an additional property to the `SENSOR_TYPES` dict (`ATTR_UNIQUE_ID`) which allows us to map modern sensor names from python_awair to what older versions called them - ie: `humidity` -> `HUMID`. We then use that value when constructing the unique ID. This should allow users to upgrade and not lose configuration even if entity IDs would otherwise change (because we have changed the name format that generates entity IDs). One note is that for the gen1 `DUST` sensor, we have to treat it differently. This integration used to call that a "PM2.5" sensor, but the unique_id generated would be something like `awair_12345_DUST`. So we special-case that sensor, and do the same thing. We do not need to special-case the PM10 sensor for gen1 devices, because we didn't create a PM10 sensor in the past (we do now, because the "DUST" sensor is really a hybrid PM2.5/PM10 sensor). * Patch async_setup_entry for two tests * Update awair config_flow to require / use an email address for unique_id Also, only start one re-auth flow. * Add a few more tests, try to get coverage up. * Add another test * Move attribution to device_state_attributes * Don't require email * Switch from Union[dict, None] to Optional[dict] * Use a mock where requested * Fix missing constant rename * Use async_create_task * Bump test coverage a bit for config_flow * s/CONF_UNIQUE_ID/UNIQUE_ID/g * Add warning about deprecated platform config Co-authored-by: Martin Hjelmare --- CODEOWNERS | 2 +- homeassistant/components/awair/__init__.py | 111 ++++ homeassistant/components/awair/config_flow.py | 109 ++++ homeassistant/components/awair/const.py | 120 ++++ homeassistant/components/awair/manifest.json | 5 +- homeassistant/components/awair/sensor.py | 403 ++++++------- homeassistant/components/awair/strings.json | 29 + homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/awair/const.py | 20 + tests/components/awair/test_config_flow.py | 190 ++++++ tests/components/awair/test_sensor.py | 568 +++++++++--------- tests/fixtures/awair/awair-offline.json | 1 + tests/fixtures/awair/awair-r2.json | 1 + tests/fixtures/awair/awair.json | 1 + tests/fixtures/awair/devices.json | 1 + tests/fixtures/awair/glow.json | 1 + tests/fixtures/awair/mint.json | 1 + tests/fixtures/awair/no_devices.json | 1 + tests/fixtures/awair/omni.json | 1 + tests/fixtures/awair/user.json | 1 + tests/fixtures/awair_air_data_latest.json | 50 -- .../awair_air_data_latest_updated.json | 50 -- tests/fixtures/awair_devices.json | 25 - 25 files changed, 1094 insertions(+), 602 deletions(-) create mode 100644 homeassistant/components/awair/config_flow.py create mode 100644 homeassistant/components/awair/const.py create mode 100644 homeassistant/components/awair/strings.json create mode 100644 tests/components/awair/const.py create mode 100644 tests/components/awair/test_config_flow.py create mode 100644 tests/fixtures/awair/awair-offline.json create mode 100644 tests/fixtures/awair/awair-r2.json create mode 100644 tests/fixtures/awair/awair.json create mode 100644 tests/fixtures/awair/devices.json create mode 100644 tests/fixtures/awair/glow.json create mode 100644 tests/fixtures/awair/mint.json create mode 100644 tests/fixtures/awair/no_devices.json create mode 100644 tests/fixtures/awair/omni.json create mode 100644 tests/fixtures/awair/user.json delete mode 100644 tests/fixtures/awair_air_data_latest.json delete mode 100644 tests/fixtures/awair_air_data_latest_updated.json delete mode 100644 tests/fixtures/awair_devices.json 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 - } - } - } - } -]