Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
289 changes: 289 additions & 0 deletions homeassistant/components/sensor/airvisual.py
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
Copy link
Copy Markdown
Member

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_measurement property?

Copy link
Copy Markdown
Member

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. 😉

Copy link
Copy Markdown
Contributor Author

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:

  • The Air Quality Index doesn't really have a unit; I want to add something so I get a nice graph, but not sure what it would be.
  • The Air Quality Level doesn't have a unit, since its values are strings like "Good".
  • Same for the Main Pollutant.

Copy link
Copy Markdown
Member

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?

Copy link
Copy Markdown
Contributor Author

@bachya bachya Sep 8, 2017

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PSI isn't quite right, as we're not specifically measuring pressure. Sadly, the EPA specifically states that AQI:

Ranges from 0 to 500 (no units)

Check out my latest push; not sure if you'll approve, but if I return an empty string for that sensor's unit_of_measurement property, I get the bar chart that I want and the unit_of_measurement attribute is the technically correct value: nothing.

Copy link
Copy Markdown
Member

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.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

})

@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)
3 changes: 3 additions & 0 deletions requirements_all.txt
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,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

Expand Down