From 074f93f51061c9d74c38ff30d8309704601d4e4d Mon Sep 17 00:00:00 2001 From: Joshua Harley Date: Sat, 22 Aug 2020 23:24:17 -0600 Subject: [PATCH] Initial commit. --- .gitattributes | 1 + .gitignore | 132 ++++++++++++++++++++++++++++++ LICENSE.txt | 21 +++++ README.md | 52 ++++++++++++ purpleair/PurpleAirApi.py | 143 +++++++++++++++++++++++++++++++++ purpleair/__init__.py | 51 ++++++++++++ purpleair/air_quality.py | 91 +++++++++++++++++++++ purpleair/config_flow.py | 92 +++++++++++++++++++++ purpleair/const.py | 24 ++++++ purpleair/manifest.json | 14 ++++ purpleair/sensor.py | 69 ++++++++++++++++ purpleair/strings.json | 21 +++++ purpleair/translations/en.json | 21 +++++ 13 files changed, 732 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 purpleair/PurpleAirApi.py create mode 100644 purpleair/__init__.py create mode 100644 purpleair/air_quality.py create mode 100644 purpleair/config_flow.py create mode 100644 purpleair/const.py create mode 100644 purpleair/manifest.json create mode 100644 purpleair/sensor.py create mode 100644 purpleair/strings.json create mode 100644 purpleair/translations/en.json diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6313b56 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text=auto eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f490798 --- /dev/null +++ b/.gitignore @@ -0,0 +1,132 @@ +# Created by https://www.gitignore.io/api/vim,python +# Edit at https://www.gitignore.io/?templates=vim,python + +### Python ### +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# pyenv +.python-version +venv/ + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# Mr Developer +.mr.developer.cfg +.project +.pydevproject + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +### Vim ### +# Swap +[._]*.s[a-v][a-z] +[._]*.sw[a-p] +[._]s[a-rt-v][a-z] +[._]ss[a-gi-z] +[._]sw[a-p] + +# Session +Session.vim +Sessionx.vim + +# Temporary +.netrwhist +*~ + +# Auto-generated tag files +tags + +# Persistent undo +[._]*.un~ + +# Coc configuration directory +.vim + +# End of https://www.gitignore.io/api/vim,python diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..e6663a6 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Joshua Harley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..cceb519 --- /dev/null +++ b/README.md @@ -0,0 +1,52 @@ +A quick and dirty integration for Home Assistant to integrate PurpleAir +air quality sensors. This will create an `air_quality` sensor with the +relevant data and create an additional AQI `sensor` for ease-of-use. + +Simply copy the `/purpleair` directory in to your config's +`custom_components` directory (you may need to create it), restart Home +Assistant, and add the integration via the UI (it's simple!). + +To find a sensor to integrate: + +1. Look at the [PurpleAir Map][1]. +2. Find and click an available _outdoor_ station (indoor won't do you + any good). +3. In the station pop up, click on "Get This Widget". +4. Right-click the "JSON" link at the bottom of the black box and copy + the link. (Copy Link Location, et al.) +5. Go to Home Assistant and go to the Integrations Page. +6. Add the PurpleAir integration. +7. Paste the link and finish. + +You'll have two entities added: an `air_quality` entity and a `sensor` +entity. The air quality fills out all available values via the state +dictionary, and the sensor entity is simply the calculated AQI value, +for ease of use. (The AQI also shows up as an attribute on the air +quality entity as well). + +Sensor data on PurpleAir is only updated every two minutes, and to be +nice, this integration will batch its updates every five minutes. If you +add multiple sensors, the new sensors will take up to five minutes to +get their data, as to not flood their free service with requests. + +This component is licensed under the MIT license, so feel free to copy, +enhance, and redistribute as you see fit. + +### Notes +This was a very single-day project, so it works for outdoor sensors that +report an A and B channel. It _should_ work with a single channel sensor +as well, but I didn't test that. + +This uses the free API to access the data. If you have your own sensors +being published and have them marked as private, you'll need to modify +this source to allow you to authenticate to view your data with your +Google account (I think, it was mentioned in their FAQ). + +I don't have any local devices, so this will not currently work with +sensors on your internal network. It should be simple to add it, but I +have no way to test it. It sounds like the payload is slightly different +and the URL is private. This code simply extracts the given sensor ID to +batch the `/json` requests (the site is hard-coded too, I just use the +full URL to start). + +[1]: http://www.purpleair.com/map?mylocation diff --git a/purpleair/PurpleAirApi.py b/purpleair/PurpleAirApi.py new file mode 100644 index 0000000..775bcd5 --- /dev/null +++ b/purpleair/PurpleAirApi.py @@ -0,0 +1,143 @@ +from datetime import timedelta +import logging + +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval, async_track_point_in_utc_time +from homeassistant.util import dt + +from .const import AQI_BREAKPOINTS, DISPATCHER_PURPLE_AIR, JSON_PROPERTIES, SCAN_INTERVAL, URL + +_LOGGER = logging.getLogger(__name__) + + +def calc_aqi(value, index): + if index not in AQI_BREAKPOINTS: + _LOGGER.debug('calc_aqi requested for unknown type: %s', index) + return None + + bp = next((bp for bp in AQI_BREAKPOINTS[index] if value >= bp['pm_low'] and value <= + bp['pm_high']), None) + if not bp: + _LOGGER.debug('value %s did not fall in valid range for type %s', value, index) + return None + + aqi_range = bp['aqi_high'] - bp['aqi_low'] + pm_range = bp['pm_high'] - bp['pm_low'] + c = value - bp['pm_low'] + return round((aqi_range/pm_range) * c + bp['aqi_low']) + + +class PurpleAirApi: + def __init__(self, hass, session): + self._hass = hass + self._session = session + self._nodes = [] + self._data = {} + self._scan_interval = timedelta(seconds=SCAN_INTERVAL) + self._shutdown_interval = None + + def is_node_registered(self, node_id): + return node_id in self._data + + def get_property(self, node_id, prop): + if node_id not in self._data: + return None + + node = self._data[node_id] + return node[prop] + + def get_reading(self, node_id, prop): + readings = self.get_property(node_id, 'readings') + return readings[prop] if prop in readings else None + + def register_node(self, node_id): + if node_id in self._nodes: + _LOGGER.debug('detected duplicate registration: %s', node_id) + return + + self._nodes.append(node_id) + _LOGGER.debug('registered new node: %s', node_id) + + if not self._shutdown_interval: + _LOGGER.debug('starting background poll: %s', self._scan_interval) + self._shutdown_interval = async_track_time_interval( + self._hass, + self._update, + self._scan_interval + ) + + async_track_point_in_utc_time( + self._hass, + self._update, + dt.utcnow() + timedelta(seconds=5) + ) + + def unregister_node(self, node_id): + if node_id not in self._nodes: + _LOGGER.debug('detected non-existent unregistration: %s', node_id) + return + + self._nodes.remove(node_id) + _LOGGER.debug('unregistered node: %s', node_id) + + if not self._nodes and self._shutdown_interval: + _LOGGER.debug('no more nodes, shutting down interval') + self._shutdown_interval() + self._shutdown_interval = None + + async def _update(self, now=None): + url = URL.format(node_list='|'.join(self._nodes)) + _LOGGER.debug('calling update url: %s', url) + + results = {} + async with self._session.get(url) as resp: + if resp.status != 200: + _LOGGER.warning('bad API response for %s: %s', url, resp.status) + return + + json = await resp.json() + results = json['results'] + + nodes = {} + for result in results: + node_id = str(result['ID'] if 'ParentID' not in result else result['ParentID']) + if 'ParentID' not in result: + nodes[node_id] = { + 'last_seen': result['LastSeen'], + 'last_update': result['LastUpdateCheck'], + 'readings': {}, + } + + sensor = 'A' if 'ParentID' not in result else 'B' + readings = nodes[node_id]['readings'] + + if sensor not in readings: + readings[sensor] = {} + + for prop in JSON_PROPERTIES: + readings[sensor][prop] = result[prop] if prop in result else None + + for node in nodes: + readings = nodes[node]['readings'] + if 'A' in readings and 'B' in readings: + for prop in JSON_PROPERTIES: + if prop in readings['A'] and prop in readings['B']: + a = float(readings['A'][prop]) + b = float(readings['B'][prop]) + readings[prop] = round((a + b) / 2, 1) + readings[f'{prop}_confidence'] = 'Good' if abs(a - b) < 45 else 'Questionable' + else: + readings[prop] = None + else: + for prop in JSON_PROPERTIES: + if prop in readings['A']: + readings[prop] = readings['A'][prop] + readings[f'{prop}_confidence'] = 'Good' + else: + readings[prop] = None + + if 'pm2_5_atm' in readings: + readings['pm2_5_atm_aqi'] = calc_aqi(readings['pm2_5_atm'], 'pm2_5') + + self._data = nodes + async_dispatcher_send(self._hass, DISPATCHER_PURPLE_AIR) diff --git a/purpleair/__init__.py b/purpleair/__init__.py new file mode 100644 index 0000000..a84b575 --- /dev/null +++ b/purpleair/__init__.py @@ -0,0 +1,51 @@ +"""The PurpleAir integration.""" +import asyncio +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import DOMAIN +from .PurpleAirApi import PurpleAirApi + +PLATFORMS = ["air_quality", "sensor"] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the PurpleAir component.""" + session = async_get_clientsession(hass) + + hass.data[DOMAIN] = PurpleAirApi(hass, session) + + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up PurpleAir from a config entry.""" + + for component in PLATFORMS: + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, component) + ) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry): + """Unload a config entry.""" + unload_ok = all( + await asyncio.gather( + *[ + hass.config_entries.async_forward_entry_unload(entry, component) + for component in PLATFORMS + ] + ) + ) + + return unload_ok diff --git a/purpleair/air_quality.py b/purpleair/air_quality.py new file mode 100644 index 0000000..5d5e776 --- /dev/null +++ b/purpleair/air_quality.py @@ -0,0 +1,91 @@ +""" The Purple Air air_quality platform. """ +import asyncio +import logging + +from homeassistant.components.air_quality import AirQualityEntity +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DISPATCHER_PURPLE_AIR, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass, config_entry, async_schedule_add_entities): + node_id = config_entry.data["id"] + title = config_entry.data["title"] + + async_schedule_add_entities([PurpleAirQuality(hass, node_id, title)]) + + +class PurpleAirQuality(AirQualityEntity): + def __init__(self, hass, node_id, title): + self._hass = hass + self._node_id = node_id + self._title = title + self._api = hass.data[DOMAIN] + self._stop_listening = None + + @property + def air_quality_index(self): + return self._api.get_reading(self._node_id, 'pm2_5_atm_aqi') + + @property + def attribution(self): + return 'Data provided by PurpleAir' + + @property + def available(self): + return self._api.is_node_registered(self._node_id) + + @property + def name(self): + return self._title + + @property + def particulate_matter_1_0(self): + return self._api.get_reading(self._node_id, 'pm1_0_atm') + + @property + def particulate_matter_2_5(self): + return self._api.get_reading(self._node_id, 'pm2_5_atm') + + @property + def particulate_matter_10(self): + return self._api.get_reading(self._node_id, 'pm10_0_atm') + + @property + def should_poll(self): + return False + + @property + def state_attributes(self): + attributes = super().state_attributes + pm1_0 = self.particulate_matter_1_0 + + if pm1_0: + attributes['particulate_matter_1_0'] = pm1_0 + + return attributes + + @property + def unique_id(self): + return f'{self._node_id}_air_quality' + + async def async_added_to_hass(self): + _LOGGER.debug('registering with node_id: %s', self._node_id) + self._api.register_node(self._node_id) + self._stop_listening = async_dispatcher_connect( + self._hass, + DISPATCHER_PURPLE_AIR, + self.async_write_ha_state + ) + + + async def async_will_remove_from_hass(self): + _LOGGER.debug('unregistering node_id: %s', self._node_id) + self._api.unregister_node(self._node_id) + if self._stop_listening: + self._stop_listening() + self._stop_listening = None diff --git a/purpleair/config_flow.py b/purpleair/config_flow.py new file mode 100644 index 0000000..75fa2d6 --- /dev/null +++ b/purpleair/config_flow.py @@ -0,0 +1,92 @@ +"""Config flow for Purple Air integration.""" +import logging +import voluptuous as vol + +from homeassistant import config_entries, core, exceptions +from homeassistant.const import CONF_URL +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def validate_input(hass: core.HomeAssistant, data): + """Validate the user input allows us to connect. + + Data has the keys from DATA_SCHEMA with values provided by the user. + """ + + json = {} + client = async_get_clientsession(hass) + url = data['url'] + _LOGGER.debug('using url: %s', url) + async with client.get(url) as resp: + if not resp.status == 200: + raise InvalidResponse(resp) + + json = await resp.json() + + node = json['results'][0] + node_id = str(node['ID']) + if ('ParentID' in node): + node_id = str(node['ParentID']) + + config = { + 'title': node['Label'], + 'id': node_id, + } + + _LOGGER.debug('generated config data: %s', config) + + return config + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for PurpleAir.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + errors = {} + if user_input is not None: + try: + info = await validate_input(self.hass, user_input) + + await self.async_set_unique_id(info['id']) + self._abort_if_unique_id_configured() + + return self.async_create_entry(title=info["title"], data=info) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + data_schema = vol.Schema( + { + vol.Required(CONF_URL): str, + } + ) + + return self.async_show_form( + step_id="user", data_schema=data_schema, errors=errors + ) + + +class CannotConnect(exceptions.HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(exceptions.HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class InvalidResponse(exceptions.HomeAssistantError): + """Error to indicate a bad HTTP response.""" + + def __init__(self, response): + self.response = response diff --git a/purpleair/const.py b/purpleair/const.py new file mode 100644 index 0000000..0111b5b --- /dev/null +++ b/purpleair/const.py @@ -0,0 +1,24 @@ +"""Constants for the Purple Air integration.""" + +AQI_BREAKPOINTS = { + 'pm2_5': [ + { 'pm_low': 500.5, 'pm_high': 999.9, 'aqi_low': 501, 'aqi_high': 999 }, + { 'pm_low': 350.5, 'pm_high': 500.4, 'aqi_low': 401, 'aqi_high': 500 }, + { 'pm_low': 250.5, 'pm_high': 350.4, 'aqi_low': 301, 'aqi_high': 400 }, + { 'pm_low': 150.5, 'pm_high': 250.4, 'aqi_low': 201, 'aqi_high': 300 }, + { 'pm_low': 55.5, 'pm_high': 150.4, 'aqi_low': 151, 'aqi_high': 200 }, + { 'pm_low': 35.5, 'pm_high': 55.4, 'aqi_low': 101, 'aqi_high': 150 }, + { 'pm_low': 12.1, 'pm_high': 35.4, 'aqi_low': 51, 'aqi_high': 100 }, + { 'pm_low': 0, 'pm_high': 12.0, 'aqi_low': 0, 'aqi_high': 50 }, + ], +} + +DISPATCHER_PURPLE_AIR = 'dispatcher_purple_air' + +DOMAIN = "purpleair" + +JSON_PROPERTIES = ['pm1_0_atm', 'pm2_5_atm', 'pm10_0_atm'] + +SCAN_INTERVAL = 300 + +URL = "https://www.purpleair.com/json?show={node_list}" diff --git a/purpleair/manifest.json b/purpleair/manifest.json new file mode 100644 index 0000000..bf6984e --- /dev/null +++ b/purpleair/manifest.json @@ -0,0 +1,14 @@ +{ + "domain": "purpleair", + "name": "PurpleAir", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/purpleair", + "requirements": [], + "ssdp": [], + "zeroconf": [], + "homekit": {}, + "dependencies": [], + "codeowners": [ + "@gibwar" + ] +} \ No newline at end of file diff --git a/purpleair/sensor.py b/purpleair/sensor.py new file mode 100644 index 0000000..e220009 --- /dev/null +++ b/purpleair/sensor.py @@ -0,0 +1,69 @@ +""" The Purple Air air_quality platform. """ +import asyncio + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DISPATCHER_PURPLE_AIR, DOMAIN + + +async def async_setup_entry(hass, config_entry, async_schedule_add_entities): + node_id = config_entry.data["id"] + title = config_entry.data["title"] + + async_schedule_add_entities([PurpleAirQualityIndex(hass, node_id, title)]) + + +class PurpleAirQualityIndex(Entity): + def __init__(self, hass, node_id, title): + self._hass = hass + self._node_id = node_id + self._title = title + self._api = hass.data[DOMAIN] + self._stop_listening = None + + @property + def attribution(self): + return 'Data provided by PurpleAir' + + @property + def available(self): + return self._api.is_node_registered(self._node_id) + + @property + def icon(self): + return 'mdi:weather-hazy' + + @property + def name(self): + return f'{self._title} Air Quality Index' + + @property + def should_poll(self): + return False + + @property + def state(self): + return self._api.get_reading(self._node_id, 'pm2_5_atm_aqi') + + @property + def unique_id(self): + return f'{self._node_id}_air_quality_index' + + @property + def unit_of_measurement(self): + return 'AQI' + + async def async_added_to_hass(self): + self._stop_listening = async_dispatcher_connect( + self._hass, + DISPATCHER_PURPLE_AIR, + self.async_write_ha_state + ) + + async def async_will_remove_from_hass(self): + if self._stop_listening: + self._stop_listening() + self._stop_listening = None diff --git a/purpleair/strings.json b/purpleair/strings.json new file mode 100644 index 0000000..5bf2f05 --- /dev/null +++ b/purpleair/strings.json @@ -0,0 +1,21 @@ +{ + "title": "PurpleAir", + "config": { + "step": { + "user": { + "description": "To connect to a PurpleAir station, find the station you want to configure on the PurpleAir map, select it, and in the popup, click on \"Get This Widget\" and copy the JSON link.\n\nIf you are configuring more than one station, it will take up to 5 minutes for the station data to start flowing.", + "title": "Connect to a PurpleAir Station", + "data": { + "url": "Station JSON URL" + } + } + }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "unknown": "An unknown error occurred." + }, + "abort": { + "already_configured": "This PurpleAir station ID is already registered." + } + } +} diff --git a/purpleair/translations/en.json b/purpleair/translations/en.json new file mode 100644 index 0000000..62ece9d --- /dev/null +++ b/purpleair/translations/en.json @@ -0,0 +1,21 @@ +{ + "config": { + "abort": { + "already_configured": "This PurpleAir station ID is already registered." + }, + "error": { + "cannot_connect": "Failed to connect, please try again.", + "unknown": "An unknown error occurred." + }, + "step": { + "user": { + "data": { + "url": "Station JSON URL" + }, + "description": "To connect to a PurpleAir station, find the station you want to configure on the PurpleAir map, select it, and in the popup, click on \"Get This Widget\" and copy the JSON link.\n\nIf you are configuring more than one station, it will take up to 5 minutes for the station data to start flowing.", + "title": "Connect to a PurpleAir Station" + } + } + }, + "title": "PurpleAir" +}