blueair-api 1.9.1__py3-none-any.whl → 1.33.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- blueair_api/__init__.py +1 -0
- blueair_api/callbacks.py +3 -1
- blueair_api/const.py +9 -10
- blueair_api/device.py +108 -71
- blueair_api/device_aws.py +209 -92
- blueair_api/http_aws_blueair.py +23 -31
- blueair_api/http_blueair.py +117 -8
- blueair_api/intermediate_representation_aws.py +183 -0
- blueair_api/model_enum.py +90 -0
- blueair_api/stub.py +4 -8
- blueair_api/util.py +26 -12
- blueair_api/util_bootstrap.py +28 -26
- {blueair_api-1.9.1.dist-info → blueair_api-1.33.2.dist-info}/METADATA +10 -14
- blueair_api-1.33.2.dist-info/RECORD +19 -0
- {blueair_api-1.9.1.dist-info → blueair_api-1.33.2.dist-info}/WHEEL +1 -1
- blueair_api-1.9.1.dist-info/RECORD +0 -17
- {blueair_api-1.9.1.dist-info → blueair_api-1.33.2.dist-info}/LICENSE +0 -0
- {blueair_api-1.9.1.dist-info → blueair_api-1.33.2.dist-info}/top_level.txt +0 -0
blueair_api/http_aws_blueair.py
CHANGED
@@ -1,3 +1,5 @@
|
|
1
|
+
from typing import Any
|
2
|
+
import functools
|
1
3
|
import logging
|
2
4
|
|
3
5
|
from aiohttp import ClientSession, ClientResponse, FormData
|
@@ -10,7 +12,8 @@ _LOGGER = logging.getLogger(__name__)
|
|
10
12
|
|
11
13
|
|
12
14
|
def request_with_active_session(func):
|
13
|
-
|
15
|
+
@functools.wraps(func)
|
16
|
+
async def request_with_active_session_wrapper(*args, **kwargs) -> ClientResponse:
|
14
17
|
_LOGGER.debug("session")
|
15
18
|
try:
|
16
19
|
return await func(*args, **kwargs)
|
@@ -28,7 +31,8 @@ def request_with_active_session(func):
|
|
28
31
|
|
29
32
|
|
30
33
|
def request_with_errors(func):
|
31
|
-
|
34
|
+
@functools.wraps(func)
|
35
|
+
async def request_with_errors_wrapper(*args, **kwargs) -> ClientResponse:
|
32
36
|
_LOGGER.debug("checking for errors")
|
33
37
|
response: ClientResponse = await func(*args, **kwargs)
|
34
38
|
status_code = response.status
|
@@ -48,9 +52,12 @@ def request_with_errors(func):
|
|
48
52
|
url = kwargs["url"]
|
49
53
|
response_text = await response.text()
|
50
54
|
if "accounts.login" in url:
|
55
|
+
_LOGGER.debug("login error")
|
51
56
|
raise LoginError(response_text)
|
52
57
|
else:
|
58
|
+
_LOGGER.debug("session error")
|
53
59
|
raise SessionError(response_text)
|
60
|
+
raise ValueError(f"unknown status code {status_code}")
|
54
61
|
|
55
62
|
return request_with_errors_wrapper
|
56
63
|
|
@@ -61,7 +68,7 @@ class HttpAwsBlueair:
|
|
61
68
|
username: str,
|
62
69
|
password: str,
|
63
70
|
region: str = "us",
|
64
|
-
client_session: ClientSession = None,
|
71
|
+
client_session: ClientSession | None = None,
|
65
72
|
):
|
66
73
|
self.username = username
|
67
74
|
self.password = password
|
@@ -85,7 +92,7 @@ class HttpAwsBlueair:
|
|
85
92
|
@request_with_errors
|
86
93
|
@request_with_logging
|
87
94
|
async def _get_request_with_logging_and_errors_raised(
|
88
|
-
self, url: str, headers: dict = None
|
95
|
+
self, url: str, headers: dict | None = None
|
89
96
|
) -> ClientResponse:
|
90
97
|
return await self.api_session.get(url=url, headers=headers)
|
91
98
|
|
@@ -94,15 +101,15 @@ class HttpAwsBlueair:
|
|
94
101
|
async def _post_request_with_logging_and_errors_raised(
|
95
102
|
self,
|
96
103
|
url: str,
|
97
|
-
json_body: dict = None,
|
98
|
-
form_data: FormData = None,
|
99
|
-
headers: dict = None,
|
104
|
+
json_body: dict | None = None,
|
105
|
+
form_data: FormData | None = None,
|
106
|
+
headers: dict | None = None,
|
100
107
|
) -> ClientResponse:
|
101
108
|
return await self.api_session.post(
|
102
109
|
url=url, data=form_data, json=json_body, headers=headers
|
103
110
|
)
|
104
111
|
|
105
|
-
async def refresh_session(self):
|
112
|
+
async def refresh_session(self) -> None:
|
106
113
|
_LOGGER.debug("refresh_session")
|
107
114
|
url = f"https://accounts.{AWS_APIKEYS[self.region]['gigyaRegion']}.gigya.com/accounts.login"
|
108
115
|
form_data = FormData()
|
@@ -119,7 +126,7 @@ class HttpAwsBlueair:
|
|
119
126
|
self.session_token = response_json["sessionInfo"]["sessionToken"]
|
120
127
|
self.session_secret = response_json["sessionInfo"]["sessionSecret"]
|
121
128
|
|
122
|
-
async def refresh_jwt(self):
|
129
|
+
async def refresh_jwt(self) -> None:
|
123
130
|
_LOGGER.debug("refresh_jwt")
|
124
131
|
if self.session_token is None or self.session_secret is None:
|
125
132
|
await self.refresh_session()
|
@@ -136,7 +143,7 @@ class HttpAwsBlueair:
|
|
136
143
|
response_json = await response.json(content_type="text/javascript")
|
137
144
|
self.jwt = response_json["id_token"]
|
138
145
|
|
139
|
-
async def refresh_access_token(self):
|
146
|
+
async def refresh_access_token(self) -> None:
|
140
147
|
_LOGGER.debug("refresh_access_token")
|
141
148
|
if self.jwt is None:
|
142
149
|
await self.refresh_jwt()
|
@@ -150,14 +157,15 @@ class HttpAwsBlueair:
|
|
150
157
|
response_json = await response.json()
|
151
158
|
self.access_token = response_json["access_token"]
|
152
159
|
|
153
|
-
async def get_access_token(self):
|
160
|
+
async def get_access_token(self) -> str:
|
154
161
|
_LOGGER.debug("get_access_token")
|
155
162
|
if self.access_token is None:
|
156
163
|
await self.refresh_access_token()
|
164
|
+
assert self.access_token is not None
|
157
165
|
return self.access_token
|
158
166
|
|
159
167
|
@request_with_active_session
|
160
|
-
async def devices(self):
|
168
|
+
async def devices(self) -> dict[str, Any]:
|
161
169
|
_LOGGER.debug("devices")
|
162
170
|
url = f"https://{AWS_APIKEYS[self.region]['restApiId']}.execute-api.{AWS_APIKEYS[self.region]['awsRegion']}.amazonaws.com/prod/c/registered-devices"
|
163
171
|
headers = {
|
@@ -172,15 +180,8 @@ class HttpAwsBlueair:
|
|
172
180
|
return response_json["devices"]
|
173
181
|
|
174
182
|
@request_with_active_session
|
175
|
-
async def device_info(self, device_name, device_uuid):
|
183
|
+
async def device_info(self, device_name, device_uuid) -> dict[str, Any]:
|
176
184
|
_LOGGER.debug("device_info")
|
177
|
-
"""
|
178
|
-
sample; [{'id': '1b41a7c6-8f02-42fa-9af8-868dad9be98a', 'configuration': {'df': {'a': 877, 'ot': 'G4Filter', 'alg': 'FilterAlg1'}, 'di': {'cfv': '2.1.1', 'cma': '3c:61:05:45:56:98', 'mt': '2', 'name': 'sage', 'sku': '105826', 'mfv': '1.0.12', 'ofv': '2.1.1', 'hw': 'high_1.5', 'ds': '110582600000110110016855'}, '_ot': 'CmConfig', '_f': False, '_it': 'urn:blueair:openapi:version:healthprotect:0.0.5', '_eid': 'f8cb142f-c77d-11eb-b045-c98ff4f5a769', '_sc': 'Instance', 'ds': {'tVOC': {'tf': 'senml+json', 'ot': 'TVOC', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/tVOC', 'ttl': 0, 'n': 'tVOC', 'fe': True}, 'fu0': {'tf': 'senml+json', 'ot': 'FU', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/fu0', 'ttl': -1, 'n': 'fu0', 'fe': True}, 'ledb': {'tf': 'senml+json', 'ot': 'Led', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/ledb', 'ttl': 0, 'n': 'ledb', 'fe': True}, 'co2': {'tf': 'senml+json', 'ot': 'Co2', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/co2', 'ttl': 0, 'n': 'co2', 'fe': True}, 'pm2_5c': {'tf': 'senml+json', 'ot': 'PMParticleCount', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/pm2_5c', 'ttl': 0, 'n': 'pm2_5c', 'fe': True}, 'sb': {'tf': 'senml+json', 'ot': 'Standby', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/sb', 'ttl': -1, 'n': 'sb', 'fe': True}, 'ss0': {'tf': 'senml+json', 'ot': 'SafetySwitch', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/ss0', 'ttl': -1, 'n': 'ss0', 'fe': True}, 'rt1s': {'tf': 'senml+json', 'ot': 'RT1s', 'e': True, 'i': 1000, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/1s', 'sn': ['pm1', 'pm2_5', 'pm10', 't', 'h', 'tVOC', 'rssi'], 'ttl': 0, 'n': 'rt1s', 'fe': True}, 'rt5s': {'tf': 'senml+json', 'ot': 'RT5s', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/5s', 'sn': ['pm1', 'pm2_5', 'pm10', 't', 'h', 'tVOC', 'rssi'], 'ttl': -1, 'n': 'rt5s', 'fe': True}, 'pm1': {'tf': 'senml+json', 'ot': 'PM1', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/pm1', 'ttl': 0, 'n': 'pm1', 'fe': True}, 'pm2_5': {'tf': 'senml+json', 'ot': 'PM2_5', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/pm2_5', 'ttl': 0, 'n': 'pm2_5', 'fe': True}, 'pm10c': {'tf': 'senml+json', 'ot': 'PMParticleCount', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/pm10c', 'ttl': 0, 'n': 'pm10c', 'fe': True}, 'sp': {'tf': 'senml+json', 'ot': 'SensorBoxPresense', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/sp', 'ttl': -1, 'n': 'sp', 'fe': True}, 'rssi': {'tf': 'senml+json', 'ot': 'RSSI', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/rssi', 'ttl': 0, 'n': 'rssi', 'fe': True}, 'chl': {'tf': 'senml+json', 'ot': 'ChildLock', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/chl', 'ttl': -1, 'n': 'chl', 'fe': True}, 'fp0': {'tf': 'senml+json', 'ot': 'FilterPresence', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/fp0', 'ttl': -1, 'n': 'fp0', 'fe': True}, 'pm10': {'tf': 'senml+json', 'ot': 'PM10', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/pm10', 'ttl': 0, 'n': 'pm10', 'fe': True}, 'h': {'tf': 'senml+json', 'ot': 'Humidity', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/h', 'ttl': 0, 'n': 'h', 'fe': True}, 'is': {'tf': 'senml+json', 'ot': 'IonizerState', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/is', 'ttl': -1, 'n': 'is', 'fe': True}, 'gs': {'tf': 'senml+json', 'ot': 'GermShield', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/gs', 'ttl': -1, 'n': 'gs', 'fe': True}, 'am': {'tf': 'senml+json', 'ot': 'AutoMode', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/am', 'ttl': -1, 'n': 'am', 'fe': True}, 'tVOCbaseline': {'t': 0, 'e': False, 'ot': 'TVOCbaseline', 'i': 60000, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/TVOCbaseline', 'ttl': 600, 'n': 'tVOCbaseline', 'fe': True}, 'rt5m': {'tf': 'senml+json', 'ot': 'RT5m', 'e': True, 'i': 300000, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/5m', 'sn': ['pm1', 'pm2_5', 'pm10', 't', 'h', 'tVOC'], 'ttl': 0, 'n': 'rt5m', 'fe': True}, 't': {'tf': 'senml+json', 'ot': 'Temperature', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/t', 'ttl': 0, 'n': 't', 'fe': True}, 'pm1c': {'tf': 'senml+json', 'ot': 'PMParticleCount', 'e': False, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/pm1c', 'ttl': 0, 'n': 'pm1c', 'fe': True}, 'b5m': {'tf': 'senml+json', 'th': ['5kb', '4h'], 'ot': 'Batch5m', 'e': True, 'i': 300000, 'tn': '$aws/rules/telemetry_ingest_rule/d/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/batch/b5m', 'sn': ['pm1', 'pm2_5', 'pm10', 't', 'h', 'tVOC', 'fsp0'], 'ttl': -1, 'n': 'b5m', 'fe': True}, 'fsp0': {'st': 'fsp0', 'tf': 'senml+json', 't': 10, 'ot': 'Fanspeed', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/fsp0', 'sn': ['fsp0'], 'ttl': -1, 'n': 'fsp0', 'fe': True}, 'nm': {'tf': 'senml+json', 'ot': 'NightMode', 'e': True, 'i': 0, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/s/nm', 'ttl': -1, 'n': 'nm', 'fe': True}}, '_r': 'us-east-2', '_s': {'sig': 'f812cd40acae289ad965c159051abadb28ed6c8b1eb2df917fedaa135f5a69d2', 'salg': 'SHA256'}, '_t': 'Diff', '_v': 1655924801, '_cas': 1623062101, '_id': '1b41a7c6-8f02-42fa-9af8-868dad9be98a', 'fc': {'pwd': '9102revreSyrotcaF', 'ssid': 'BAFactory', 'url': ' '}, 'da': {'reboot': {'p': False, 'a': False, 'ot': 'Reboot', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/reboot', 'n': 'reboot', 'fe': True}, 'uitest': {'a': 'off', 'ot': 'UiTest', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/uitest', 'n': 'uitest', 'fe': True}, 'ledb': {'p': True, 'a': 0, 'tf': 'senml+json', 'ot': 'LedBrightness', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/ledb', 'n': 'ledb', 'fe': True}, 'chl': {'p': True, 'a': False, 'tf': 'senml+json', 'ot': 'ChildLock', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/chl', 'n': 'chl', 'fe': True}, 'sflu': {'p': False, 'a': False, 'ot': 'SensorFlush', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/sflu', 'n': 'sflu', 'fe': True}, 'buttontest': {'p': False, 'a': False, 'ot': 'ButtonTest', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/buttontest', 'n': 'buttontest', 'fe': True}, 'is': {'p': True, 'a': False, 'tf': 'senml+json', 'ot': 'IonizerState', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/is', 'n': 'is', 'fe': True}, 'gs': {'p': True, 'a': False, 'tf': 'senml+json', 'ot': 'GermShield', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/gs', 'n': 'gs', 'fe': True}, 'am': {'p': True, 'a': False, 'tf': 'senml+json', 'ot': 'AutoMode', 'e': True, 'amt': 'PM', 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/am', 'n': 'am', 'fe': True}, 'tVOCbaseline': {'a': 0, 'ot': 'TVOCbaseline', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/tVOCbaseline', 'n': 'tVOCbaseline', 'fe': True}, 'sb': {'p': True, 'a': False, 'tf': 'senml+json', 'sbt': 'sensor_on', 'ot': 'Standby', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/sb', 'n': 'sb', 'fe': True}, 'fsp0': {'p': True, 'st': 'fsp0', 'a': 20, 'tf': 'senml+json', 'ot': 'Fanspeed', 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/fsp0', 'n': 'fsp0', 'fe': True}, 'nm': {'p': True, 'maxfsp': 25, 'a': False, 'tf': 'senml+json', 'nmt': 'PM', 'ot': 'NightMode', 'ledb': 12, 'e': True, 'tn': 'd/1b41a7c6-8f02-42fa-9af8-868dad9be98a/a/nm', 'n': 'nm', 'fe': True}}, 'dc': {'cfv': {'d': 'di.cfv', 't': 'integer', 'v': 0, 'n': 'cfv'}, 'germshield': {'a': 'gs', 's': 'gs', 't': 'boolean', 'v': False, 'n': 'germshield'}, 'filterusage': {'s': 'fu0', 't': 'integer', 'v': 0, 'n': 'filterusage'}, 'brightness': {'a': 'ledb', 's': 'ledb', 't': 'integer', 'v': 100, 'n': 'brightness'}, 'standby': {'a': 'sb', 's': 'sb', 't': 'boolean', 'v': False, 'n': 'standby'}, 'fanspeed': {'a': 'fsp0', 's': 'fsp0', 't': 'integer', 'v': 11, 'n': 'fanspeed'}, 'nightmode': {'a': 'nm', 's': 'nm', 't': 'boolean', 'v': False, 'n': 'nightmode'}, 'childlock': {'a': 'chl', 's': 'chl', 't': 'boolean', 'v': False, 'n': 'childlock'}, 'safetyswitch': {'s': 'ss0', 't': 'boolean', 'v': True, 'n': 'safetyswitch'}, 'mfv': {'d': 'di.mfv', 't': 'integer', 'v': 0, 'n': 'mfv'}, 'ofv': {'d': 'di.ofv', 't': 'integer', 'v': 0, 'n': 'ofv'}, 'automode': {'a': 'am', 's': 'am', 't': 'boolean', 'v': False, 'n': 'automode'}}}, 'alarms': [], 'events': [], 'sensordata': [{'v': '0', 'n': 'pm1', 't': 1656198091}, {'v': '0', 'n': 'pm2_5', 't': 1656198091}, {'v': '0', 'n': 'pm10', 't': 1656198091}, {'v': '23', 'n': 't', 't': 1656198091}, {'v': '45', 'n': 'h', 't': 1656198091}, {'v': '170', 'n': 'tVOC', 't': 1656198091}, {'v': '11', 'n': 'fsp0', 't': 1656198091}], 'states': [{'n': 'cfv', 'v': 33554689, 't': 1656203510}, {'n': 'germshield', 'vb': True, 't': 1656203510}, {'n': 'filterusage', 'v': 1, 't': 1656203510}, {'n': 'brightness', 'v': 0, 't': 1656203510}, {'n': 'standby', 'vb': False, 't': 1656203510}, {'n': 'fanspeed', 'v': 11, 't': 1656203510}, {'n': 'nightmode', 'vb': False, 't': 1656203510}, {'n': 'childlock', 'vb': False, 't': 1656203510}, {'n': 'safetyswitch', 'vb': True, 't': 1656203510}, {'n': 'mfv', 'v': 16777228, 't': 1656203510}, {'n': 'ofv', 'v': 33554689, 't': 1656203510}, {'n': 'automode', 'vb': False, 't': 1656203510}, {'t': 1656203510, 'vb': True, 'n': 'online'}], 'welcomehome': {'setting': []}, 'fleet_info': []}]
|
179
|
-
|
180
|
-
:param device_name:
|
181
|
-
:param device_uuid:
|
182
|
-
:return:
|
183
|
-
"""
|
184
185
|
url = f"https://{AWS_APIKEYS[self.region]['restApiId']}.execute-api.{AWS_APIKEYS[self.region]['awsRegion']}.amazonaws.com/prod/c/{device_name}/r/initial"
|
185
186
|
headers = {
|
186
187
|
"Authorization": f"Bearer {await self.get_access_token()}",
|
@@ -196,16 +197,7 @@ class HttpAwsBlueair:
|
|
196
197
|
},
|
197
198
|
},
|
198
199
|
],
|
199
|
-
"includestates": True
|
200
|
-
"eventsubscription": {
|
201
|
-
"include": [
|
202
|
-
{
|
203
|
-
"filter": {
|
204
|
-
"o": f"= {device_uuid}",
|
205
|
-
},
|
206
|
-
},
|
207
|
-
],
|
208
|
-
},
|
200
|
+
"includestates": True
|
209
201
|
}
|
210
202
|
response: ClientResponse = (
|
211
203
|
await self._post_request_with_logging_and_errors_raised(
|
@@ -218,7 +210,7 @@ class HttpAwsBlueair:
|
|
218
210
|
@request_with_active_session
|
219
211
|
async def set_device_info(
|
220
212
|
self, device_uuid, service_name, action_verb, action_value
|
221
|
-
):
|
213
|
+
) -> bool:
|
222
214
|
_LOGGER.debug("set_device_info")
|
223
215
|
url = f"https://{AWS_APIKEYS[self.region]['restApiId']}.execute-api.{AWS_APIKEYS[self.region]['awsRegion']}.amazonaws.com/prod/c/{device_uuid}/a/{service_name}"
|
224
216
|
headers = {
|
blueair_api/http_blueair.py
CHANGED
@@ -1,3 +1,4 @@
|
|
1
|
+
from typing import Any
|
1
2
|
import logging
|
2
3
|
|
3
4
|
from aiohttp import ClientSession, ClientResponse
|
@@ -15,9 +16,9 @@ class HttpBlueair:
|
|
15
16
|
self,
|
16
17
|
username: str,
|
17
18
|
password: str,
|
18
|
-
home_host: str = None,
|
19
|
-
auth_token: str = None,
|
20
|
-
client_session: ClientSession = None,
|
19
|
+
home_host: str | None = None,
|
20
|
+
auth_token: str | None = None,
|
21
|
+
client_session: ClientSession | None = None,
|
21
22
|
):
|
22
23
|
self.username = username
|
23
24
|
self.password = password
|
@@ -44,13 +45,13 @@ class HttpBlueair:
|
|
44
45
|
|
45
46
|
@request_with_logging
|
46
47
|
async def _get_request_with_logging_and_errors_raised(
|
47
|
-
self, url: str, headers: dict = None
|
48
|
+
self, url: str, headers: dict | None = None
|
48
49
|
) -> ClientResponse:
|
49
50
|
return await self.api_session.get(url=url, headers=headers)
|
50
51
|
|
51
52
|
@request_with_logging
|
52
53
|
async def _post_request_with_logging_and_errors_raised(
|
53
|
-
self, url: str, json_body: dict, headers: dict = None
|
54
|
+
self, url: str, json_body: dict, headers: dict | None = None
|
54
55
|
) -> ClientResponse:
|
55
56
|
return await self.api_session.post(url=url, json=json_body, headers=headers)
|
56
57
|
|
@@ -99,7 +100,7 @@ class HttpBlueair:
|
|
99
100
|
else:
|
100
101
|
raise LoginError("invalid password")
|
101
102
|
|
102
|
-
async def get_devices(self) -> list[dict[str,
|
103
|
+
async def get_devices(self) -> list[dict[str, Any]]:
|
103
104
|
"""
|
104
105
|
Fetch a list of devices.
|
105
106
|
|
@@ -123,7 +124,7 @@ class HttpBlueair:
|
|
123
124
|
return await response.json()
|
124
125
|
|
125
126
|
# Note: refreshes every 5 minutes
|
126
|
-
async def get_attributes(self, device_uuid: str) -> dict[str,
|
127
|
+
async def get_attributes(self, device_uuid: str) -> dict[str, Any]:
|
127
128
|
"""
|
128
129
|
Fetch a list of attributes for the provided device ID.
|
129
130
|
|
@@ -154,7 +155,7 @@ class HttpBlueair:
|
|
154
155
|
return attributes
|
155
156
|
|
156
157
|
# Note: refreshes every 5 minutes, timestamps are in seconds
|
157
|
-
async def get_info(self, device_uuid: str) -> dict[str,
|
158
|
+
async def get_info(self, device_uuid: str) -> dict[str, Any]:
|
158
159
|
"""
|
159
160
|
Fetch device information for the provided device ID.
|
160
161
|
|
@@ -177,6 +178,53 @@ class HttpBlueair:
|
|
177
178
|
)
|
178
179
|
return await response.json()
|
179
180
|
|
181
|
+
# Note: refreshes every 5 minutes, timestamps are in seconds
|
182
|
+
async def get_current_data_point(self, device_uuid: str) -> dict[str, Any]:
|
183
|
+
"""
|
184
|
+
Fetch device information for the provided device ID.
|
185
|
+
|
186
|
+
The return value is a dictionary containing key-value pairs for the
|
187
|
+
available device information.
|
188
|
+
|
189
|
+
Note: the data for this API call is only updated once every 5 minutes.
|
190
|
+
Calling it more often will return the same response from the server and
|
191
|
+
should be avoided to limit server load.
|
192
|
+
"""
|
193
|
+
url = f"https://{await self.get_home_host()}/v2/device/{device_uuid}/datapoint/0/last/0/"
|
194
|
+
headers = {
|
195
|
+
"X-API-KEY-TOKEN": API_KEY,
|
196
|
+
"X-AUTH-TOKEN": await self.get_auth_token(),
|
197
|
+
}
|
198
|
+
response: ClientResponse = (
|
199
|
+
await self._get_request_with_logging_and_errors_raised(
|
200
|
+
url=url, headers=headers
|
201
|
+
)
|
202
|
+
)
|
203
|
+
return await response.json()
|
204
|
+
|
205
|
+
async def get_data_points_since(self, device_uuid: str, seconds_ago: int = 0, sample_period: int = 300) -> dict[str, Any]:
|
206
|
+
"""
|
207
|
+
Fetch the list of data points between a relative timestamp (in seconds) and the current time.
|
208
|
+
|
209
|
+
An optional sample period can be provided to group data points
|
210
|
+
together. The minimum sample period size is 300 (5 minutes).
|
211
|
+
|
212
|
+
Note: the data for the most recent data point is only updated once
|
213
|
+
every 5 minutes. Calling it more often will return the same response
|
214
|
+
from the server and should be avoided to limit server load.
|
215
|
+
"""
|
216
|
+
url = f"https://{await self.get_home_host()}/v2/device/{device_uuid}/datapoint/{seconds_ago}/last/{sample_period}/"
|
217
|
+
headers = {
|
218
|
+
"X-API-KEY-TOKEN": API_KEY,
|
219
|
+
"X-AUTH-TOKEN": await self.get_auth_token(),
|
220
|
+
}
|
221
|
+
response: ClientResponse = (
|
222
|
+
await self._get_request_with_logging_and_errors_raised(
|
223
|
+
url=url, headers=headers
|
224
|
+
)
|
225
|
+
)
|
226
|
+
return await response.json()
|
227
|
+
|
180
228
|
async def set_fan_speed(self, device_uuid, new_speed: str):
|
181
229
|
"""
|
182
230
|
Set the fan speed per @spikeyGG comment at https://community.home-assistant.io/t/blueair-purifier-addon/154456/14
|
@@ -205,3 +253,64 @@ class HttpBlueair:
|
|
205
253
|
)
|
206
254
|
)
|
207
255
|
return await response.json()
|
256
|
+
|
257
|
+
async def set_brightness(self, device_uuid, new_brightness: int):
|
258
|
+
if new_brightness not in [0, 1, 2, 3, 4]:
|
259
|
+
raise Exception("Brightness not supported")
|
260
|
+
url = f"https://{await self.get_home_host()}/v2/device/{device_uuid}/attribute/brightness/"
|
261
|
+
headers = {
|
262
|
+
"X-API-KEY-TOKEN": API_KEY,
|
263
|
+
"X-AUTH-TOKEN": await self.get_auth_token(),
|
264
|
+
}
|
265
|
+
json_body = {
|
266
|
+
"currentValue": new_brightness,
|
267
|
+
"scope": "device",
|
268
|
+
"name": "brightness",
|
269
|
+
"uuid": str(device_uuid),
|
270
|
+
}
|
271
|
+
response: ClientResponse = (
|
272
|
+
await self._post_request_with_logging_and_errors_raised(
|
273
|
+
url=url, json_body=json_body, headers=headers
|
274
|
+
)
|
275
|
+
)
|
276
|
+
return await response.json()
|
277
|
+
|
278
|
+
async def set_child_lock(self, device_uuid, enabled: bool):
|
279
|
+
url = f"https://{await self.get_home_host()}/v2/device/{device_uuid}/attribute/child_lock/"
|
280
|
+
new_value = "1" if enabled else "0"
|
281
|
+
headers = {
|
282
|
+
"X-API-KEY-TOKEN": API_KEY,
|
283
|
+
"X-AUTH-TOKEN": await self.get_auth_token(),
|
284
|
+
}
|
285
|
+
json_body = {
|
286
|
+
"currentValue": new_value,
|
287
|
+
"scope": "device",
|
288
|
+
"name": "child_lock",
|
289
|
+
"uuid": str(device_uuid),
|
290
|
+
}
|
291
|
+
response: ClientResponse = (
|
292
|
+
await self._post_request_with_logging_and_errors_raised(
|
293
|
+
url=url, json_body=json_body, headers=headers
|
294
|
+
)
|
295
|
+
)
|
296
|
+
return await response.json()
|
297
|
+
|
298
|
+
async def set_fan_auto_mode(self, device_uuid, auto: bool):
|
299
|
+
url = f"https://{await self.get_home_host()}/v2/device/{device_uuid}/attribute/mode/"
|
300
|
+
new_value = "auto" if auto else "manual"
|
301
|
+
headers = {
|
302
|
+
"X-API-KEY-TOKEN": API_KEY,
|
303
|
+
"X-AUTH-TOKEN": await self.get_auth_token(),
|
304
|
+
}
|
305
|
+
json_body = {
|
306
|
+
"currentValue": new_value,
|
307
|
+
"scope": "device",
|
308
|
+
"name": "mode",
|
309
|
+
"uuid": str(device_uuid),
|
310
|
+
}
|
311
|
+
response: ClientResponse = (
|
312
|
+
await self._post_request_with_logging_and_errors_raised(
|
313
|
+
url=url, json_body=json_body, headers=headers
|
314
|
+
)
|
315
|
+
)
|
316
|
+
return await response.json()
|
@@ -0,0 +1,183 @@
|
|
1
|
+
import typing
|
2
|
+
from typing import Any, TypeVar
|
3
|
+
from collections.abc import Iterable
|
4
|
+
import dataclasses
|
5
|
+
import base64
|
6
|
+
|
7
|
+
type ScalarType = str | float | bool | int | None
|
8
|
+
type MappingType = dict[str, "ObjectType"]
|
9
|
+
type SequenceType = list["ObjectType"]
|
10
|
+
type ObjectType = ScalarType | MappingType | SequenceType
|
11
|
+
|
12
|
+
|
13
|
+
def query_json(jsonobj: ObjectType, path: str) -> ObjectType:
|
14
|
+
value = jsonobj
|
15
|
+
segs = path.split(".")
|
16
|
+
for i, seg in enumerate(segs):
|
17
|
+
if isinstance(value, list):
|
18
|
+
value = value[int(seg)]
|
19
|
+
elif isinstance(value, dict):
|
20
|
+
if seg in value:
|
21
|
+
value = value[seg]
|
22
|
+
elif i == len(segs) - 1:
|
23
|
+
# last segment returns None if it is not found.
|
24
|
+
value = None
|
25
|
+
else:
|
26
|
+
raise KeyError(
|
27
|
+
f"cannot resolve path segment on a scalar "
|
28
|
+
f"when resolving segment {i}:{seg} of {path}. "
|
29
|
+
f"available keys are {value.keys()}.")
|
30
|
+
else:
|
31
|
+
raise KeyError(
|
32
|
+
f"cannot resolve path segment on a scalar "
|
33
|
+
f"when resolving segment {i}:{seg} of {path}.")
|
34
|
+
return value
|
35
|
+
|
36
|
+
def parse_json[T](kls: type[T], jsonobj: MappingType) -> dict[str, T]:
|
37
|
+
"""Parses a json mapping object to dict.
|
38
|
+
|
39
|
+
The key is preserved. The value is parsed as dataclass type kls.
|
40
|
+
"""
|
41
|
+
assert dataclasses.is_dataclass(kls)
|
42
|
+
result = {}
|
43
|
+
fields = dataclasses.fields(kls)
|
44
|
+
|
45
|
+
for key, value in jsonobj.items():
|
46
|
+
if not isinstance(value, dict):
|
47
|
+
raise TypeError("expecting mapping value to be dict.")
|
48
|
+
extra_fields = dict(value) # make extra_fields copy.
|
49
|
+
kwargs : dict[str, Any] = {}
|
50
|
+
for field in fields:
|
51
|
+
if field.name == "extra_fields":
|
52
|
+
kwargs[field.name] = extra_fields
|
53
|
+
elif field.default is dataclasses.MISSING:
|
54
|
+
kwargs[field.name] = extra_fields.pop(field.name)
|
55
|
+
else:
|
56
|
+
kwargs[field.name] = extra_fields.pop(field.name, field.default)
|
57
|
+
|
58
|
+
obj = kls(**kwargs)
|
59
|
+
result[key] = typing.cast(T, obj)
|
60
|
+
return result
|
61
|
+
|
62
|
+
|
63
|
+
########################
|
64
|
+
# Blueair AWS API Schema.
|
65
|
+
|
66
|
+
@dataclasses.dataclass
|
67
|
+
class Attribute:
|
68
|
+
"""DeviceAttribute(da); defines an attribute
|
69
|
+
|
70
|
+
An attribute is most likely mutable. An attribute may
|
71
|
+
also have alias names, likely derived from the 'dc' relation
|
72
|
+
e.g. a/sb, a/standby all refer to the 'sb' attribute.
|
73
|
+
"""
|
74
|
+
extra_fields : MappingType
|
75
|
+
n: str # name
|
76
|
+
a: int | bool # default attribute value, example value?
|
77
|
+
e: bool # ??? always True
|
78
|
+
fe:bool # ??? always True
|
79
|
+
ot: str # object type? topic type?
|
80
|
+
p: bool # only false for reboot and sflu
|
81
|
+
tn: str # topic name a path-like name d/????/a/{n}
|
82
|
+
|
83
|
+
|
84
|
+
@dataclasses.dataclass
|
85
|
+
class Sensor:
|
86
|
+
"""DeviceSensor(ds); seems to define a sensor.
|
87
|
+
|
88
|
+
We never directly access these objects. Thos this defines
|
89
|
+
the schema for 'h', 't', 'pm10' etc that gets returned in
|
90
|
+
the sensor_data senml SensorPack.
|
91
|
+
"""
|
92
|
+
extra_fields : MappingType
|
93
|
+
n: str # name
|
94
|
+
i: int # integration time? in millis
|
95
|
+
e: bool # ???
|
96
|
+
fe: bool # ??? always True.
|
97
|
+
ot: str # object type / topic name
|
98
|
+
tn: str # topic name a path-like name d/????/s/{n}
|
99
|
+
ttl: int # only seen 0 or -1, not sure if used.
|
100
|
+
tf: str | None = None # senml+json; topic format
|
101
|
+
|
102
|
+
@dataclasses.dataclass
|
103
|
+
class Control:
|
104
|
+
"""DeviceControl (dc); seems to define a state.
|
105
|
+
|
106
|
+
The states SensorPack seem to be using fields defined
|
107
|
+
in dc. The only exception is 'online' which is not defined
|
108
|
+
here.
|
109
|
+
"""
|
110
|
+
extra_fields : MappingType
|
111
|
+
n: str # name
|
112
|
+
v: int | bool
|
113
|
+
a: str | None = None
|
114
|
+
s: str | None = None
|
115
|
+
d: str | None = None # device info json path
|
116
|
+
|
117
|
+
|
118
|
+
########################
|
119
|
+
# SenML RFC8428
|
120
|
+
|
121
|
+
@dataclasses.dataclass
|
122
|
+
class Record:
|
123
|
+
"""A RFC8428 SenML record, resolved to Python types."""
|
124
|
+
name: str
|
125
|
+
unit: str | None
|
126
|
+
value: float | bool | str | bytes
|
127
|
+
timestamp: float | None
|
128
|
+
integral: float | None
|
129
|
+
|
130
|
+
|
131
|
+
class SensorPack(list[Record]):
|
132
|
+
"""Represents a RFC8428 SensorPack, resolved to Python Types."""
|
133
|
+
|
134
|
+
def __init__(self, stream: Iterable[MappingType]):
|
135
|
+
seq = []
|
136
|
+
for record in stream:
|
137
|
+
rs = None
|
138
|
+
rt = None
|
139
|
+
rn : str
|
140
|
+
ru = None
|
141
|
+
rv : float | bool | str | bytes
|
142
|
+
for label, value in record.items():
|
143
|
+
assert isinstance(value, str | int | float | bool)
|
144
|
+
match label:
|
145
|
+
case 'bn' | 'bt' | 'bu' | 'bv' | 'bs' | 'bver':
|
146
|
+
raise ValueError("TODO: base fields not supported. c.f. RFC8428, 4.1")
|
147
|
+
case 't':
|
148
|
+
rt = float(value)
|
149
|
+
case 's':
|
150
|
+
rs = float(value)
|
151
|
+
case 'v':
|
152
|
+
rv = float(value)
|
153
|
+
case 'vb':
|
154
|
+
rv = bool(value)
|
155
|
+
case 'vs':
|
156
|
+
rv = str(value)
|
157
|
+
case 'vd':
|
158
|
+
rv = bytes(base64.b64decode(str(value)))
|
159
|
+
case 'n':
|
160
|
+
rn = str(value)
|
161
|
+
case 'u':
|
162
|
+
ru = str(value)
|
163
|
+
seq.append(Record(name=rn, unit=ru, value=rv, integral=rs, timestamp=rt))
|
164
|
+
super().__init__(seq)
|
165
|
+
|
166
|
+
def to_latest_value(self) -> dict[str, str | bool | float | bytes]:
|
167
|
+
return {rn : record.value for rn, record in self.to_latest().items()}
|
168
|
+
|
169
|
+
def to_latest(self) -> dict[str, Record]:
|
170
|
+
latest : dict[str, Record] = {}
|
171
|
+
for record in self:
|
172
|
+
rn = record.name
|
173
|
+
if record.name not in latest:
|
174
|
+
latest[rn] = record
|
175
|
+
continue
|
176
|
+
lt = latest[record.name].timestamp
|
177
|
+
if record.timestamp is None:
|
178
|
+
latest[rn] = record
|
179
|
+
elif lt is None:
|
180
|
+
latest[rn] = record
|
181
|
+
elif lt < record.timestamp:
|
182
|
+
latest[rn] = record
|
183
|
+
return latest
|
@@ -0,0 +1,90 @@
|
|
1
|
+
from enum import Enum, StrEnum
|
2
|
+
|
3
|
+
|
4
|
+
class FeatureEnum(StrEnum):
|
5
|
+
TEMPERATURE = "Temperature"
|
6
|
+
HUMIDITY = "Humidity"
|
7
|
+
VOC = "VOC"
|
8
|
+
PM1 = "PM1"
|
9
|
+
PM10 = "PM10"
|
10
|
+
PM25 = "PM25"
|
11
|
+
WATER_SHORTAGE = "Water Shortage"
|
12
|
+
FILTER_EXPIRED = "Filter Expired"
|
13
|
+
CHILD_LOCK = "Child Lock"
|
14
|
+
|
15
|
+
|
16
|
+
class ModelEnum(Enum):
|
17
|
+
def __new__(cls, *args, **kwds):
|
18
|
+
value = len(cls.__members__) + 1
|
19
|
+
obj = object.__new__(cls)
|
20
|
+
obj._value_ = value
|
21
|
+
return obj
|
22
|
+
|
23
|
+
def __init__(self,
|
24
|
+
name,
|
25
|
+
supported_features):
|
26
|
+
self.model_name = name
|
27
|
+
self.supported_features = supported_features
|
28
|
+
|
29
|
+
def supports_feature(self, supported_features) -> bool:
|
30
|
+
return supported_features in self.supported_features
|
31
|
+
|
32
|
+
UNKNOWN = "Unknown", [
|
33
|
+
FeatureEnum.TEMPERATURE,
|
34
|
+
FeatureEnum.HUMIDITY,
|
35
|
+
FeatureEnum.WATER_SHORTAGE,
|
36
|
+
FeatureEnum.VOC,
|
37
|
+
FeatureEnum.PM1,
|
38
|
+
FeatureEnum.PM10,
|
39
|
+
FeatureEnum.PM25,
|
40
|
+
FeatureEnum.FILTER_EXPIRED,
|
41
|
+
FeatureEnum.CHILD_LOCK,
|
42
|
+
]
|
43
|
+
HUMIDIFIER_H35I = "Blueair Humidifier H35i", [
|
44
|
+
FeatureEnum.TEMPERATURE,
|
45
|
+
FeatureEnum.HUMIDITY,
|
46
|
+
FeatureEnum.WATER_SHORTAGE,
|
47
|
+
]
|
48
|
+
PROTECT_7440I = "Blueair Protect 7440i", [
|
49
|
+
FeatureEnum.TEMPERATURE,
|
50
|
+
FeatureEnum.HUMIDITY,
|
51
|
+
FeatureEnum.VOC,
|
52
|
+
FeatureEnum.PM1,
|
53
|
+
FeatureEnum.PM10,
|
54
|
+
FeatureEnum.PM25,
|
55
|
+
FeatureEnum.FILTER_EXPIRED,
|
56
|
+
FeatureEnum.CHILD_LOCK,
|
57
|
+
]
|
58
|
+
PROTECT_7470I = "Blueair Protect 7470i", [
|
59
|
+
FeatureEnum.TEMPERATURE,
|
60
|
+
FeatureEnum.HUMIDITY,
|
61
|
+
FeatureEnum.VOC,
|
62
|
+
FeatureEnum.PM1,
|
63
|
+
FeatureEnum.PM10,
|
64
|
+
FeatureEnum.PM25,
|
65
|
+
FeatureEnum.FILTER_EXPIRED,
|
66
|
+
FeatureEnum.CHILD_LOCK,
|
67
|
+
]
|
68
|
+
MAX_211I = "Blueair Blue Pure 211i Max", [
|
69
|
+
FeatureEnum.PM1,
|
70
|
+
FeatureEnum.PM10,
|
71
|
+
FeatureEnum.PM25,
|
72
|
+
FeatureEnum.FILTER_EXPIRED,
|
73
|
+
FeatureEnum.CHILD_LOCK,
|
74
|
+
]
|
75
|
+
MAX_311I = "Blueair Blue Pure 311i Max", [
|
76
|
+
FeatureEnum.PM25,
|
77
|
+
FeatureEnum.FILTER_EXPIRED,
|
78
|
+
FeatureEnum.CHILD_LOCK,
|
79
|
+
]
|
80
|
+
MAX_411I = "Blueair Blue Pure 411i Max", [
|
81
|
+
FeatureEnum.PM25,
|
82
|
+
FeatureEnum.FILTER_EXPIRED,
|
83
|
+
FeatureEnum.CHILD_LOCK,
|
84
|
+
]
|
85
|
+
T10I = "T10i ComfortPure 3-in-1 Filter/Heater/Fan", [
|
86
|
+
FeatureEnum.TEMPERATURE,
|
87
|
+
FeatureEnum.HUMIDITY,
|
88
|
+
FeatureEnum.PM25,
|
89
|
+
FeatureEnum.FILTER_EXPIRED,
|
90
|
+
]
|
blueair_api/stub.py
CHANGED
@@ -1,17 +1,14 @@
|
|
1
1
|
# run with "python3 src/blueair_api/stub.py"
|
2
2
|
import logging
|
3
3
|
import asyncio
|
4
|
-
from threading import Event
|
5
4
|
|
6
5
|
from getpass import getpass
|
7
6
|
from pathlib import Path
|
8
7
|
import sys
|
9
8
|
|
10
|
-
# import blueair_api
|
11
|
-
|
12
9
|
path_root = Path(__file__).parents[2]
|
13
10
|
sys.path.append(str(path_root))
|
14
|
-
from src.blueair_api import get_devices, get_aws_devices
|
11
|
+
from src.blueair_api import get_devices, get_aws_devices, DeviceAws
|
15
12
|
|
16
13
|
|
17
14
|
logger = logging.getLogger("src.blueair_api")
|
@@ -28,16 +25,15 @@ async def testing():
|
|
28
25
|
password = getpass()
|
29
26
|
try:
|
30
27
|
api, devices = await get_aws_devices(username=username, password=password)
|
31
|
-
|
32
|
-
|
33
|
-
|
28
|
+
for device in devices:
|
29
|
+
await device.refresh()
|
30
|
+
logger.debug(device)
|
34
31
|
finally:
|
35
32
|
if api:
|
36
33
|
await api.cleanup_client_session()
|
37
34
|
try:
|
38
35
|
api, devices = await get_devices(username=username, password=password)
|
39
36
|
for device in devices:
|
40
|
-
await device.init()
|
41
37
|
await device.refresh()
|
42
38
|
logger.debug(device)
|
43
39
|
finally:
|