Make Pollen.com platform async (#14963)

* Most of the work in place

* Final touches

* Small style updates

* Owner-requested changes

* Member-requested changes
This commit is contained in:
Aaron Bach 2018-06-24 11:04:31 -06:00 committed by GitHub
parent 9de7034d0e
commit 6064932e2e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 190 additions and 239 deletions

View File

@ -13,17 +13,17 @@ import voluptuous as vol
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import ( from homeassistant.const import (
ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS)
) from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle, slugify from homeassistant.util import Throttle
REQUIREMENTS = ['pypollencom==1.1.2'] REQUIREMENTS = ['pypollencom==2.1.0']
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
ATTR_ALLERGEN_GENUS = 'primary_allergen_genus' ATTR_ALLERGEN_GENUS = 'allergen_genus'
ATTR_ALLERGEN_NAME = 'primary_allergen_name' ATTR_ALLERGEN_NAME = 'allergen_name'
ATTR_ALLERGEN_TYPE = 'primary_allergen_type' ATTR_ALLERGEN_TYPE = 'allergen_type'
ATTR_CITY = 'city' ATTR_CITY = 'city'
ATTR_OUTLOOK = 'outlook' ATTR_OUTLOOK = 'outlook'
ATTR_RATING = 'rating' ATTR_RATING = 'rating'
@ -34,53 +34,30 @@ ATTR_ZIP_CODE = 'zip_code'
CONF_ZIP_CODE = 'zip_code' CONF_ZIP_CODE = 'zip_code'
DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™'
DEFAULT_SCAN_INTERVAL = timedelta(minutes=30)
MIN_TIME_UPDATE_AVERAGES = timedelta(hours=12) TYPE_ALLERGY_FORECAST = 'allergy_average_forecasted'
MIN_TIME_UPDATE_INDICES = timedelta(minutes=10) TYPE_ALLERGY_HISTORIC = 'allergy_average_historical'
TYPE_ALLERGY_INDEX = 'allergy_index'
TYPE_ALLERGY_OUTLOOK = 'allergy_outlook'
TYPE_ALLERGY_TODAY = 'allergy_index_today'
TYPE_ALLERGY_TOMORROW = 'allergy_index_tomorrow'
TYPE_ALLERGY_YESTERDAY = 'allergy_index_yesterday'
TYPE_DISEASE_FORECAST = 'disease_average_forecasted'
CONDITIONS = { SENSORS = {
'allergy_average_forecasted': ( TYPE_ALLERGY_FORECAST: (
'Allergy Index: Forecasted Average', 'Allergy Index: Forecasted Average', None, 'mdi:flower', 'index'),
'AllergyAverageSensor', TYPE_ALLERGY_HISTORIC: (
'allergy_average_data', 'Allergy Index: Historical Average', None, 'mdi:flower', 'index'),
{'data_attr': 'extended_data'}, TYPE_ALLERGY_TODAY: (
'mdi:flower' 'Allergy Index: Today', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'),
), TYPE_ALLERGY_TOMORROW: (
'allergy_average_historical': ( 'Allergy Index: Tomorrow', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'),
'Allergy Index: Historical Average', TYPE_ALLERGY_YESTERDAY: (
'AllergyAverageSensor', 'Allergy Index: Yesterday', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'),
'allergy_average_data', TYPE_DISEASE_FORECAST: (
{'data_attr': 'historic_data'}, 'Cold & Flu: Forecasted Average', None, 'mdi:snowflake', 'index')
'mdi:flower'
),
'allergy_index_today': (
'Allergy Index: Today',
'AllergyIndexSensor',
'allergy_index_data',
{'key': 'Today'},
'mdi:flower'
),
'allergy_index_tomorrow': (
'Allergy Index: Tomorrow',
'AllergyIndexSensor',
'allergy_index_data',
{'key': 'Tomorrow'},
'mdi:flower'
),
'allergy_index_yesterday': (
'Allergy Index: Yesterday',
'AllergyIndexSensor',
'allergy_index_data',
{'key': 'Yesterday'},
'mdi:flower'
),
'disease_average_forecasted': (
'Cold & Flu: Forecasted Average',
'AllergyAverageSensor',
'disease_average_data',
{'data_attr': 'extended_data'},
'mdi:snowflake'
)
} }
RATING_MAPPING = [{ RATING_MAPPING = [{
@ -105,69 +82,69 @@ RATING_MAPPING = [{
'maximum': 12 'maximum': 12
}] }]
TREND_FLAT = 'Flat'
TREND_INCREASING = 'Increasing'
TREND_SUBSIDING = 'Subsiding'
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_ZIP_CODE): str, vol.Required(CONF_ZIP_CODE): str,
vol.Required(CONF_MONITORED_CONDITIONS): vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)):
vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), vol.All(cv.ensure_list, [vol.In(SENSORS)])
}) })
def setup_platform(hass, config, add_devices, discovery_info=None): async def async_setup_platform(
hass, config, async_add_devices, discovery_info=None):
"""Configure the platform and add the sensors.""" """Configure the platform and add the sensors."""
from pypollencom import Client from pypollencom import Client
_LOGGER.debug('Configuration data: %s', config) websession = aiohttp_client.async_get_clientsession(hass)
client = Client(config[CONF_ZIP_CODE]) data = PollenComData(
datas = { Client(config[CONF_ZIP_CODE], websession),
'allergy_average_data': AllergyAveragesData(client), config[CONF_MONITORED_CONDITIONS])
'allergy_index_data': AllergyIndexData(client),
'disease_average_data': DiseaseData(client)
}
classes = {
'AllergyAverageSensor': AllergyAverageSensor,
'AllergyIndexSensor': AllergyIndexSensor
}
for data in datas.values(): await data.async_update()
data.update()
sensors = [] sensors = []
for condition in config[CONF_MONITORED_CONDITIONS]: for kind in config[CONF_MONITORED_CONDITIONS]:
name, sensor_class, data_key, params, icon = CONDITIONS[condition] name, category, icon, unit = SENSORS[kind]
sensors.append(classes[sensor_class]( sensors.append(
datas[data_key], PollencomSensor(
params, data, config[CONF_ZIP_CODE], kind, category, name, icon, unit))
name,
icon,
config[CONF_ZIP_CODE]
))
add_devices(sensors, True) async_add_devices(sensors, True)
def calculate_trend(list_of_nums): def calculate_average_rating(indices):
"""Calculate the most common rating as a trend.""" """Calculate the human-friendly historical allergy average."""
ratings = list( ratings = list(
r['label'] for n in list_of_nums r['label'] for n in indices for r in RATING_MAPPING
for r in RATING_MAPPING
if r['minimum'] <= n <= r['maximum']) if r['minimum'] <= n <= r['maximum'])
return max(set(ratings), key=ratings.count) return max(set(ratings), key=ratings.count)
class BaseSensor(Entity): class PollencomSensor(Entity):
"""Define a base class for all of our sensors.""" """Define a Pollen.com sensor."""
def __init__(self, data, data_params, name, icon, unique_id): def __init__(self, pollencom, zip_code, kind, category, name, icon, unit):
"""Initialize the sensor.""" """Initialize the sensor."""
self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}
self._category = category
self._icon = icon self._icon = icon
self._name = name self._name = name
self._data_params = data_params
self._state = None self._state = None
self._unit = None self._type = kind
self._unique_id = unique_id self._unit = unit
self.data = data self._zip_code = zip_code
self.pollencom = pollencom
@property
def available(self):
"""Return True if entity is available."""
return bool(
self.pollencom.data.get(self._type)
or self.pollencom.data.get(self._category))
@property @property
def device_state_attributes(self): def device_state_attributes(self):
@ -192,187 +169,161 @@ class BaseSensor(Entity):
@property @property
def unique_id(self): def unique_id(self):
"""Return a unique, HASS-friendly identifier for this entity.""" """Return a unique, HASS-friendly identifier for this entity."""
return '{0}_{1}'.format(self._unique_id, slugify(self._name)) return '{0}_{1}'.format(self._zip_code, self._type)
@property @property
def unit_of_measurement(self): def unit_of_measurement(self):
"""Return the unit the value is expressed in.""" """Return the unit the value is expressed in."""
return self._unit return self._unit
async def async_update(self):
class AllergyAverageSensor(BaseSensor): """Update the sensor."""
"""Define a sensor to show allergy average information.""" await self.pollencom.async_update()
if not self.pollencom.data:
def update(self):
"""Update the status of the sensor."""
self.data.update()
try:
data_attr = getattr(self.data, self._data_params['data_attr'])
indices = [p['Index'] for p in data_attr['Location']['periods']]
self._attrs[ATTR_TREND] = calculate_trend(indices)
except KeyError:
_LOGGER.error("Pollen.com API didn't return any data")
return return
try: if self._category:
self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() data = self.pollencom.data[self._category]['Location']
self._attrs[ATTR_STATE] = data_attr['Location']['State'] else:
self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] data = self.pollencom.data[self._type]['Location']
except KeyError:
_LOGGER.debug('Location data not included in API response')
self._attrs[ATTR_CITY] = None
self._attrs[ATTR_STATE] = None
self._attrs[ATTR_ZIP_CODE] = None
indices = [p['Index'] for p in data['periods']]
average = round(mean(indices), 1) average = round(mean(indices), 1)
[rating] = [ [rating] = [
i['label'] for i in RATING_MAPPING i['label'] for i in RATING_MAPPING
if i['minimum'] <= average <= i['maximum'] if i['minimum'] <= average <= i['maximum']
] ]
self._attrs[ATTR_RATING] = rating slope = (data['periods'][-1]['Index'] - data['periods'][-2]['Index'])
trend = TREND_FLAT
if slope > 0:
trend = TREND_INCREASING
elif slope < 0:
trend = TREND_SUBSIDING
self._state = average if self._type == TYPE_ALLERGY_FORECAST:
self._unit = 'index' outlook = self.pollencom.data[TYPE_ALLERGY_OUTLOOK]
self._attrs.update({
class AllergyIndexSensor(BaseSensor): ATTR_CITY: data['City'].title(),
"""Define a sensor to show allergy index information.""" ATTR_OUTLOOK: outlook['Outlook'],
ATTR_RATING: rating,
def update(self): ATTR_SEASON: outlook['Season'].title(),
"""Update the status of the sensor.""" ATTR_STATE: data['State'],
self.data.update() ATTR_TREND: outlook['Trend'].title(),
ATTR_ZIP_CODE: data['ZIP']
try: })
location_data = self.data.current_data['Location'] self._state = average
[period] = [ elif self._type == TYPE_ALLERGY_HISTORIC:
p for p in location_data['periods'] self._attrs.update({
if p['Type'] == self._data_params['key'] ATTR_CITY: data['City'].title(),
] ATTR_RATING: calculate_average_rating(indices),
ATTR_STATE: data['State'],
ATTR_TREND: trend,
ATTR_ZIP_CODE: data['ZIP']
})
self._state = average
elif self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW,
TYPE_ALLERGY_YESTERDAY):
key = self._type.split('_')[-1].title()
[period] = [p for p in data['periods'] if p['Type'] == key]
[rating] = [ [rating] = [
i['label'] for i in RATING_MAPPING i['label'] for i in RATING_MAPPING
if i['minimum'] <= period['Index'] <= i['maximum'] if i['minimum'] <= period['Index'] <= i['maximum']
] ]
for i in range(3): for idx, attrs in enumerate(period['Triggers']):
index = i + 1 index = idx + 1
try: self._attrs.update({
data = period['Triggers'][i] '{0}_{1}'.format(ATTR_ALLERGEN_GENUS, index):
self._attrs['{0}_{1}'.format( attrs['Genus'],
ATTR_ALLERGEN_GENUS, index)] = data['Genus'] '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index):
self._attrs['{0}_{1}'.format( attrs['Name'],
ATTR_ALLERGEN_NAME, index)] = data['Name'] '{0}_{1}'.format(ATTR_ALLERGEN_TYPE, index):
self._attrs['{0}_{1}'.format( attrs['PlantType'],
ATTR_ALLERGEN_TYPE, index)] = data['PlantType'] })
except IndexError:
self._attrs['{0}_{1}'.format(
ATTR_ALLERGEN_GENUS, index)] = None
self._attrs['{0}_{1}'.format(
ATTR_ALLERGEN_NAME, index)] = None
self._attrs['{0}_{1}'.format(
ATTR_ALLERGEN_TYPE, index)] = None
self._attrs[ATTR_RATING] = rating self._attrs.update({
ATTR_CITY: data['City'].title(),
except KeyError: ATTR_RATING: rating,
_LOGGER.error("Pollen.com API didn't return any data") ATTR_STATE: data['State'],
return ATTR_ZIP_CODE: data['ZIP']
})
try: self._state = period['Index']
self._attrs[ATTR_CITY] = location_data['City'].title() elif self._type == TYPE_DISEASE_FORECAST:
self._attrs[ATTR_STATE] = location_data['State'] self._attrs.update({
self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] ATTR_CITY: data['City'].title(),
except KeyError: ATTR_RATING: rating,
_LOGGER.debug('Location data not included in API response') ATTR_STATE: data['State'],
self._attrs[ATTR_CITY] = None ATTR_TREND: trend,
self._attrs[ATTR_STATE] = None ATTR_ZIP_CODE: data['ZIP']
self._attrs[ATTR_ZIP_CODE] = None })
self._state = average
try:
self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook']
except KeyError:
_LOGGER.debug('Outlook data not included in API response')
self._attrs[ATTR_OUTLOOK] = None
try:
self._attrs[ATTR_SEASON] = self.data.outlook_data['Season']
except KeyError:
_LOGGER.debug('Season data not included in API response')
self._attrs[ATTR_SEASON] = None
try:
self._attrs[ATTR_TREND] = self.data.outlook_data['Trend'].title()
except KeyError:
_LOGGER.debug('Trend data not included in API response')
self._attrs[ATTR_TREND] = None
self._state = period['Index']
self._unit = 'index'
class DataBase(object): class PollenComData(object):
"""Define a generic data object.""" """Define a data object to retrieve info from Pollen.com."""
def __init__(self, client): def __init__(self, client, sensor_types):
"""Initialize.""" """Initialize."""
self._client = client self._client = client
self._sensor_types = sensor_types
self.data = {}
def _get_client_data(self, module, operation): @Throttle(DEFAULT_SCAN_INTERVAL)
"""Get data from a particular point in the API.""" async def async_update(self):
from pypollencom.exceptions import HTTPError """Update Pollen.com data."""
from pypollencom.errors import InvalidZipError, PollenComError
# Pollen.com requires a bit more complicated error handling, given that
# it sometimes has parts (but not the whole thing) go down:
#
# 1. If `InvalidZipError` is thrown, quit everything immediately.
# 2. If an individual request throws any other error, try the others.
data = {}
try: try:
data = getattr(getattr(self._client, module), operation)() if TYPE_ALLERGY_FORECAST in self._sensor_types:
_LOGGER.debug('Received "%s_%s" data: %s', module, operation, data) try:
except HTTPError as exc: data = await self._client.allergens.extended()
_LOGGER.error('An error occurred while retrieving data') self.data[TYPE_ALLERGY_FORECAST] = data
_LOGGER.debug(exc) except PollenComError as err:
_LOGGER.error('Unable to get allergy forecast: %s', err)
self.data[TYPE_ALLERGY_FORECAST] = {}
return data try:
data = await self._client.allergens.outlook()
self.data[TYPE_ALLERGY_OUTLOOK] = data
except PollenComError as err:
_LOGGER.error('Unable to get allergy outlook: %s', err)
self.data[TYPE_ALLERGY_OUTLOOK] = {}
if TYPE_ALLERGY_HISTORIC in self._sensor_types:
try:
data = await self._client.allergens.historic()
self.data[TYPE_ALLERGY_HISTORIC] = data
except PollenComError as err:
_LOGGER.error('Unable to get allergy history: %s', err)
self.data[TYPE_ALLERGY_HISTORIC] = {}
class AllergyAveragesData(DataBase): if all(s in self._sensor_types
"""Define an object to averages on future and historical allergy data.""" for s in [TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW,
TYPE_ALLERGY_YESTERDAY]):
try:
data = await self._client.allergens.current()
self.data[TYPE_ALLERGY_INDEX] = data
except PollenComError as err:
_LOGGER.error('Unable to get current allergies: %s', err)
self.data[TYPE_ALLERGY_TODAY] = {}
def __init__(self, client): if TYPE_DISEASE_FORECAST in self._sensor_types:
"""Initialize.""" try:
super().__init__(client) data = await self._client.disease.extended()
self.extended_data = None self.data[TYPE_DISEASE_FORECAST] = data
self.historic_data = None except PollenComError as err:
_LOGGER.error('Unable to get disease forecast: %s', err)
self.data[TYPE_DISEASE_FORECAST] = {}
@Throttle(MIN_TIME_UPDATE_AVERAGES) _LOGGER.debug('New data retrieved: %s', self.data)
def update(self): except InvalidZipError:
"""Update with new data.""" _LOGGER.error(
self.extended_data = self._get_client_data('allergens', 'extended') 'Cannot retrieve data for ZIP code: %s', self._client.zip_code)
self.historic_data = self._get_client_data('allergens', 'historic') self.data = {}
class AllergyIndexData(DataBase):
"""Define an object to retrieve current allergy index info."""
def __init__(self, client):
"""Initialize."""
super().__init__(client)
self.current_data = None
self.outlook_data = None
@Throttle(MIN_TIME_UPDATE_INDICES)
def update(self):
"""Update with new index data."""
self.current_data = self._get_client_data('allergens', 'current')
self.outlook_data = self._get_client_data('allergens', 'outlook')
class DiseaseData(DataBase):
"""Define an object to retrieve current disease index info."""
def __init__(self, client):
"""Initialize."""
super().__init__(client)
self.extended_data = None
@Throttle(MIN_TIME_UPDATE_INDICES)
def update(self):
"""Update with new cold/flu data."""
self.extended_data = self._get_client_data('disease', 'extended')

View File

@ -954,7 +954,7 @@ pyotp==2.2.6
pyowm==2.8.0 pyowm==2.8.0
# homeassistant.components.sensor.pollen # homeassistant.components.sensor.pollen
pypollencom==1.1.2 pypollencom==2.1.0
# homeassistant.components.qwikswitch # homeassistant.components.qwikswitch
pyqwikswitch==0.8 pyqwikswitch==0.8