diff --git a/.coveragerc b/.coveragerc index 2fc424e91f67b0d3b5cb76566260368949407a26..d5eb32e670c280c2a6311409b400d8377d0399cf 100644 --- a/.coveragerc +++ b/.coveragerc @@ -427,6 +427,7 @@ omit = homeassistant/components/remote/itach.py homeassistant/components/scene/hunterdouglas_powerview.py homeassistant/components/scene/lifx_cloud.py + homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py new file mode 100644 index 0000000000000000000000000000000000000000..7b077aa38eea0d471c9c059713646ebec3cc85d1 --- /dev/null +++ b/homeassistant/components/sensor/airvisual.py @@ -0,0 +1,289 @@ +""" +Support for AirVisual air quality sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.airvisual/ +""" + +import asyncio +from logging import getLogger +from datetime import timedelta + +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_API_KEY, + CONF_LATITUDE, CONF_LONGITUDE, + CONF_MONITORED_CONDITIONS) +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +_LOGGER = getLogger(__name__) +REQUIREMENTS = ['pyairvisual==0.1.0'] + +ATTR_CITY = 'city' +ATTR_COUNTRY = 'country' +ATTR_POLLUTANT_SYMBOL = 'pollutant_symbol' +ATTR_POLLUTANT_UNIT = 'pollutant_unit' +ATTR_TIMESTAMP = 'timestamp' + +CONF_RADIUS = 'radius' + +MASS_PARTS_PER_MILLION = 'ppm' +MASS_PARTS_PER_BILLION = 'ppb' +VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) + +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for Sensitive Groups', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] +POLLUTANT_MAPPING = { + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + } +} + +SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} +SENSOR_TYPES = [ + ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), + ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), + ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): + cv.string, + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), + vol.Optional(CONF_LATITUDE): + cv.latitude, + vol.Optional(CONF_LONGITUDE): + cv.longitude, + vol.Optional(CONF_RADIUS, default=1000): + cv.positive_int, +}) + + +@asyncio.coroutine +def async_setup_platform(hass, config, async_add_devices, discovery_info=None): + """Configure the platform and add the sensors.""" + import pyairvisual as pav + + api_key = config.get(CONF_API_KEY) + _LOGGER.debug('AirVisual API Key: %s', api_key) + + monitored_locales = config.get(CONF_MONITORED_CONDITIONS) + _LOGGER.debug('Monitored Conditions: %s', monitored_locales) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + _LOGGER.debug('AirVisual Latitude: %s', latitude) + + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + _LOGGER.debug('AirVisual Longitude: %s', longitude) + + radius = config.get(CONF_RADIUS) + _LOGGER.debug('AirVisual Radius: %s', radius) + + data = AirVisualData(pav.Client(api_key), latitude, longitude, radius) + + sensors = [] + for locale in monitored_locales: + for sensor_class, name, icon in SENSOR_TYPES: + sensors.append(globals()[sensor_class](data, name, icon, locale)) + + async_add_devices(sensors, True) + + +def merge_two_dicts(dict1, dict2): + """Merge two dicts into a new dict as a shallow copy.""" + final = dict1.copy() + final.update(dict2) + return final + + +class AirVisualBaseSensor(Entity): + """Define a base class for all of our sensors.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + self._data = data + self._icon = icon + self._locale = locale + self._name = name + self._state = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return { + ATTR_ATTRIBUTION: 'AirVisual©', + ATTR_CITY: self._data.city, + ATTR_COUNTRY: self._data.country, + ATTR_STATE: self._data.state, + ATTR_TIMESTAMP: self._data.pollution_info.get('ts') + } + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def name(self): + """Return the name.""" + return '{0} {1}'.format(SENSOR_LOCALES[self._locale], self._name) + + @property + def state(self): + """Return the state.""" + return self._state + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + _LOGGER.debug('updating sensor: %s', self._name) + self._data.update() + + +class AirPollutionLevelSensor(AirVisualBaseSensor): + """Define a sensor to measure air pollution level.""" + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + aqi = self._data.pollution_info.get('aqi{0}'.format(self._locale)) + + try: + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level.get('label') + except ValueError: + self._state = None + + +class AirQualityIndexSensor(AirVisualBaseSensor): + """Define a sensor to measure AQI.""" + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return '' + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + self._state = self._data.pollution_info.get( + 'aqi{0}'.format(self._locale)) + + +class MainPollutantSensor(AirVisualBaseSensor): + """Define a sensor to the main pollutant of an area.""" + + def __init__(self, data, name, icon, locale): + """Initialize.""" + super().__init__(data, name, icon, locale) + self._symbol = None + self._unit = None + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self._data: + return merge_two_dicts(super().device_state_attributes, { + ATTR_POLLUTANT_SYMBOL: self._symbol, + ATTR_POLLUTANT_UNIT: self._unit + }) + + @asyncio.coroutine + def async_update(self): + """Update the status of the sensor.""" + yield from super().async_update() + symbol = self._data.pollution_info.get('main{0}'.format(self._locale)) + pollution_info = POLLUTANT_MAPPING.get(symbol, {}) + self._state = pollution_info.get('label') + self._unit = pollution_info.get('unit') + self._symbol = symbol + + +class AirVisualData(object): + """Define an object to hold sensor data.""" + + def __init__(self, client, latitude, longitude, radius): + """Initialize.""" + self.city = None + self._client = client + self.country = None + self.latitude = latitude + self.longitude = longitude + self.pollution_info = None + self.radius = radius + self.state = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update with new AirVisual data.""" + import pyairvisual.exceptions as exceptions + + try: + resp = self._client.nearest_city(self.latitude, self.longitude, + self.radius).get('data') + _LOGGER.debug('New data retrieved: %s', resp) + + self.city = resp.get('city') + self.state = resp.get('state') + self.country = resp.get('country') + self.pollution_info = resp.get('current').get('pollution') + except exceptions.HTTPError as exc_info: + _LOGGER.error('Unable to update sensor data') + _LOGGER.debug(exc_info) diff --git a/requirements_all.txt b/requirements_all.txt index 1bafef96fbaa48c6ba4d77526d50da07d7004233..80401ed3733f324f61c60294579a316284a19ce8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -538,6 +538,9 @@ pyRFXtrx==0.20.1 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.sensor.airvisual +pyairvisual==0.1.0 + # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.0