airthings-cloud 0.2.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
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: airthings_cloud
3
- Version: 0.2.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
@@ -11,7 +11,15 @@ Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
- Requires-Python: >=3.7.0
14
+ Requires-Python: >=3.13.0
15
15
  License-File: LICENSE
16
- Requires-Dist: aiohttp>=3.0.6
17
- Requires-Dist: async_timeout>=3.0.0
16
+ Requires-Dist: aiohttp>=3.13.0
17
+ Dynamic: author
18
+ Dynamic: author-email
19
+ Dynamic: classifier
20
+ Dynamic: home-page
21
+ Dynamic: license
22
+ Dynamic: license-file
23
+ Dynamic: requires-dist
24
+ Dynamic: requires-python
25
+ Dynamic: summary
@@ -5,14 +5,15 @@ 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
- import async_timeout
11
11
 
12
12
  _LOGGER = logging.getLogger(__name__)
13
13
 
14
14
  API_URL = "https://ext-api.airthings.com/v1/"
15
15
  TIMEOUT = 10
16
+ CACHE_TTL = 3600
16
17
 
17
18
 
18
19
  @dataclass
@@ -72,6 +73,10 @@ class AirthingsAuthError(AirthingsError):
72
73
  """AirthingsAuthError Airthings occurred."""
73
74
 
74
75
 
76
+ class AirthingsNotFoundError(AirthingsError):
77
+ """Requested resource does not exist or is no longer accessible."""
78
+
79
+
75
80
  class Airthings:
76
81
  """Airthings data handler."""
77
82
 
@@ -83,67 +88,126 @@ class Airthings:
83
88
  self._access_token = None
84
89
  self._locations = []
85
90
  self._devices = {}
91
+ self._cache_updated = None
92
+ self._unresolved = set()
93
+
94
+ async def _fetch_locations(self):
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)
111
+ json_data = await response.json()
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
+ )
86
130
 
87
131
  async def update_devices(self):
88
132
  """Update data."""
89
- if not self._locations:
90
- response = await self._request(API_URL + "locations")
91
- json_data = await response.json()
92
- self._locations = []
93
- for location in json_data.get("locations"):
94
- self._locations.append(AirthingsLocation.init_from_response(location))
95
- if not self._devices:
96
- response = await self._request(API_URL + "devices")
97
- json_data = await response.json()
98
- self._devices = {}
99
- for device in json_data.get("devices"):
100
- self._devices[device['id']] = device
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
141
+
142
+ async def _fetch_latest_samples(self, skip_stale):
143
+ """Return None on a new unknown location or device unless skip_stale."""
101
144
  res = {}
102
145
  for location in self._locations:
103
146
  if not location.location_id:
104
147
  continue
105
- response = await self._request(
106
- API_URL + f"/locations/{location.location_id}/latest-samples"
107
- )
108
- if response is None:
109
- continue
110
- json_data = await response.json()
111
- if json_data is None:
112
- continue
113
- if devices := json_data.get("devices"):
114
- for device in devices:
115
- id = device.get('id')
116
- res[id] = AirthingsDevice.init_from_response(
117
- 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",
118
156
  location.name,
119
- self._devices.get(id)
157
+ err,
120
158
  )
121
- 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:
122
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
+ )
123
188
  return res
124
189
 
125
- async def _request(self, url, json_data=None, retry=3):
126
- _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)
127
192
  if self._access_token is None:
128
193
  self._access_token = await get_token(
129
194
  self._websession, self._client_id, self._secret
130
195
  )
131
- if self._access_token is None:
132
- return None
133
196
 
134
197
  headers = {"Authorization": self._access_token}
135
198
  try:
136
- async with async_timeout.timeout(TIMEOUT):
137
- if json_data:
138
- response = await self._websession.post(
139
- url, json=json_data, headers=headers
140
- )
141
- else:
142
- response = await self._websession.get(url, headers=headers)
199
+ async with asyncio.timeout(TIMEOUT):
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
+ )
143
207
  if response.status != 200:
144
208
  if retry > 0 and response.status != 429:
145
209
  self._access_token = None
146
- return await self._request(url, json_data, retry=retry - 1)
210
+ return await self._request(url, retry=retry - 1)
147
211
  _LOGGER.error(
148
212
  "Error connecting to Airthings, response: %s %s",
149
213
  response.status,
@@ -159,7 +223,7 @@ class Airthings:
159
223
  except asyncio.TimeoutError as err:
160
224
  self._access_token = None
161
225
  if retry > 0:
162
- return await self._request(url, json_data, retry=retry - 1)
226
+ return await self._request(url, retry=retry - 1)
163
227
  _LOGGER.error("Timed out when connecting to Airthings")
164
228
  raise AirthingsError from err
165
229
  return response
@@ -168,7 +232,7 @@ class Airthings:
168
232
  async def get_token(websession, client_id, secret, retry=3, timeout=10):
169
233
  """Get token for Airthings."""
170
234
  try:
171
- async with async_timeout.timeout(timeout):
235
+ async with asyncio.timeout(timeout):
172
236
  response = await websession.post(
173
237
  "https://accounts-api.airthings.com/v1/token",
174
238
  headers={
@@ -199,4 +263,8 @@ async def get_token(websession, client_id, secret, retry=3, timeout=10):
199
263
  )
200
264
  raise AirthingsAuthError(f"Failed to login to retrieve token {response.reason}")
201
265
  token_data = json.loads(await response.text())
202
- 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
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: airthings_cloud
3
- Version: 0.2.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
@@ -11,7 +11,15 @@ Classifier: Operating System :: OS Independent
11
11
  Classifier: Programming Language :: Python
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
14
- Requires-Python: >=3.7.0
14
+ Requires-Python: >=3.13.0
15
15
  License-File: LICENSE
16
- Requires-Dist: aiohttp>=3.0.6
17
- Requires-Dist: async_timeout>=3.0.0
16
+ Requires-Dist: aiohttp>=3.13.0
17
+ Dynamic: author
18
+ Dynamic: author-email
19
+ Dynamic: classifier
20
+ Dynamic: home-page
21
+ Dynamic: license
22
+ Dynamic: license-file
23
+ Dynamic: requires-dist
24
+ Dynamic: requires-python
25
+ Dynamic: summary
@@ -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
@@ -0,0 +1 @@
1
+ aiohttp>=3.13.0
@@ -3,10 +3,10 @@ from setuptools import setup
3
3
  setup(
4
4
  name="airthings_cloud",
5
5
  packages=["airthings"],
6
- install_requires=["aiohttp>=3.0.6", "async_timeout>=3.0.0"],
7
- version="0.2.0",
6
+ install_requires=["aiohttp>=3.13.0"],
7
+ version="0.3.1",
8
8
  description="A python3 library to communicate with Airthings devices",
9
- python_requires=">=3.7.0",
9
+ python_requires=">=3.13.0",
10
10
  author="Daniel Hjelseth Høyer",
11
11
  author_email="github@dahoiv.net",
12
12
  url="https://github.com/Danielhiversen/pyAirthings",
@@ -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")
@@ -1,2 +0,0 @@
1
- aiohttp>=3.0.6
2
- async_timeout>=3.0.0
File without changes