From eb6b6ed87d095d28b5258b70ff09e3adc233b3e7 Mon Sep 17 00:00:00 2001 From: Andrew Hayworth Date: Sun, 25 Nov 2018 02:01:19 -0600 Subject: [PATCH] Add Awair sensor platform (#18570) * Awair Sensor Platform This commit adds a sensor platform for Awair devices, by accessing their beta API. Awair heavily rate-limits this API, so we throttle updates based on the number of devices found. We also allow for the user to bypass API device listing entirely, because the device list endpoint is limited to only 6 calls per day. A crashing or restarting server would quickly hit that limit. This sensor platform uses the python_awair library (also written as part of this PR), which is available for async usage. * Disable pylint warning for broad try/catch It's true that this is generally not a great idea, but we really don't want to crash here. If we can't set up the platform, logging it and continuing is the right answer. * Add space to satisfy the linter * Awair platform PR feedback - Bump python_awair to 0.0.2, which has support for more granular exceptions - Ensure we have python_awair available in test - Raise PlatformNotReady if we can't set up Awair - Make the 'Awair score' its own sensor, rather than exposing it other ways - Set the platform up as polling, and set a sensible default - Pass in throttling parameters to the underlying data class, rather than use hacky global variable access to dynamically set the interval - Switch to dict access for required variables - Use pytest coroutines, set up components via async_setup_component, and test/modify/assert in generally better ways - Commit test data as fixtures * Awair PR feedback, volume 2 - Don't force updates in test, instead modify time itself and let homeassistant update things "normally". - Remove unneeded polling attribute - Rename timestamp attribute to 'last_api_update', to better reflect that it is the timestamp of the last time the Awair API servers received data from this device. - Use that attribute to flag the component as unavailable when data is stale. My own Awair device periodically goes offline and it really hardly indicates that at all. - Dynamically set fixture timestamps to the test run utcnow() value, so that we don't have to worry about ancient timestamps in tests blowing up down the line. - Don't assert on entities directly, for the most part. Find desired attributes in ... the attributes dict. * Patch an instance of utcnow I overlooked * Switch to using a context manager for timestream modification Honestly, it's just a lot easier to keep track of patches. Moreover, the ones I seem to have missed are now caught, and tests seem to consistently pass. Also, switch test_throttle_async_update to manipulating time more explicitly. * Missing blank line, thank you hound * Fix pydocstyle error I very much need to set up a script to do this quickly w/o tox, because running flake8 is not enough! * PR feedback * PR feedback --- homeassistant/components/sensor/awair.py | 227 ++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/sensor/test_awair.py | 282 ++++++++++++++++++ tests/fixtures/awair_air_data_latest.json | 50 ++++ .../awair_air_data_latest_updated.json | 50 ++++ tests/fixtures/awair_devices.json | 25 ++ 8 files changed, 641 insertions(+) create mode 100644 homeassistant/components/sensor/awair.py create mode 100644 tests/components/sensor/test_awair.py create mode 100644 tests/fixtures/awair_air_data_latest.json create mode 100644 tests/fixtures/awair_air_data_latest_updated.json create mode 100644 tests/fixtures/awair_devices.json diff --git a/homeassistant/components/sensor/awair.py b/homeassistant/components/sensor/awair.py new file mode 100644 index 00000000000000..3995309de421ce --- /dev/null +++ b/homeassistant/components/sensor/awair.py @@ -0,0 +1,227 @@ +""" +Support for the Awair indoor air quality monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.awair/ +""" + +from datetime import timedelta +import logging +import math + +import voluptuous as vol + +from homeassistant.const import ( + CONF_ACCESS_TOKEN, CONF_DEVICES, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle, dt + +REQUIREMENTS = ['python_awair==0.0.2'] + +_LOGGER = logging.getLogger(__name__) + +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': '%', + 'icon': 'mdi:water-percent'}, + 'CO2': {'device_class': DEVICE_CLASS_CARBON_DIOXIDE, + 'unit_of_measurement': 'ppm', + 'icon': 'mdi:periodic-table-co2'}, + 'VOC': {'device_class': DEVICE_CLASS_VOLATILE_ORGANIC_COMPOUNDS, + 'unit_of_measurement': 'ppb', + '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': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'PM25': {'device_class': DEVICE_CLASS_PM2_5, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'PM10': {'device_class': DEVICE_CLASS_PM10, + 'unit_of_measurement': 'µg/m3', + 'icon': 'mdi:cloud'}, + 'score': {'device_class': DEVICE_CLASS_SCORE, + 'unit_of_measurement': '%', + '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]), +}) + + +# 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.""" + from python_awair import AwairClient + + token = config[CONF_ACCESS_TOKEN] + client = AwairClient(token, session=async_get_clientsession(hass)) + + try: + all_devices = [] + devices = config.get(CONF_DEVICES, await client.devices()) + + # 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) + + 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) + + 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 + + +class AwairSensor(Entity): + """Implementation of an Awair device.""" + + 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 = 'Awair {}'.format(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 + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self._type]['icon'] + + @property + def state(self): + """Return the state of the device.""" + return self._data.data[self._type] + + @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) + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return "{}_{}".format(self._uuid, self._type) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data.""" + await self._data.async_update() + + +class AwairData: + """Get data from Awair API.""" + + 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) + + async def _async_update(self): + """Get the data from Awair API.""" + resp = await self._client.air_data_latest(self._uuid) + timestamp = dt.parse_datetime(resp[0][ATTR_TIMESTAMP]) + self.attrs[ATTR_LAST_API_UPDATE] = timestamp + self.data[ATTR_SCORE] = resp[0][ATTR_SCORE] + + # 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]] = sensor[ATTR_VALUE] + + _LOGGER.debug("Got Awair Data for %s: %s", self._uuid, self.data) diff --git a/requirements_all.txt b/requirements_all.txt index 1fa86a9daf5a13..339c212f23775d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1266,6 +1266,9 @@ python-vlc==1.1.2 # homeassistant.components.wink python-wink==1.10.1 +# homeassistant.components.sensor.awair +python_awair==0.0.2 + # homeassistant.components.sensor.swiss_public_transport python_opendata_transport==0.1.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6ebc180908e76b..a73d80b199a5fa 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -198,6 +198,9 @@ python-forecastio==1.4.0 # homeassistant.components.nest python-nest==4.0.5 +# homeassistant.components.sensor.awair +python_awair==0.0.2 + # homeassistant.components.sensor.whois pythonwhois==2.4.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 76a9e05de3397c..b0ad953e2b5a8f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -91,6 +91,7 @@ 'pyspcwebgw', 'python-forecastio', 'python-nest', + 'python_awair', 'pytradfri\\[async\\]', 'pyunifi', 'pyupnp-async', diff --git a/tests/components/sensor/test_awair.py b/tests/components/sensor/test_awair.py new file mode 100644 index 00000000000000..b539bdbfe7da8c --- /dev/null +++ b/tests/components/sensor/test_awair.py @@ -0,0 +1,282 @@ +"""Tests for the Awair sensor platform.""" + +from contextlib import contextmanager +from datetime import timedelta +import json +import logging +from unittest.mock import patch + +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.sensor.awair 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.const import ( + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, STATE_UNAVAILABLE, + TEMP_CELSIUS) +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import parse_datetime, utcnow + +from tests.common import async_fire_time_changed, load_fixture, mock_coro + +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)) + + +@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.sensor.awair.dt.utcnow', + return_value=retval) + + with patch_one, patch_two, patch_three: + yield + + +async def setup_awair(hass, config=None): + """Load the Awair platform.""" + devices_json = json.loads(load_fixture('awair_devices.json')) + devices_mock = mock_coro(devices_json) + devices_patch = patch('python_awair.AwairClient.devices', + return_value=devices_mock) + air_data_mock = mock_coro(AIR_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) + 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) + + 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' + + +async def test_platform_automatically_configured(hass): + """Test that we can discover devices from the API.""" + await setup_awair(hass) + + assert len(hass.states.async_all()) == 6 + + # 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) + + with auth_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + with rate_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + with generic_patch: + assert await async_setup_component(hass, SENSOR_DOMAIN, + DISCOVERY_CONFIG) + assert not hass.states.async_all() + + +async def test_awair_misc_attributes(hass): + """Test that desired attributes are set.""" + await setup_awair(hass) + + attributes = hass.states.get('sensor.awair_co2').attributes + assert (attributes[ATTR_LAST_API_UPDATE] == + parse_datetime(AIR_DATA_FIXTURE[0][ATTR_TIMESTAMP])) + + +async def test_awair_score(hass): + """Test that we create a sensor for the 'Awair score'.""" + await setup_awair(hass) + + 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'] == '%' + + +async def test_awair_temp(hass): + """Test that we create a temperature sensor.""" + await setup_awair(hass) + + 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 + + +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.73' + assert sensor.attributes['device_class'] == DEVICE_CLASS_HUMIDITY + assert sensor.attributes['unit_of_measurement'] == '%' + + +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'] == 'ppm' + + +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'] == 'ppb' + + +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_pm25') + assert sensor.state == '6.2' + assert sensor.attributes['device_class'] == DEVICE_CLASS_PM2_5 + assert sensor.attributes['unit_of_measurement'] == 'µg/m3' + + +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=mock_coro(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=mock_coro(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' + + +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=mock_coro(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.73' + assert hass.states.get('sensor.awair_co2').state == '613' + assert hass.states.get('sensor.awair_voc').state == '1013' + assert hass.states.get('sensor.awair_pm25').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=mock_coro(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' diff --git a/tests/fixtures/awair_air_data_latest.json b/tests/fixtures/awair_air_data_latest.json new file mode 100644 index 00000000000000..674c0662197686 --- /dev/null +++ b/tests/fixtures/awair_air_data_latest.json @@ -0,0 +1,50 @@ +[ + { + "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 new file mode 100644 index 00000000000000..05ad8371232548 --- /dev/null +++ b/tests/fixtures/awair_air_data_latest_updated.json @@ -0,0 +1,50 @@ +[ + { + "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 new file mode 100644 index 00000000000000..899ad4eed72ba2 --- /dev/null +++ b/tests/fixtures/awair_devices.json @@ -0,0 +1,25 @@ +[ + { + "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 + } + } + } + } +]