diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index b4007c8d744..0002274833f 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -9,16 +9,16 @@ 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_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, - CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_STATE, - CONF_SHOW_ON_MAP, CONF_RADIUS) + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, CONF_STATE, CONF_SHOW_ON_MAP) +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyairvisual==1.0.0'] +REQUIREMENTS = ['pyairvisual==2.0.1'] _LOGGER = getLogger(__name__) ATTR_CITY = 'city' @@ -29,135 +29,173 @@ ATTR_REGION = 'region' CONF_CITY = 'city' CONF_COUNTRY = 'country' -CONF_ATTRIBUTION = "Data provided by AirVisual" + +DEFAULT_ATTRIBUTION = "Data provided by AirVisual" +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) MASS_PARTS_PER_MILLION = 'ppm' MASS_PARTS_PER_BILLION = 'ppb' VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) - -SENSOR_TYPES = [ - ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), - ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), - ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +SENSOR_TYPE_LEVEL = 'air_pollution_level' +SENSOR_TYPE_AQI = 'air_quality_index' +SENSOR_TYPE_POLLUTANT = 'main_pollutant' +SENSORS = [ + (SENSOR_TYPE_LEVEL, 'Air Pollution Level', 'mdi:scale', None), + (SENSOR_TYPE_AQI, 'Air Quality Index', 'mdi:format-list-numbers', 'AQI'), + (SENSOR_TYPE_POLLUTANT, 'Main Pollutant', 'mdi:chemical-weapon', None), ] -POLLUTANT_LEVEL_MAPPING = [ - {'label': 'Good', 'minimum': 0, 'maximum': 50}, - {'label': 'Moderate', 'minimum': 51, 'maximum': 100}, - {'label': 'Unhealthy for sensitive group', 'minimum': 101, 'maximum': 150}, - {'label': 'Unhealthy', 'minimum': 151, 'maximum': 200}, - {'label': 'Very Unhealthy', 'minimum': 201, 'maximum': 300}, - {'label': 'Hazardous', 'minimum': 301, 'maximum': 10000} -] +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for sensitive group', + '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}, + '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.'} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS): + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_LOCALES)): vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), - vol.Optional(CONF_CITY): cv.string, - vol.Optional(CONF_COUNTRY): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_RADIUS, default=1000): cv.positive_int, + vol.Inclusive(CONF_CITY, 'city'): cv.string, + vol.Inclusive(CONF_COUNTRY, 'city'): cv.string, + vol.Inclusive(CONF_LATITUDE, 'coords'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coords'): cv.longitude, vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean, - vol.Optional(CONF_STATE): cv.string, + vol.Inclusive(CONF_STATE, 'city'): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period }) -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.""" from pyairvisual import Client - classes = { - 'AirPollutionLevelSensor': AirPollutionLevelSensor, - 'AirQualityIndexSensor': AirQualityIndexSensor, - 'MainPollutantSensor': MainPollutantSensor - } - - api_key = config.get(CONF_API_KEY) - monitored_locales = config.get(CONF_MONITORED_CONDITIONS) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - radius = config.get(CONF_RADIUS) city = config.get(CONF_CITY) state = config.get(CONF_STATE) country = config.get(CONF_COUNTRY) - show_on_map = config.get(CONF_SHOW_ON_MAP) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + websession = aiohttp_client.async_get_clientsession(hass) if city and state and country: _LOGGER.debug( "Using city, state, and country: %s, %s, %s", city, state, country) location_id = ','.join((city, state, country)) data = AirVisualData( - Client(api_key), city=city, state=state, country=country, - show_on_map=show_on_map) + Client(config[CONF_API_KEY], websession), + city=city, + state=state, + country=country, + show_on_map=config[CONF_SHOW_ON_MAP], + scan_interval=config[CONF_SCAN_INTERVAL]) else: _LOGGER.debug( "Using latitude and longitude: %s, %s", latitude, longitude) location_id = ','.join((str(latitude), str(longitude))) data = AirVisualData( - Client(api_key), latitude=latitude, longitude=longitude, - radius=radius, show_on_map=show_on_map) + Client(config[CONF_API_KEY], websession), + latitude=latitude, + longitude=longitude, + show_on_map=config[CONF_SHOW_ON_MAP], + scan_interval=config[CONF_SCAN_INTERVAL]) - data.update() + await data.async_update() sensors = [] - for locale in monitored_locales: - for sensor_class, name, icon in SENSOR_TYPES: - sensors.append(classes[sensor_class]( - data, - name, - icon, - locale, - location_id - )) + for locale in config[CONF_MONITORED_CONDITIONS]: + for kind, name, icon, unit in SENSORS: + sensors.append( + AirVisualSensor( + data, kind, name, icon, unit, locale, location_id)) - add_devices(sensors, True) + async_add_devices(sensors, True) -class AirVisualBaseSensor(Entity): - """Define a base class for all of our sensors.""" +class AirVisualSensor(Entity): + """Define an AirVisual sensor.""" - def __init__(self, data, name, icon, locale, entity_id): - """Initialize the sensor.""" - self.data = data - self._attrs = {} + def __init__(self, airvisual, kind, name, icon, unit, locale, location_id): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._icon = icon self._locale = locale + self._location_id = location_id self._name = name self._state = None - self._entity_id = entity_id - self._unit = None + self._type = kind + self._unit = unit + self.airvisual = airvisual @property def device_state_attributes(self): """Return the device state attributes.""" - self._attrs.update({ - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - }) - - if self.data.show_on_map: - self._attrs[ATTR_LATITUDE] = self.data.latitude - self._attrs[ATTR_LONGITUDE] = self.data.longitude + if self.airvisual.show_on_map: + self._attrs[ATTR_LATITUDE] = self.airvisual.latitude + self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude else: - self._attrs['lati'] = self.data.latitude - self._attrs['long'] = self.data.longitude + self._attrs['lati'] = self.airvisual.latitude + self._attrs['long'] = self.airvisual.longitude return self._attrs + @property + def available(self): + """Return True if entity is available.""" + return bool(self.airvisual.pollution_info) + @property def icon(self): """Return the icon.""" @@ -173,127 +211,83 @@ class AirVisualBaseSensor(Entity): """Return the state.""" return self._state - -class AirPollutionLevelSensor(AirVisualBaseSensor): - """Define a sensor to measure air pollution level.""" - @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_pollution_level'.format(self._entity_id) - - def update(self): - """Update the status of the sensor.""" - self.data.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 TypeError: - self._state = None - except ValueError: - self._state = None - - -class AirQualityIndexSensor(AirVisualBaseSensor): - """Define a sensor to measure AQI.""" - - @property - def unique_id(self): - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_aqi'.format(self._entity_id) + return '{0}_{1}_{2}'.format( + self._location_id, self._locale, self._type) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return 'AQI' + return self._unit - def update(self): - """Update the status of the sensor.""" - self.data.update() + async def async_update(self): + """Update the sensor.""" + await self.airvisual.async_update() + data = self.airvisual.pollution_info - self._state = self.data.pollution_info.get( - 'aqi{0}'.format(self._locale)) + if not data: + return - -class MainPollutantSensor(AirVisualBaseSensor): - """Define a sensor to the main pollutant of an area.""" - - def __init__(self, data, name, icon, locale, entity_id): - """Initialize the sensor.""" - super().__init__(data, name, icon, locale, entity_id) - self._symbol = None - self._unit = None - - @property - def unique_id(self): - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_main_pollutant'.format(self._entity_id) - - def update(self): - """Update the status of the sensor.""" - self.data.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 - - self._attrs.update({ - ATTR_POLLUTANT_SYMBOL: self._symbol, - ATTR_POLLUTANT_UNIT: self._unit - }) + if self._type == SENSOR_TYPE_LEVEL: + aqi = data['aqi{0}'.format(self._locale)] + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level['label'] + elif self._type == SENSOR_TYPE_AQI: + self._state = data['aqi{0}'.format(self._locale)] + elif self._type == SENSOR_TYPE_POLLUTANT: + symbol = data['main{0}'.format(self._locale)] + self._state = POLLUTANT_MAPPING[symbol]['label'] + self._attrs.update({ + ATTR_POLLUTANT_SYMBOL: symbol, + ATTR_POLLUTANT_UNIT: POLLUTANT_MAPPING[symbol]['unit'] + }) class AirVisualData(object): """Define an object to hold sensor data.""" def __init__(self, client, **kwargs): - """Initialize the AirVisual data element.""" + """Initialize.""" self._client = client - self.attrs = {} - self.pollution_info = None - self.city = kwargs.get(CONF_CITY) - self.state = kwargs.get(CONF_STATE) self.country = kwargs.get(CONF_COUNTRY) - self.latitude = kwargs.get(CONF_LATITUDE) self.longitude = kwargs.get(CONF_LONGITUDE) - self._radius = kwargs.get(CONF_RADIUS) - + self.pollution_info = {} self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP) + self.state = kwargs.get(CONF_STATE) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update with new AirVisual data.""" - from pyairvisual.exceptions import HTTPError + self.async_update = Throttle( + kwargs[CONF_SCAN_INTERVAL])(self._async_update) + + async def _async_update(self): + """Update AirVisual data.""" + from pyairvisual.errors import AirVisualError try: if self.city and self.state and self.country: - resp = self._client.city( - self.city, self.state, self.country).get('data') - self.longitude, self.latitude = resp.get('location').get( - 'coordinates') + resp = await self._client.data.city( + self.city, self.state, self.country) + self.longitude, self.latitude = resp['location']['coordinates'] else: - resp = self._client.nearest_city( - self.latitude, self.longitude, self._radius).get('data') + resp = await self._client.data.nearest_city( + self.latitude, self.longitude) + _LOGGER.debug("New data retrieved: %s", resp) - self.pollution_info = resp.get('current', {}).get('pollution', {}) + self.pollution_info = resp['current']['pollution'] + except AirVisualError as err: + if self.city and self.state and self.country: + location = (self.city, self.state, self.country) + else: + location = (self.latitude, self.longitude) - self.attrs = { - ATTR_CITY: resp.get('city'), - ATTR_REGION: resp.get('state'), - ATTR_COUNTRY: resp.get('country') - } - except HTTPError as exc_info: - _LOGGER.error("Unable to retrieve data on this location: %s", - self.__dict__) - _LOGGER.debug(exc_info) + _LOGGER.error( + "Can't retrieve data for location: %s (%s)", location, + err) self.pollution_info = {} diff --git a/requirements_all.txt b/requirements_all.txt index dabecdacb2f..8a075631792 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -740,7 +740,7 @@ py_ryobi_gdo==0.0.10 pyads==2.2.6 # homeassistant.components.sensor.airvisual -pyairvisual==1.0.0 +pyairvisual==2.0.1 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.2