From 5af7666a61fe3cf08cfdb940acb26981a05b1e96 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 26 Jan 2018 10:40:02 -0700 Subject: [PATCH] Adds allergy/disease sensor platform from Pollen.com (#11573) * Base platform in place * Logic in place * Requirements and coverage * Fixed some linting issues * Small attribute reorganization * Collaborator-requested changes round 1 * Updated documentation --- .coveragerc | 1 + CODEOWNERS | 1 + homeassistant/components/sensor/pollen.py | 322 ++++++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 327 insertions(+) create mode 100644 homeassistant/components/sensor/pollen.py diff --git a/.coveragerc b/.coveragerc index c1a9fa291fe..16832843e7e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -593,6 +593,7 @@ omit = homeassistant/components/sensor/pi_hole.py homeassistant/components/sensor/plex.py homeassistant/components/sensor/pocketcasts.py + homeassistant/components/sensor/pollen.py homeassistant/components/sensor/pushbullet.py homeassistant/components/sensor/pvoutput.py homeassistant/components/sensor/pyload.py diff --git a/CODEOWNERS b/CODEOWNERS index 9ec7ce0742c..d6b0385614a 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -61,6 +61,7 @@ homeassistant/components/sensor/airvisual.py @bachya homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel +homeassistant/components/sensor/pollen.py @bachya homeassistant/components/sensor/sytadin.py @gautric homeassistant/components/sensor/tibber.py @danielhiversen homeassistant/components/sensor/waqi.py @andrey-git diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py new file mode 100644 index 00000000000..3998af7e32f --- /dev/null +++ b/homeassistant/components/sensor/pollen.py @@ -0,0 +1,322 @@ +""" +Support for Pollen.com allergen and cold/flu sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.pollen/ +""" +import logging +from datetime import timedelta +from statistics import mean + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS +) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['pypollencom==1.1.1'] +_LOGGER = logging.getLogger(__name__) + +ATTR_ALLERGEN_GENUS = 'primary_allergen_genus' +ATTR_ALLERGEN_NAME = 'primary_allergen_name' +ATTR_ALLERGEN_TYPE = 'primary_allergen_type' +ATTR_CITY = 'city' +ATTR_OUTLOOK = 'outlook' +ATTR_RATING = 'rating' +ATTR_SEASON = 'season' +ATTR_TREND = 'trend' +ATTR_ZIP_CODE = 'zip_code' + +CONF_ZIP_CODE = 'zip_code' + +DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' + +MIN_TIME_UPDATE_AVERAGES = timedelta(hours=12) +MIN_TIME_UPDATE_INDICES = timedelta(minutes=10) + +CONDITIONS = { + 'allergy_average_forecasted': ( + 'Allergy Index: Forecasted Average', + 'AllergyAverageSensor', + 'allergy_average_data', + {'data_attr': 'extended_data'}, + 'mdi:flower' + ), + 'allergy_average_historical': ( + 'Allergy Index: Historical Average', + 'AllergyAverageSensor', + 'allergy_average_data', + {'data_attr': 'historic_data'}, + '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 = [{ + 'label': 'Low', + 'minimum': 0.0, + 'maximum': 2.4 +}, { + 'label': 'Low/Medium', + 'minimum': 2.5, + 'maximum': 4.8 +}, { + 'label': 'Medium', + 'minimum': 4.9, + 'maximum': 7.2 +}, { + 'label': 'Medium/High', + 'minimum': 7.3, + 'maximum': 9.6 +}, { + 'label': 'High', + 'minimum': 9.7, + 'maximum': 12 +}] + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ZIP_CODE): cv.positive_int, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + from pypollencom import Client + + _LOGGER.debug('Configuration data: %s', config) + + client = Client(config[CONF_ZIP_CODE]) + datas = { + 'allergy_average_data': AllergyAveragesData(client), + 'allergy_index_data': AllergyIndexData(client), + 'disease_average_data': DiseaseData(client) + } + + for data in datas.values(): + data.update() + + sensors = [] + for condition in config[CONF_MONITORED_CONDITIONS]: + name, sensor_class, data_key, params, icon = CONDITIONS[condition] + sensors.append(globals()[sensor_class]( + datas[data_key], + params, + name, + icon + )) + + add_devices(sensors, True) + + +def calculate_trend(list_of_nums): + """Calculate the most common rating as a trend.""" + ratings = list( + r['label'] for n in list_of_nums + for r in RATING_MAPPING + if r['minimum'] <= n <= r['maximum']) + return max(set(ratings), key=ratings.count) + + +class BaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, data_params, name, icon): + """Initialize the sensor.""" + self._attrs = {} + self._icon = icon + self._name = name + self._data_params = data_params + self._state = None + self._unit = None + self.data = data + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + self._attrs.update({ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION}) + return self._attrs + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return self._name + + @property + def state(self): + """Return the state.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + +class AllergyAverageSensor(BaseSensor): + """Define a sensor to show allergy average information.""" + + def update(self): + """Update the status of the sensor.""" + self.data.update() + + data_attr = getattr(self.data, self._data_params['data_attr']) + indices = [ + p['Index'] + for p in data_attr['Location']['periods'] + ] + average = round(mean(indices), 1) + + self._attrs[ATTR_TREND] = calculate_trend(indices) + self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() + self._attrs[ATTR_STATE] = data_attr['Location']['State'] + self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] + + [rating] = [ + i['label'] for i in RATING_MAPPING + if i['minimum'] <= average <= i['maximum'] + ] + self._attrs[ATTR_RATING] = rating + + self._state = average + self._unit = 'index' + + +class AllergyIndexSensor(BaseSensor): + """Define a sensor to show allergy index information.""" + + def update(self): + """Update the status of the sensor.""" + self.data.update() + + location_data = self.data.current_data['Location'] + [period] = [ + p for p in location_data['periods'] + if p['Type'] == self._data_params['key'] + ] + + self._attrs[ATTR_ALLERGEN_GENUS] = period['Triggers'][0]['Genus'] + self._attrs[ATTR_ALLERGEN_NAME] = period['Triggers'][0]['Name'] + self._attrs[ATTR_ALLERGEN_TYPE] = period['Triggers'][0]['PlantType'] + self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook'] + self._attrs[ATTR_SEASON] = self.data.outlook_data['Season'] + self._attrs[ATTR_TREND] = self.data.outlook_data[ + 'Trend'].title() + self._attrs[ATTR_CITY] = location_data['City'].title() + self._attrs[ATTR_STATE] = location_data['State'] + self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] + + [rating] = [ + i['label'] for i in RATING_MAPPING + if i['minimum'] <= period['Index'] <= i['maximum'] + ] + self._attrs[ATTR_RATING] = rating + + self._state = period['Index'] + self._unit = 'index' + + +class DataBase(object): + """Define a generic data object.""" + + def __init__(self, client): + """Initialize.""" + self._client = client + + def _get_client_data(self, module, operation): + """Get data from a particular point in the API.""" + from pypollencom.exceptions import HTTPError + + try: + data = getattr(getattr(self._client, module), operation)() + _LOGGER.debug('Received "%s_%s" data: %s', module, + operation, data) + except HTTPError as exc: + _LOGGER.error('An error occurred while retrieving data') + _LOGGER.debug(exc) + + return data + + +class AllergyAveragesData(DataBase): + """Define an object to averages on future and historical allergy data.""" + + def __init__(self, client): + """Initialize.""" + super().__init__(client) + self.extended_data = None + self.historic_data = None + + @Throttle(MIN_TIME_UPDATE_AVERAGES) + def update(self): + """Update with new data.""" + self.extended_data = self._get_client_data('allergens', 'extended') + self.historic_data = self._get_client_data('allergens', 'historic') + + +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') diff --git a/requirements_all.txt b/requirements_all.txt index fa872ddb2d1..57f4d64cb3e 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -830,6 +830,9 @@ pyotp==2.2.6 # homeassistant.components.weather.openweathermap pyowm==2.8.0 +# homeassistant.components.sensor.pollen +pypollencom==1.1.1 + # homeassistant.components.qwikswitch pyqwikswitch==0.4