hass-core/homeassistant/components/sensor/awair.py
Andrew Hayworth 87a0118082 Do not choke on no awair data (#19708)
* awair: do not choke on no data

The awair API returns an empty response for various air data queries
when a device is offline. The underlying library (python_awair) does
not directly inform us that a device is offline, since we really can
only infer it from an empty response - there is no online/offline
indicator in the graphql API.

So - we should just ensure that we do not attempt to update device state
from an empty response. This ensures that the platform does not crash
when starting up with offline devices, and also ensures that the
platform is marked unavailable once devices go offline.

* Fix typo

Further proof that coding after 10pm is rolling the dice.
2019-01-03 14:41:18 +01:00

231 lines
8 KiB
Python

"""
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.3']
_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)
if not resp:
return
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)