-
-
Notifications
You must be signed in to change notification settings - Fork 37.6k
Adds the AirVisual air quality sensor platform #9320
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
6f142ab
Adds the AirVisual air quality sensor platform
bachya 4f0062e
Updated .coveragerc
bachya 26df7f4
Removed some un-needed code
bachya fed4722
Adding strangely-necessary pylint disable
bachya d012e7b
Removing a Python3.5-specific dict combiner method
bachya 00b3453
Restarting stuck coverage test
bachya 06a2e9f
Added units to AQI sensor (to get nice graph)
bachya 0bacae6
Making collaborator-requested changes
bachya cd53c57
Removing unnecessary parameter from data object
bachya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Don't you want to have a unit of measure for these sensors? Why not overwrite the
unit_of_measurementproperty?There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I see you're working on that now. 😉
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Would love your thoughts here:
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Maybe use
'PSI'as unit for the pollution index?Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
PSIisn't quite right, as we're not specifically measuring pressure. Sadly, the EPA specifically states that AQI:Check out my latest push; not sure if you'll approve, but if I return an empty string for that sensor's
unit_of_measurementproperty, I get the bar chart that I want and theunit_of_measurementattribute is the technically correct value: nothing.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I was referring to pollutant standard index:
https://en.m.wikipedia.org/wiki/Pollutant_Standards_Index
An empty string could be OK, if we make that standard for all unit less measurements that should have a line chart.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
👍