* Moved climate components with tests into platform dirs. * Updated tests from climate component. * Moved binary_sensor components with tests into platform dirs. * Updated tests from binary_sensor component. * Moved calendar components with tests into platform dirs. * Updated tests from calendar component. * Moved camera components with tests into platform dirs. * Updated tests from camera component. * Moved cover components with tests into platform dirs. * Updated tests from cover component. * Moved device_tracker components with tests into platform dirs. * Updated tests from device_tracker component. * Moved fan components with tests into platform dirs. * Updated tests from fan component. * Moved geo_location components with tests into platform dirs. * Updated tests from geo_location component. * Moved image_processing components with tests into platform dirs. * Updated tests from image_processing component. * Moved light components with tests into platform dirs. * Updated tests from light component. * Moved lock components with tests into platform dirs. * Moved media_player components with tests into platform dirs. * Updated tests from media_player component. * Moved scene components with tests into platform dirs. * Moved sensor components with tests into platform dirs. * Updated tests from sensor component. * Moved switch components with tests into platform dirs. * Updated tests from sensor component. * Moved vacuum components with tests into platform dirs. * Updated tests from vacuum component. * Moved weather components with tests into platform dirs. * Fixed __init__.py files * Fixes for stuff moved as part of this branch. * Fix stuff needed to merge with balloob's branch. * Formatting issues. * Missing __init__.py files. * Fix-ups * Fixup * Regenerated requirements. * Linting errors fixed. * Fixed more broken tests. * Missing init files. * Fix broken tests. * More broken tests * There seems to be a thread race condition. I suspect the logger stuff is running in another thread, which means waiting until the aio loop is done is missing the log messages. Used sleep instead because that allows the logger thread to run. I think the api_streams sensor might not be thread safe. * Disabled tests, will remove sensor in #22147 * Updated coverage and codeowners.
232 lines
8.0 KiB
Python
232 lines
8.0 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)
|