forked from catchdave/home-assistant-purpleair
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
0 parents
commit 074f93f
Showing
13 changed files
with
732 additions
and
0 deletions.
There are no files selected for viewing
This file contains 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 @@ | ||
* text=auto eol=lf |
This file contains 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,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 |
This file contains 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,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. |
This file contains 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,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 |
This file contains 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,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) |
Oops, something went wrong.