airthings-cloud 0.3.0__tar.gz → 0.3.1__tar.gz

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,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: airthings_cloud
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A python3 library to communicate with Airthings devices
5
5
  Home-page: https://github.com/Danielhiversen/pyAirthings
6
6
  Author: Daniel Hjelseth Høyer
@@ -5,6 +5,7 @@ import asyncio
5
5
  from dataclasses import dataclass
6
6
  import json
7
7
  import logging
8
+ import time
8
9
 
9
10
  from aiohttp import ClientError
10
11
 
@@ -12,6 +13,7 @@ _LOGGER = logging.getLogger(__name__)
12
13
 
13
14
  API_URL = "https://ext-api.airthings.com/v1/"
14
15
  TIMEOUT = 10
16
+ CACHE_TTL = 3600
15
17
 
16
18
 
17
19
  @dataclass
@@ -71,6 +73,10 @@ class AirthingsAuthError(AirthingsError):
71
73
  """AirthingsAuthError Airthings occurred."""
72
74
 
73
75
 
76
+ class AirthingsNotFoundError(AirthingsError):
77
+ """Requested resource does not exist or is no longer accessible."""
78
+
79
+
74
80
  class Airthings:
75
81
  """Airthings data handler."""
76
82
 
@@ -82,78 +88,126 @@ class Airthings:
82
88
  self._access_token = None
83
89
  self._locations = []
84
90
  self._devices = {}
91
+ self._cache_updated = None
92
+ self._unresolved = set()
85
93
 
86
94
  async def _fetch_locations(self):
87
- response = await self._request(API_URL + "locations")
95
+ return [
96
+ AirthingsLocation.init_from_response(location)
97
+ for location in await self._fetch_list("locations")
98
+ ]
99
+
100
+ async def _fetch_devices(self):
101
+ devices = {}
102
+ for device in await self._fetch_list("devices"):
103
+ if not isinstance(device, dict) or "id" not in device:
104
+ _LOGGER.debug("Skipping device metadata entry without id")
105
+ continue
106
+ devices[device["id"]] = device
107
+ return devices
108
+
109
+ async def _fetch_list(self, key):
110
+ response = await self._request(API_URL + key)
88
111
  json_data = await response.json()
89
- self._locations = []
90
- for location in json_data.get("locations"):
91
- self._locations.append(AirthingsLocation.init_from_response(location))
112
+ items = json_data.get(key) if isinstance(json_data, dict) else None
113
+ if not isinstance(items, list):
114
+ raise AirthingsError(f"Invalid {key} response from Airthings")
115
+ return items
116
+
117
+ async def _refresh_cache(self):
118
+ locations = await self._fetch_locations()
119
+ devices = await self._fetch_devices()
120
+ self._locations = locations
121
+ self._devices = devices
122
+ self._unresolved = set()
123
+ self._cache_updated = time.monotonic()
124
+
125
+ def _cache_expired(self):
126
+ return (
127
+ self._cache_updated is None
128
+ or time.monotonic() - self._cache_updated > CACHE_TTL
129
+ )
92
130
 
93
131
  async def update_devices(self):
94
132
  """Update data."""
95
- locations_fetched = False
133
+ refreshed = self._cache_expired()
134
+ if refreshed:
135
+ await self._refresh_cache()
136
+ res = await self._fetch_latest_samples(skip_stale=refreshed)
137
+ if res is None:
138
+ await self._refresh_cache()
139
+ res = await self._fetch_latest_samples(skip_stale=True)
140
+ return res
96
141
 
97
- if not self._locations:
98
- await self._fetch_locations()
99
- locations_fetched = True
100
- if not self._devices:
101
- response = await self._request(API_URL + "devices")
102
- json_data = await response.json()
103
- self._devices = {}
104
- for device in json_data.get("devices"):
105
- self._devices[device['id']] = device
142
+ async def _fetch_latest_samples(self, skip_stale):
143
+ """Return None on a new unknown location or device unless skip_stale."""
106
144
  res = {}
107
145
  for location in self._locations:
108
146
  if not location.location_id:
109
147
  continue
110
- response = await self._request(
111
- API_URL + f"/locations/{location.location_id}/latest-samples"
112
- )
113
- if response is None:
114
- continue
115
- json_data = await response.json()
116
- if json_data is None:
117
- continue
118
- if devices := json_data.get("devices"):
119
- for device in devices:
120
- if not isinstance(device, dict) or 'id' not in device:
121
- if not locations_fetched:
122
- _LOGGER.debug("Device has no id, fetching locations again")
123
- await self._fetch_locations()
124
- locations_fetched = True
125
- device_id = device.get('id')
126
- res[device_id] = AirthingsDevice.init_from_response(
127
- device,
148
+ try:
149
+ response = await self._request(
150
+ API_URL + f"/locations/{location.location_id}/latest-samples"
151
+ )
152
+ except AirthingsNotFoundError as err:
153
+ if not skip_stale and location.location_id not in self._unresolved:
154
+ _LOGGER.debug(
155
+ "Location '%s' is not accessible (%s), refreshing cache",
128
156
  location.name,
129
- self._devices.get(device_id)
157
+ err,
130
158
  )
131
- else:
159
+ return None
160
+ self._unresolved.add(location.location_id)
161
+ _LOGGER.debug("Skipping inaccessible location '%s'", location.name)
162
+ continue
163
+ json_data = await response.json()
164
+ devices = json_data.get("devices") if isinstance(json_data, dict) else None
165
+ if not devices:
132
166
  _LOGGER.debug("No devices in location '%s'", location.name)
167
+ continue
168
+ for device in devices:
169
+ if not isinstance(device, dict) or "id" not in device:
170
+ _LOGGER.debug("Skipping device without id in location '%s'", location.name)
171
+ continue
172
+ device_id = device["id"]
173
+ if device_id not in self._devices:
174
+ if not skip_stale and device_id not in self._unresolved:
175
+ _LOGGER.debug(
176
+ "Unknown device '%s' in location '%s', refreshing cache",
177
+ device_id,
178
+ location.name,
179
+ )
180
+ return None
181
+ self._unresolved.add(device_id)
182
+ _LOGGER.debug("Device '%s' missing from cached metadata", device_id)
183
+ res[device_id] = AirthingsDevice.init_from_response(
184
+ device,
185
+ location.name,
186
+ self._devices.get(device_id, {}),
187
+ )
133
188
  return res
134
189
 
135
- async def _request(self, url, json_data=None, retry=3):
136
- _LOGGER.debug("Request %s %s, %s", url, retry, json_data)
190
+ async def _request(self, url, retry=3):
191
+ _LOGGER.debug("Request %s %s", url, retry)
137
192
  if self._access_token is None:
138
193
  self._access_token = await get_token(
139
194
  self._websession, self._client_id, self._secret
140
195
  )
141
- if self._access_token is None:
142
- return None
143
196
 
144
197
  headers = {"Authorization": self._access_token}
145
198
  try:
146
199
  async with asyncio.timeout(TIMEOUT):
147
- if json_data:
148
- response = await self._websession.post(
149
- url, json=json_data, headers=headers
150
- )
151
- else:
152
- response = await self._websession.get(url, headers=headers)
200
+ response = await self._websession.get(url, headers=headers)
201
+ if response.status != 200:
202
+ response.release()
203
+ if response.status in (403, 404):
204
+ raise AirthingsNotFoundError(
205
+ f"No access to {url}, response: {response.status} {response.reason}"
206
+ )
153
207
  if response.status != 200:
154
208
  if retry > 0 and response.status != 429:
155
209
  self._access_token = None
156
- return await self._request(url, json_data, retry=retry - 1)
210
+ return await self._request(url, retry=retry - 1)
157
211
  _LOGGER.error(
158
212
  "Error connecting to Airthings, response: %s %s",
159
213
  response.status,
@@ -169,7 +223,7 @@ class Airthings:
169
223
  except asyncio.TimeoutError as err:
170
224
  self._access_token = None
171
225
  if retry > 0:
172
- return await self._request(url, json_data, retry=retry - 1)
226
+ return await self._request(url, retry=retry - 1)
173
227
  _LOGGER.error("Timed out when connecting to Airthings")
174
228
  raise AirthingsError from err
175
229
  return response
@@ -209,4 +263,8 @@ async def get_token(websession, client_id, secret, retry=3, timeout=10):
209
263
  )
210
264
  raise AirthingsAuthError(f"Failed to login to retrieve token {response.reason}")
211
265
  token_data = json.loads(await response.text())
212
- return token_data.get("access_token")
266
+ access_token = token_data.get("access_token")
267
+ if not access_token:
268
+ _LOGGER.error("Airthings: Token response did not contain an access token")
269
+ raise AirthingsAuthError("Token response did not contain an access token")
270
+ return access_token
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: airthings_cloud
3
- Version: 0.3.0
3
+ Version: 0.3.1
4
4
  Summary: A python3 library to communicate with Airthings devices
5
5
  Home-page: https://github.com/Danielhiversen/pyAirthings
6
6
  Author: Daniel Hjelseth Høyer
@@ -6,4 +6,5 @@ airthings_cloud.egg-info/PKG-INFO
6
6
  airthings_cloud.egg-info/SOURCES.txt
7
7
  airthings_cloud.egg-info/dependency_links.txt
8
8
  airthings_cloud.egg-info/requires.txt
9
- airthings_cloud.egg-info/top_level.txt
9
+ airthings_cloud.egg-info/top_level.txt
10
+ tests/test_airthings.py
@@ -4,7 +4,7 @@ setup(
4
4
  name="airthings_cloud",
5
5
  packages=["airthings"],
6
6
  install_requires=["aiohttp>=3.13.0"],
7
- version="0.3.0",
7
+ version="0.3.1",
8
8
  description="A python3 library to communicate with Airthings devices",
9
9
  python_requires=">=3.13.0",
10
10
  author="Daniel Hjelseth Høyer",
@@ -0,0 +1,512 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ import json
5
+ from unittest.mock import AsyncMock, MagicMock, patch
6
+
7
+ from aiohttp import ClientError
8
+ import pytest
9
+
10
+ from airthings import (
11
+ API_URL,
12
+ CACHE_TTL,
13
+ Airthings,
14
+ AirthingsAuthError,
15
+ AirthingsConnectionError,
16
+ AirthingsError,
17
+ AirthingsNotFoundError,
18
+ get_token,
19
+ )
20
+
21
+
22
+ class MockResponse:
23
+ def __init__(
24
+ self,
25
+ *,
26
+ json_data: dict | None = None,
27
+ text_data: str | None = None,
28
+ status: int = 200,
29
+ reason: str = "OK",
30
+ ) -> None:
31
+ self._json_data = json_data
32
+ self._text_data = text_data
33
+ self.status = status
34
+ self.reason = reason
35
+ self.released = False
36
+
37
+ async def json(self) -> dict | None:
38
+ return self._json_data
39
+
40
+ async def text(self) -> str:
41
+ return self._text_data or ""
42
+
43
+ def release(self) -> None:
44
+ self.released = True
45
+
46
+
47
+ def token_response(token: str = "token") -> MockResponse:
48
+ return MockResponse(text_data=json.dumps({"access_token": token}))
49
+
50
+
51
+ PRODUCT_NAMES = {"WAVE_PLUS": "Wave Plus", "VIEW_PLUS": "View Plus"}
52
+
53
+
54
+ class FakeCloud:
55
+ """In-memory ext-api account that routes GET requests by URL."""
56
+
57
+ def __init__(self) -> None:
58
+ self.locations = {"loc1": "Home"}
59
+ self.devices = {"dev1": {"location": "loc1", "name": "Kitchen", "type": "WAVE_PLUS"}}
60
+ self.extra_samples: dict[str, list] = {}
61
+ self.forbidden: set[str] = set()
62
+ self.urls: list[str] = []
63
+
64
+ async def get(self, url: str, headers: dict) -> MockResponse:
65
+ self.urls.append(url)
66
+ if url == API_URL + "locations":
67
+ return MockResponse(
68
+ json_data={"locations": [{"id": i, "name": n} for i, n in self.locations.items()]}
69
+ )
70
+ if url == API_URL + "devices":
71
+ return MockResponse(
72
+ json_data={
73
+ "devices": [
74
+ {"id": i, "deviceType": d["type"], "productName": PRODUCT_NAMES[d["type"]]}
75
+ for i, d in self.devices.items()
76
+ ]
77
+ }
78
+ )
79
+ location_id = url.split("/locations/")[1].split("/")[0]
80
+ if location_id not in self.locations or location_id in self.forbidden:
81
+ return MockResponse(status=403, reason="Forbidden")
82
+ samples = [
83
+ {
84
+ "id": i,
85
+ "segment": {"name": d["name"], "isActive": True},
86
+ "data": {"temp": 20.0},
87
+ }
88
+ for i, d in self.devices.items()
89
+ if d["location"] == location_id
90
+ ]
91
+ return MockResponse(json_data={"devices": samples + self.extra_samples.get(location_id, [])})
92
+
93
+ def count(self, suffix: str) -> int:
94
+ return sum(url.endswith(suffix) for url in self.urls)
95
+
96
+
97
+ def make_airthings(cloud: FakeCloud | None = None) -> tuple[Airthings, MagicMock]:
98
+ websession = MagicMock()
99
+ websession.post = AsyncMock(return_value=token_response())
100
+ if cloud is not None:
101
+ websession.get = AsyncMock(side_effect=cloud.get)
102
+ return Airthings("client_id", "secret", websession), websession
103
+
104
+
105
+ @pytest.fixture
106
+ def clock():
107
+ now = [1000.0]
108
+ with patch("airthings.time.monotonic", side_effect=lambda: now[0]):
109
+ yield now
110
+
111
+
112
+ @pytest.mark.asyncio
113
+ async def test_update_devices_returns_device_data() -> None:
114
+ cloud = FakeCloud()
115
+ airthings, websession = make_airthings(cloud)
116
+
117
+ result = await airthings.update_devices()
118
+
119
+ device = result["dev1"]
120
+ assert device.device_id == "dev1"
121
+ assert device.name == "Kitchen"
122
+ assert device.sensors == {"temp": 20.0}
123
+ assert list(device.sensor_types) == ["temp"]
124
+ assert device.is_active is True
125
+ assert device.location_name == "Home"
126
+ assert device.device_type == "WAVE_PLUS"
127
+ assert device.product_name == "Wave Plus"
128
+ websession.get.assert_any_call(API_URL + "locations", headers={"Authorization": "token"})
129
+
130
+
131
+ @pytest.mark.asyncio
132
+ async def test_update_devices_reuses_cache_and_token(clock) -> None:
133
+ cloud = FakeCloud()
134
+ airthings, websession = make_airthings(cloud)
135
+
136
+ await airthings.update_devices()
137
+ await airthings.update_devices()
138
+
139
+ assert cloud.count("/locations") == 1
140
+ assert cloud.count("/devices") == 1
141
+ assert cloud.count("/latest-samples") == 2
142
+ assert websession.post.await_count == 1
143
+
144
+
145
+ @pytest.mark.asyncio
146
+ async def test_update_devices_refreshes_cache_after_ttl(clock) -> None:
147
+ cloud = FakeCloud()
148
+ airthings, _ = make_airthings(cloud)
149
+ await airthings.update_devices()
150
+
151
+ cloud.locations["loc1"] = "Renamed"
152
+ cloud.locations["loc2"] = "Cabin"
153
+ cloud.devices["dev2"] = {"location": "loc2", "name": "Office", "type": "VIEW_PLUS"}
154
+ clock[0] += CACHE_TTL
155
+ result = await airthings.update_devices()
156
+ assert "dev2" not in result
157
+ assert result["dev1"].location_name == "Home"
158
+
159
+ clock[0] += 1
160
+ result = await airthings.update_devices()
161
+ assert result["dev1"].location_name == "Renamed"
162
+ assert result["dev2"].location_name == "Cabin"
163
+ assert cloud.count("/locations") == 2
164
+
165
+
166
+ @pytest.mark.asyncio
167
+ async def test_update_devices_refreshes_when_device_is_added(clock, caplog) -> None:
168
+ cloud = FakeCloud()
169
+ airthings, _ = make_airthings(cloud)
170
+ await airthings.update_devices()
171
+
172
+ cloud.devices["dev2"] = {"location": "loc1", "name": "Office", "type": "VIEW_PLUS"}
173
+ with caplog.at_level("DEBUG", logger="airthings"):
174
+ result = await airthings.update_devices()
175
+
176
+ assert "Unknown device 'dev2' in location 'Home', refreshing cache" in caplog.text
177
+ assert set(result) == {"dev1", "dev2"}
178
+ assert result["dev2"].product_name == "View Plus"
179
+ assert cloud.count("/devices") == 2
180
+
181
+ await airthings.update_devices()
182
+ assert cloud.count("/devices") == 2
183
+
184
+
185
+ @pytest.mark.asyncio
186
+ async def test_update_devices_refreshes_when_location_is_deleted(clock, caplog) -> None:
187
+ cloud = FakeCloud()
188
+ cloud.locations["loc2"] = "Cabin"
189
+ cloud.devices["dev2"] = {"location": "loc2", "name": "Office", "type": "VIEW_PLUS"}
190
+ airthings, _ = make_airthings(cloud)
191
+ await airthings.update_devices()
192
+
193
+ del cloud.locations["loc1"]
194
+ del cloud.devices["dev1"]
195
+ with caplog.at_level("DEBUG", logger="airthings"):
196
+ result = await airthings.update_devices()
197
+
198
+ assert "Location 'Home' is not accessible (No access to" in caplog.text
199
+ assert "403 Forbidden), refreshing cache" in caplog.text
200
+
201
+ assert set(result) == {"dev2"}
202
+ assert cloud.count("/locations") == 2
203
+ assert cloud.count("/loc1/latest-samples") == 2
204
+
205
+ await airthings.update_devices()
206
+ assert cloud.count("/loc1/latest-samples") == 2
207
+
208
+
209
+ @pytest.mark.asyncio
210
+ async def test_update_devices_removes_deleted_device(clock) -> None:
211
+ cloud = FakeCloud()
212
+ cloud.devices["dev2"] = {"location": "loc1", "name": "Office", "type": "VIEW_PLUS"}
213
+ airthings, _ = make_airthings(cloud)
214
+ await airthings.update_devices()
215
+
216
+ del cloud.devices["dev2"]
217
+ result = await airthings.update_devices()
218
+
219
+ assert set(result) == {"dev1"}
220
+ assert cloud.count("/locations") == 1
221
+
222
+
223
+ @pytest.mark.asyncio
224
+ async def test_update_devices_skips_location_still_inaccessible_after_refresh(clock) -> None:
225
+ cloud = FakeCloud()
226
+ cloud.locations["loc2"] = "Cabin"
227
+ cloud.devices["dev2"] = {"location": "loc2", "name": "Office", "type": "VIEW_PLUS"}
228
+ airthings, _ = make_airthings(cloud)
229
+ await airthings.update_devices()
230
+
231
+ cloud.forbidden.add("loc1")
232
+ result = await airthings.update_devices()
233
+
234
+ assert set(result) == {"dev2"}
235
+ assert cloud.count("/locations") == 2
236
+
237
+ result = await airthings.update_devices()
238
+
239
+ assert set(result) == {"dev2"}
240
+ assert cloud.count("/locations") == 2
241
+
242
+ clock[0] += CACHE_TTL + 1
243
+ await airthings.update_devices()
244
+ assert cloud.count("/locations") == 3
245
+
246
+
247
+ @pytest.mark.asyncio
248
+ async def test_update_devices_skips_inaccessible_location_on_fresh_cache(clock) -> None:
249
+ cloud = FakeCloud()
250
+ cloud.forbidden.add("loc1")
251
+ airthings, _ = make_airthings(cloud)
252
+
253
+ result = await airthings.update_devices()
254
+
255
+ assert result == {}
256
+ assert cloud.count("/locations") == 1
257
+
258
+
259
+ @pytest.mark.asyncio
260
+ async def test_update_devices_returns_device_missing_from_metadata(clock) -> None:
261
+ cloud = FakeCloud()
262
+ airthings, _ = make_airthings(cloud)
263
+ await airthings.update_devices()
264
+
265
+ cloud.extra_samples["loc1"] = [{"id": "dev2", "segment": {"name": "Office"}, "data": {"co2": 500}}]
266
+ result = await airthings.update_devices()
267
+
268
+ assert result["dev2"].name == "Office"
269
+ assert result["dev2"].sensors == {"co2": 500}
270
+ assert result["dev2"].product_name is None
271
+ assert cloud.count("/devices") == 2
272
+
273
+ result = await airthings.update_devices()
274
+
275
+ assert set(result) == {"dev1", "dev2"}
276
+ assert cloud.count("/devices") == 2
277
+
278
+ cloud.extra_samples.clear()
279
+ cloud.devices["dev2"] = {"location": "loc1", "name": "Office", "type": "VIEW_PLUS"}
280
+ clock[0] += CACHE_TTL + 1
281
+ result = await airthings.update_devices()
282
+
283
+ assert result["dev2"].product_name == "View Plus"
284
+
285
+
286
+ @pytest.mark.asyncio
287
+ async def test_update_devices_skips_device_without_id_without_refreshing(clock) -> None:
288
+ cloud = FakeCloud()
289
+ cloud.extra_samples["loc1"] = [{"segment": {"name": "No id"}, "data": {}}, "not a dict"]
290
+ airthings, _ = make_airthings(cloud)
291
+ await airthings.update_devices()
292
+
293
+ result = await airthings.update_devices()
294
+
295
+ assert set(result) == {"dev1"}
296
+ assert cloud.count("/locations") == 1
297
+
298
+
299
+ @pytest.mark.asyncio
300
+ async def test_update_devices_skips_locations_without_id_or_devices() -> None:
301
+ airthings, websession = make_airthings()
302
+ websession.get = AsyncMock(
303
+ side_effect=[
304
+ MockResponse(
305
+ json_data={
306
+ "locations": [
307
+ {"name": "No id"},
308
+ {"id": "empty", "name": "Empty"},
309
+ {"id": "null", "name": "Null"},
310
+ {"id": "list", "name": "List"},
311
+ ]
312
+ }
313
+ ),
314
+ MockResponse(json_data={"devices": []}),
315
+ MockResponse(json_data={"devices": []}),
316
+ MockResponse(json_data=None),
317
+ MockResponse(json_data=["not", "a", "dict"]),
318
+ ]
319
+ )
320
+
321
+ assert await airthings.update_devices() == {}
322
+ assert websession.get.await_count == 5
323
+
324
+
325
+ @pytest.mark.asyncio
326
+ async def test_fetch_skips_device_metadata_without_id() -> None:
327
+ airthings, websession = make_airthings()
328
+ websession.get = AsyncMock(
329
+ return_value=MockResponse(
330
+ json_data={"devices": [{"deviceType": "WAVE_PLUS"}, "not a dict", {"id": "dev1"}]}
331
+ )
332
+ )
333
+
334
+ assert set(await airthings._fetch_devices()) == {"dev1"}
335
+
336
+
337
+ @pytest.mark.asyncio
338
+ @pytest.mark.parametrize("endpoint", ["locations", "devices"])
339
+ @pytest.mark.parametrize(
340
+ "payload",
341
+ [None, {}, {"locations": None, "devices": None}, {"locations": {}, "devices": {}}, ["x"], "text"],
342
+ )
343
+ async def test_update_devices_keeps_cache_on_malformed_metadata(clock, endpoint, payload) -> None:
344
+ cloud = FakeCloud()
345
+ airthings, websession = make_airthings(cloud)
346
+ await airthings.update_devices()
347
+
348
+ async def malformed(url: str, headers: dict) -> MockResponse:
349
+ if url == API_URL + endpoint:
350
+ return MockResponse(json_data=payload)
351
+ return await cloud.get(url, headers)
352
+
353
+ websession.get.side_effect = malformed
354
+ clock[0] += CACHE_TTL + 1
355
+
356
+ with pytest.raises(AirthingsError, match=f"Invalid {endpoint}"):
357
+ await airthings.update_devices()
358
+
359
+ websession.get.side_effect = cloud.get
360
+ result = await airthings.update_devices()
361
+
362
+ assert set(result) == {"dev1"}
363
+
364
+
365
+ @pytest.mark.asyncio
366
+ async def test_update_devices_keeps_whole_cache_when_refresh_fails(clock) -> None:
367
+ cloud = FakeCloud()
368
+ airthings, websession = make_airthings(cloud)
369
+ await airthings.update_devices()
370
+ cached_locations = airthings._locations
371
+
372
+ cloud.locations["loc2"] = "Cabin"
373
+ cloud.devices["dev2"] = {"location": "loc1", "name": "Office", "type": "VIEW_PLUS"}
374
+
375
+ async def broken_devices(url: str, headers: dict) -> MockResponse:
376
+ if url == API_URL + "devices":
377
+ return MockResponse(json_data={"devices": None})
378
+ return await cloud.get(url, headers)
379
+
380
+ websession.get.side_effect = broken_devices
381
+ with pytest.raises(AirthingsError, match="Invalid devices"):
382
+ await airthings.update_devices()
383
+
384
+ assert airthings._locations is cached_locations
385
+
386
+ websession.get.side_effect = cloud.get
387
+ result = await airthings.update_devices()
388
+
389
+ assert set(result) == {"dev1", "dev2"}
390
+ assert {location.name for location in airthings._locations} == {"Home", "Cabin"}
391
+
392
+
393
+ @pytest.mark.asyncio
394
+ async def test_request_refreshes_token_and_retries_on_unauthorized() -> None:
395
+ airthings, websession = make_airthings()
396
+ websession.post = AsyncMock(side_effect=[token_response("old"), token_response("new")])
397
+ ok = MockResponse(json_data={})
398
+ websession.get = AsyncMock(side_effect=[MockResponse(status=401, reason="Unauthorized"), ok])
399
+
400
+ assert await airthings._request("url") is ok
401
+ assert websession.get.await_args_list[-1].kwargs["headers"] == {"Authorization": "new"}
402
+
403
+
404
+ @pytest.mark.asyncio
405
+ async def test_request_raises_after_retries_are_exhausted() -> None:
406
+ airthings, websession = make_airthings()
407
+ response = MockResponse(status=500, reason="Server Error")
408
+ response.release = MagicMock()
409
+ websession.get = AsyncMock(return_value=response)
410
+
411
+ with pytest.raises(AirthingsError, match="Server Error"):
412
+ await airthings._request("url")
413
+ assert websession.get.await_count == 4
414
+ assert response.release.call_count == 4
415
+
416
+
417
+ @pytest.mark.asyncio
418
+ @pytest.mark.parametrize(
419
+ ("status", "error"),
420
+ [(403, AirthingsNotFoundError), (404, AirthingsNotFoundError), (429, AirthingsError)],
421
+ )
422
+ async def test_request_does_not_retry(status: int, error: type[Exception]) -> None:
423
+ airthings, websession = make_airthings()
424
+ response = MockResponse(status=status, reason="Nope")
425
+ websession.get = AsyncMock(return_value=response)
426
+
427
+ with pytest.raises(error):
428
+ await airthings._request("url")
429
+ assert websession.get.await_count == 1
430
+ assert websession.post.await_count == 1
431
+ assert response.released
432
+
433
+
434
+ @pytest.mark.asyncio
435
+ async def test_request_raises_on_client_error() -> None:
436
+ airthings, websession = make_airthings()
437
+ websession.get = AsyncMock(side_effect=ClientError("boom"))
438
+
439
+ with pytest.raises(AirthingsError):
440
+ await airthings._request("url")
441
+ assert airthings._access_token is None
442
+
443
+
444
+ @pytest.mark.asyncio
445
+ async def test_request_retries_on_timeout() -> None:
446
+ airthings, websession = make_airthings()
447
+ ok = MockResponse(json_data={})
448
+ websession.get = AsyncMock(side_effect=[asyncio.TimeoutError, ok])
449
+
450
+ assert await airthings._request("url") is ok
451
+ assert websession.post.await_count == 2
452
+
453
+
454
+ @pytest.mark.asyncio
455
+ async def test_request_raises_when_timeouts_are_exhausted() -> None:
456
+ airthings, websession = make_airthings()
457
+ websession.get = AsyncMock(side_effect=asyncio.TimeoutError)
458
+
459
+ with pytest.raises(AirthingsError):
460
+ await airthings._request("url", retry=1)
461
+ assert websession.get.await_count == 2
462
+
463
+
464
+ @pytest.mark.asyncio
465
+ async def test_get_token_returns_access_token() -> None:
466
+ websession = MagicMock()
467
+ websession.post = AsyncMock(return_value=token_response("abc"))
468
+
469
+ assert await get_token(websession, "id", "secret") == "abc"
470
+ assert websession.post.await_args.kwargs["data"] == {
471
+ "grant_type": "client_credentials",
472
+ "client_id": "id",
473
+ "client_secret": "secret",
474
+ }
475
+
476
+
477
+ @pytest.mark.asyncio
478
+ @pytest.mark.parametrize("error", [ClientError("boom"), asyncio.TimeoutError()])
479
+ async def test_get_token_retries_then_succeeds(error: Exception) -> None:
480
+ websession = MagicMock()
481
+ websession.post = AsyncMock(side_effect=[error, token_response("abc")])
482
+
483
+ assert await get_token(websession, "id", "secret") == "abc"
484
+
485
+
486
+ @pytest.mark.asyncio
487
+ @pytest.mark.parametrize("error", [ClientError("boom"), asyncio.TimeoutError()])
488
+ async def test_get_token_raises_connection_error_when_retries_are_exhausted(
489
+ error: Exception,
490
+ ) -> None:
491
+ websession = MagicMock()
492
+ websession.post = AsyncMock(side_effect=error)
493
+
494
+ with pytest.raises(AirthingsConnectionError):
495
+ await get_token(websession, "id", "secret", retry=1)
496
+ assert websession.post.await_count == 2
497
+
498
+
499
+ @pytest.mark.asyncio
500
+ @pytest.mark.parametrize(
501
+ "response",
502
+ [
503
+ MockResponse(status=401, reason="Unauthorized"),
504
+ MockResponse(text_data=json.dumps({"error": "invalid_client"})),
505
+ ],
506
+ )
507
+ async def test_get_token_raises_auth_error(response: MockResponse) -> None:
508
+ websession = MagicMock()
509
+ websession.post = AsyncMock(return_value=response)
510
+
511
+ with pytest.raises(AirthingsAuthError):
512
+ await get_token(websession, "id", "secret")
File without changes