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.
@@ -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
- async def request_with_active_session_wrapper(*args, **kwargs):
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
- async def request_with_errors_wrapper(*args, **kwargs):
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 = {
@@ -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, any]]:
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, any]:
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, any]:
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
- await devices[0].refresh()
32
- await devices[0].set_child_lock(True)
33
- logger.debug(devices[0])
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: