Skip to content

Commit

Permalink
Initial commit.
Browse files Browse the repository at this point in the history
  • Loading branch information
gibwar committed Aug 23, 2020
0 parents commit 074f93f
Show file tree
Hide file tree
Showing 13 changed files with 732 additions and 0 deletions.
1 change: 1 addition & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
* text=auto eol=lf
132 changes: 132 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions LICENSE.txt
Original file line number Diff line number Diff line change
@@ -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.
52 changes: 52 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -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
143 changes: 143 additions & 0 deletions purpleair/PurpleAirApi.py
Original file line number Diff line number Diff line change
@@ -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)
Loading

0 comments on commit 074f93f

Please sign in to comment.