Skip to content

Commit 78607e7

Browse files
authored
Merge pull request #9 from bpowers/bpowers/local-sensors-api
local sensors API support
2 parents 971458e + 888e035 commit 78607e7

File tree

9 files changed

+338
-11
lines changed

9 files changed

+338
-11
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,3 +104,6 @@ venv.bak/
104104
.mypy_cache/
105105

106106
script/
107+
108+
# PyCharm/IntelliJ project directory
109+
/.idea

docs/examples.rst

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,3 +140,61 @@ Fetching data from a different time
140140
print(f"{sensor}: {round(value, 2)}")
141141
if sensor in datum.indices:
142142
print(f" awair index: {datum.indices[sensor]}")
143+
144+
Sample local sensors program
145+
=================================
146+
147+
Awair recently added the `local sensors API`_, where you can retrieve current (and only current)
148+
air data from devices on your local network over HTTP.
149+
150+
.. _`local sensors API`: https://docs.google.com/document/d/1001C-ro_ig7aEyz0GiWUiiJn0M6DLj47BYWj31acesg/edit
151+
152+
.. code:: python
153+
154+
import asyncio
155+
import aiohttp
156+
from python_awair import AwairLocal
157+
158+
async def data():
159+
async with aiohttp.ClientSession() as session:
160+
# Instantiate a client with your access token, and an asyncio session:
161+
client = AwairLocal(
162+
session=session, device_addrs=["AWAIR-ELEM-1419E1.local"]
163+
)
164+
165+
# List the local devices:
166+
devices = await client.devices()
167+
168+
# Get some air quality data for a user's device:
169+
data = await devices[0].air_data_latest()
170+
171+
# Print things out!
172+
print(f"Device: {devices[0]}")
173+
174+
# You can access sensors as dict items:
175+
for sensor, value in data.sensors.items():
176+
print(f" {sensor}: {round(value, 2)}")
177+
178+
# Or, as attributes:
179+
print(f" temperature again: {round(data.sensors.temperature, 2)}")
180+
181+
asyncio.run(data())
182+
183+
Running this sample prints::
184+
185+
$ python awair_local_demo.py
186+
Device: <AwairDevice: uuid=awair-element_5366 model=Awair Element>
187+
dew_point: 10.81
188+
abs_humid: 9.59
189+
co2_est: 461
190+
voc_baseline: 2536742680
191+
voc_h2_raw: 27
192+
voc_ethanol_raw: 39
193+
pm10_est: 3
194+
temperature: 19.16
195+
humidity: 58.46
196+
carbon_dioxide: 438
197+
volatile_organic_compounds: 384
198+
particulate_matter_2_5: 2
199+
temperature again: 19.16
200+

pyproject.toml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "python-awair"
3-
version = "0.1.1"
3+
version = "0.2.0"
44
description = "asyncio client for the Awair GraphQL API"
55
authors = ["Andrew Hayworth <ahayworth@gmail.com>"]
66
license = "MIT"
@@ -61,8 +61,8 @@ commands = poetry run pytest {posargs}
6161
whitelist_externals = poetry
6262
commands =
6363
poetry run black . --check
64-
poetry run isort -c
65-
poetry run flake8
64+
poetry run isort --check python_awair/ tests/
65+
poetry run flake8 python_awair/ tests/
6666
poetry run pylint python_awair/ tests/
6767
poetry run mypy python_awair/ tests/
6868

python_awair/__init__.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,15 @@
1010
.. _`developer console`: https://developer.getawair.com
1111
"""
1212

13-
from typing import Optional
13+
from asyncio import gather
14+
from typing import List, Optional
1415

1516
from aiohttp import ClientSession
1617

1718
from python_awair import const
1819
from python_awair.auth import AccessTokenAuth, AwairAuth
1920
from python_awair.client import AwairClient
21+
from python_awair.devices import AwairLocalDevice
2022
from python_awair.exceptions import AwairError
2123
from python_awair.user import AwairUser
2224

@@ -69,3 +71,39 @@ async def user(self) -> AwairUser:
6971
"""
7072
response = await self.client.query(const.USER_URL)
7173
return AwairUser(client=self.client, attributes=response)
74+
75+
76+
class AwairLocal:
77+
"""Entry class for the local sensors Awair API."""
78+
79+
client: AwairClient
80+
"""AwairClient: The instantiated AwairClient
81+
that will be used to fetch API responses and
82+
check for HTTP errors.
83+
"""
84+
85+
_device_addrs: List[str]
86+
"""IP or DNS addresses of Awair devices with the local sensors API enabled."""
87+
88+
def __init__(self, session: ClientSession, device_addrs: List[str]) -> None:
89+
"""Initialize the Awair local sensors API wrapper."""
90+
self._device_addrs = device_addrs
91+
if len(device_addrs) > 0:
92+
self.client = AwairClient(AccessTokenAuth(""), session)
93+
else:
94+
raise AwairError("No local Awair device addresses supplied!")
95+
96+
async def devices(self) -> List[AwairLocalDevice]:
97+
"""Return a list of local awair devices."""
98+
responses = await gather(
99+
*(
100+
self.client.query(f"http://{addr}/settings/config/data")
101+
for addr in self._device_addrs
102+
)
103+
)
104+
return [
105+
AwairLocalDevice(
106+
client=self.client, device_addr=self._device_addrs[i], attributes=device
107+
)
108+
for i, device in enumerate(responses)
109+
]

python_awair/devices.py

Lines changed: 82 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
"""Class to describe an Awair device."""
22

33
import urllib
4+
from abc import ABC, abstractmethod
45
from datetime import datetime, timedelta
5-
from typing import Any, Dict, List, Optional, Union
6+
from typing import Any, Dict, List, Optional, Union, cast
67

78
import voluptuous as vol
89

@@ -13,7 +14,7 @@
1314
AirDataParam = Union[datetime, bool, int, None]
1415

1516

16-
class AwairDevice:
17+
class AwairBaseDevice(ABC):
1718
"""An Awair device.
1819
1920
This class serves two purposes - it provides metadata about
@@ -336,17 +337,25 @@ async def air_data_raw(self, **kwargs: AirDataParam) -> List[AirData]:
336337
"""
337338
return await self.__get_airdata("raw", **kwargs)
338339

340+
@abstractmethod
341+
def _get_airdata_base_url(self) -> str:
342+
"""Get the base URL to use for airdata."""
343+
raise TypeError("expected subclass to define override")
344+
345+
@abstractmethod
346+
def _extract_airdata(self, response: Any) -> List[Any]:
347+
"""Get the data object out of a response."""
348+
raise TypeError("expected subclass to define override")
349+
339350
async def __get_airdata(self, kind: str, **kwargs: AirDataParam) -> List[AirData]:
340351
"""Call one of several varying air-data API endpoints."""
341-
url = "/".join(
342-
[const.DEVICE_URL, self.device_type, str(self.device_id), "air-data", kind]
343-
)
352+
url = "/".join([self._get_airdata_base_url(), "air-data", kind])
344353

345354
if kwargs is not None:
346355
url += self._format_args(kind, **kwargs)
347356

348357
response = await self.client.query(url)
349-
return [AirData(data) for data in response.get("data", [])]
358+
return [AirData(data) for data in self._extract_airdata(response)]
350359

351360
@staticmethod
352361
def _format_args(kind: str, **kwargs: AirDataParam) -> str:
@@ -407,3 +416,70 @@ def validate_hours(params: Dict[str, Any]) -> Dict[str, Any]:
407416
return "?" + urllib.parse.urlencode(args)
408417

409418
return ""
419+
420+
421+
class AwairDevice(AwairBaseDevice):
422+
"""A cloud-based Awair device."""
423+
424+
def _get_airdata_base_url(self) -> str:
425+
"""Get the base URL to use for airdata."""
426+
return "/".join([const.DEVICE_URL, self.device_type, str(self.device_id)])
427+
428+
def _extract_airdata(self, response: Any) -> List[Any]:
429+
"""Get the data object out of a response."""
430+
return cast(List[Any], response.get("data", []))
431+
432+
433+
class AwairLocalDevice(AwairBaseDevice):
434+
"""A local Awair device."""
435+
436+
device_addr: str
437+
"""The DNS or IP address of the device."""
438+
439+
def __init__(
440+
self, client: AwairClient, device_addr: str, attributes: Dict[str, Any]
441+
):
442+
"""Initialize an awair local device from API attributes."""
443+
# the format of the config endpoint for local sensors is different than
444+
# the cloud API.
445+
device_uuid: str = attributes["device_uuid"]
446+
[device_type, device_id_str] = device_uuid.split("_", 1)
447+
device_id = int(device_id_str)
448+
attributes["deviceId"] = device_id
449+
attributes["deviceUUID"] = device_uuid
450+
attributes["deviceType"] = device_type
451+
attributes["macAddress"] = attributes.get("wifi_mac", None)
452+
super().__init__(client, attributes)
453+
self.device_addr = device_addr
454+
455+
def _get_airdata_base_url(self) -> str:
456+
"""Get the base URL to use for airdata."""
457+
return f"http://{self.device_addr}"
458+
459+
def _extract_airdata(self, response: Any) -> List[Any]:
460+
"""Get the data object out of a response."""
461+
# reformat local sensors response to match the cloud API
462+
top_level = {"timestamp", "score"}
463+
sensors = [
464+
{"comp": k, "value": response[k]}
465+
for k in response.keys()
466+
if k not in top_level
467+
]
468+
data = {
469+
"timestamp": response["timestamp"],
470+
"score": response["score"],
471+
"sensors": sensors,
472+
}
473+
474+
return [data]
475+
476+
@staticmethod
477+
def _format_args(kind: str, **kwargs: AirDataParam) -> str:
478+
if "fahrenheit" in kwargs:
479+
if kwargs["fahrenheit"]:
480+
raise ValueError("fahrenheit is not supported for local sensors yet")
481+
# if we pass any URL parameters with local sensors, it causes the
482+
# timestamp to be the empty string.
483+
del kwargs["fahrenheit"]
484+
485+
return AwairBaseDevice._format_args(kind, **kwargs)

tests/const.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,13 @@
3030
"deviceUUID": "awair-glow_1405",
3131
}
3232
MOCK_USER_ATTRS = {"id": "32406"}
33+
MOCK_ELEMENT_DEVICE_A_ATTRS = {
34+
"deviceId": 6049,
35+
"deviceType": "awair-element",
36+
"deviceUUID": "awair-element_6049",
37+
}
38+
MOCK_ELEMENT_DEVICE_B_ATTRS = {
39+
"deviceId": 5366,
40+
"deviceType": "awair-element",
41+
"deviceUUID": "awair-element_5366",
42+
}
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
interactions:
2+
- request:
3+
body: null
4+
headers:
5+
Content-Type:
6+
- application/json
7+
authorization:
8+
- fake_token
9+
method: GET
10+
uri: http://awair-elem-1419e1.local/settings/config/data
11+
response:
12+
body:
13+
string: '{"device_uuid":"awair-element_5366","wifi_mac":"70:88:6B:14:19:E1","ssid":"morpac-east","ip":"192.168.1.225","netmask":"255.255.255.0","gateway":"none","fw_version":"1.1.5","timezone":"America/Los_Angeles","display":"co2","led":{"mode":"manual","brightness":73},"voc_feature_set":"Unknown"}'
14+
headers:
15+
Access-Control-Allow-Origin: '*'
16+
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
17+
Connection: Keep-Alive
18+
Content-Type: application/json
19+
Pragma: no-cache
20+
Transfer-Encoding: chunked
21+
status:
22+
code: 200
23+
message: OK
24+
url: http://awair-elem-1419e1.local/settings/config/data
25+
- request:
26+
body: null
27+
headers:
28+
Content-Type:
29+
- application/json
30+
authorization:
31+
- fake_token
32+
method: GET
33+
uri: http://awair-elem-1419e1.local/air-data/latest
34+
response:
35+
body:
36+
string: '{"timestamp":"2020-08-31T22:07:03.831Z","score":93,"dew_point":11.11,"temp":19.59,"humid":58.05,"abs_humid":9.77,"co2":408,"co2_est":400,"voc":159,"voc_baseline":2533859097,"voc_h2_raw":28,"voc_ethanol_raw":40,"pm25":2,"pm10_est":3}'
37+
headers:
38+
Access-Control-Allow-Origin: '*'
39+
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
40+
Connection: Keep-Alive
41+
Content-Type: application/json
42+
Pragma: no-cache
43+
Transfer-Encoding: chunked
44+
status:
45+
code: 200
46+
message: OK
47+
url: http://awair-elem-1419e1.local/air-data/latest
48+
version: 1
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
interactions:
2+
- request:
3+
body: null
4+
headers:
5+
Content-Type:
6+
- application/json
7+
authorization:
8+
- fake_token
9+
method: GET
10+
uri: http://awair-elem-1416dc.local/settings/config/data
11+
response:
12+
body:
13+
string: '{"device_uuid":"awair-element_6049","wifi_mac":"70:88:6B:14:16:DC","ssid":"morpac-east","ip":"192.168.1.133","netmask":"255.255.255.0","gateway":"none","fw_version":"1.1.5","timezone":"America/Los_Angeles","display":"clock","led":{"mode":"auto","brightness":179},"voc_feature_set":"Unknown"}'
14+
headers:
15+
Access-Control-Allow-Origin: '*'
16+
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
17+
Connection: Keep-Alive
18+
Content-Type: application/json
19+
Pragma: no-cache
20+
Transfer-Encoding: chunked
21+
status:
22+
code: 200
23+
message: OK
24+
url: http://awair-elem-1416dc.local/settings/config/data
25+
- request:
26+
body: null
27+
headers:
28+
Content-Type:
29+
- application/json
30+
authorization:
31+
- fake_token
32+
method: GET
33+
uri: http://awair-elem-1419e1.local/settings/config/data
34+
response:
35+
body:
36+
string: '{"device_uuid":"awair-element_5366","wifi_mac":"70:88:6B:14:19:E1","ssid":"morpac-east","ip":"192.168.1.225","netmask":"255.255.255.0","gateway":"none","fw_version":"1.1.5","timezone":"America/Los_Angeles","display":"co2","led":{"mode":"manual","brightness":73},"voc_feature_set":"Unknown"}'
37+
headers:
38+
Access-Control-Allow-Origin: '*'
39+
Cache-Control: no-store, no-cache, must-revalidate, post-check=0, pre-check=0
40+
Connection: Keep-Alive
41+
Content-Type: application/json
42+
Pragma: no-cache
43+
Transfer-Encoding: chunked
44+
status:
45+
code: 200
46+
message: OK
47+
url: http://awair-elem-1419e1.local/settings/config/data
48+
version: 1

0 commit comments

Comments
 (0)