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.
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/PKG-INFO +13 -5
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/airthings/__init__.py +111 -43
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/airthings_cloud.egg-info/PKG-INFO +13 -5
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/airthings_cloud.egg-info/SOURCES.txt +2 -1
- airthings_cloud-0.3.1/airthings_cloud.egg-info/requires.txt +1 -0
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/setup.py +3 -3
- airthings_cloud-0.3.1/tests/test_airthings.py +512 -0
- airthings_cloud-0.2.0/airthings_cloud.egg-info/requires.txt +0 -2
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/LICENSE +0 -0
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/README.md +0 -0
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/airthings_cloud.egg-info/dependency_links.txt +0 -0
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/airthings_cloud.egg-info/top_level.txt +0 -0
- {airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: airthings_cloud
|
|
3
|
-
Version: 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.
|
|
14
|
+
Requires-Python: >=3.13.0
|
|
15
15
|
License-File: LICENSE
|
|
16
|
-
Requires-Dist: aiohttp>=3.0
|
|
17
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
-
|
|
157
|
+
err,
|
|
120
158
|
)
|
|
121
|
-
|
|
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,
|
|
126
|
-
_LOGGER.debug("Request %s %s
|
|
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
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: airthings_cloud
|
|
3
|
-
Version: 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.
|
|
14
|
+
Requires-Python: >=3.13.0
|
|
15
15
|
License-File: LICENSE
|
|
16
|
-
Requires-Dist: aiohttp>=3.0
|
|
17
|
-
|
|
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
|
|
@@ -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.
|
|
7
|
-
version="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.
|
|
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")
|
|
File without changes
|
|
File without changes
|
{airthings_cloud-0.2.0 → airthings_cloud-0.3.1}/airthings_cloud.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|