Skip to content

Commit

Permalink
Add Awair sensor platform (#18570)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
ahayworth authored and MartinHjelmare committed Nov 25, 2018
1 parent 00c9ca6 commit eb6b6ed
Show file tree
Hide file tree
Showing 8 changed files with 641 additions and 0 deletions.
227 changes: 227 additions & 0 deletions homeassistant/components/sensor/awair.py
Original file line number Diff line number Diff line change
@@ -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)
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions requirements_test_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions script/gen_requirements_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@
'pyspcwebgw',
'python-forecastio',
'python-nest',
'python_awair',
'pytradfri\\[async\\]',
'pyunifi',
'pyupnp-async',
Expand Down
Loading

0 comments on commit eb6b6ed

Please sign in to comment.