volvocarsapi 0.0.2b0__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.
- volvocarsapi/__init__.py +1 -0
- volvocarsapi/api.py +377 -0
- volvocarsapi/auth.py +236 -0
- volvocarsapi/models.py +189 -0
- volvocarsapi/py.typed +0 -0
- volvocarsapi/util.py +31 -0
- volvocarsapi-0.0.2b0.dist-info/METADATA +98 -0
- volvocarsapi-0.0.2b0.dist-info/RECORD +10 -0
- volvocarsapi-0.0.2b0.dist-info/WHEEL +4 -0
- volvocarsapi-0.0.2b0.dist-info/licenses/LICENSE +674 -0
volvocarsapi/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Volvo Cars API."""
|
volvocarsapi/api.py
ADDED
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
"""Volvo Cars API."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from typing import Any, cast
|
|
5
|
+
|
|
6
|
+
from aiohttp import (
|
|
7
|
+
ClientError,
|
|
8
|
+
ClientResponseError,
|
|
9
|
+
ClientSession,
|
|
10
|
+
ClientTimeout,
|
|
11
|
+
RequestInfo,
|
|
12
|
+
hdrs,
|
|
13
|
+
)
|
|
14
|
+
from yarl import URL
|
|
15
|
+
|
|
16
|
+
from .auth import AccessTokenManager
|
|
17
|
+
from .models import (
|
|
18
|
+
VolvoApiException,
|
|
19
|
+
VolvoAuthException,
|
|
20
|
+
VolvoCarsAvailableCommand,
|
|
21
|
+
VolvoCarsCommandResult,
|
|
22
|
+
VolvoCarsErrorResult,
|
|
23
|
+
VolvoCarsLocation,
|
|
24
|
+
VolvoCarsValue,
|
|
25
|
+
VolvoCarsValueField,
|
|
26
|
+
VolvoCarsVehicle,
|
|
27
|
+
)
|
|
28
|
+
from .util import redact_data, redact_url
|
|
29
|
+
|
|
30
|
+
_API_CONNECTED_ENDPOINT = "/connected-vehicle/v2/vehicles"
|
|
31
|
+
_API_ENERGY_ENDPOINT = "/energy/v1/vehicles"
|
|
32
|
+
_API_LOCATION_ENDPOINT = "/location/v1/vehicles"
|
|
33
|
+
_API_URL = "https://api.volvocars.com"
|
|
34
|
+
_API_STATUS_URL = "https://public-developer-portal-bff.weu-prod.ecpaz.volvocars.biz/api/v1/backend-status"
|
|
35
|
+
_API_REQUEST_TIMEOUT = ClientTimeout(total=30)
|
|
36
|
+
|
|
37
|
+
_DATA_TO_REDACT = [
|
|
38
|
+
"coordinates",
|
|
39
|
+
"heading",
|
|
40
|
+
"href",
|
|
41
|
+
"vin",
|
|
42
|
+
]
|
|
43
|
+
|
|
44
|
+
_LOGGER = logging.getLogger(__name__)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class VolvoCarsApi:
|
|
48
|
+
"""Volvo Cars API."""
|
|
49
|
+
|
|
50
|
+
def __init__(
|
|
51
|
+
self,
|
|
52
|
+
client: ClientSession,
|
|
53
|
+
token_manager: AccessTokenManager,
|
|
54
|
+
vin: str,
|
|
55
|
+
api_key: str,
|
|
56
|
+
) -> None:
|
|
57
|
+
"""Initialize Volvo Cars API."""
|
|
58
|
+
self._client = client
|
|
59
|
+
self._token_manager = token_manager
|
|
60
|
+
self._vin = vin
|
|
61
|
+
self._api_key = api_key
|
|
62
|
+
|
|
63
|
+
async def async_get_api_status(self) -> dict[str, VolvoCarsValue]:
|
|
64
|
+
"""Check the API status."""
|
|
65
|
+
try:
|
|
66
|
+
_LOGGER.debug("Request [API status]")
|
|
67
|
+
async with self._client.get(
|
|
68
|
+
_API_STATUS_URL, timeout=_API_REQUEST_TIMEOUT
|
|
69
|
+
) as response:
|
|
70
|
+
_LOGGER.debug("Request [API status] status: %s", response.status)
|
|
71
|
+
response.raise_for_status()
|
|
72
|
+
json = await response.json()
|
|
73
|
+
data = cast(dict[str, Any], json)
|
|
74
|
+
_LOGGER.debug("Request [API status] response: %s", data)
|
|
75
|
+
|
|
76
|
+
message = data.get("message") or "OK"
|
|
77
|
+
|
|
78
|
+
except (ClientError, TimeoutError) as ex:
|
|
79
|
+
_LOGGER.debug("Request [API status] error: %s", ex)
|
|
80
|
+
message = "Unknown"
|
|
81
|
+
|
|
82
|
+
return {"apiStatus": VolvoCarsValue(message)}
|
|
83
|
+
|
|
84
|
+
async def async_get_brakes_status(self) -> dict[str, VolvoCarsValueField | None]:
|
|
85
|
+
"""Get brakes status.
|
|
86
|
+
|
|
87
|
+
Required scopes: openid conve:brake_status
|
|
88
|
+
"""
|
|
89
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "brakes")
|
|
90
|
+
|
|
91
|
+
async def async_get_command_accessibility(
|
|
92
|
+
self,
|
|
93
|
+
) -> dict[str, VolvoCarsValueField | None]:
|
|
94
|
+
"""Get availability status.
|
|
95
|
+
|
|
96
|
+
Required scopes: openid conve:command_accessibility
|
|
97
|
+
"""
|
|
98
|
+
return await self._async_get_field(
|
|
99
|
+
_API_CONNECTED_ENDPOINT, "command-accessibility"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
async def async_get_commands(self) -> list[VolvoCarsAvailableCommand | None]:
|
|
103
|
+
"""Get available commands.
|
|
104
|
+
|
|
105
|
+
Required scopes: openid conve:commands
|
|
106
|
+
"""
|
|
107
|
+
body = await self._async_get(_API_CONNECTED_ENDPOINT, "commands")
|
|
108
|
+
items = self._get_data_list(body)
|
|
109
|
+
return [VolvoCarsAvailableCommand.from_dict(item) for item in items]
|
|
110
|
+
|
|
111
|
+
async def async_get_diagnostics(self) -> dict[str, VolvoCarsValueField | None]:
|
|
112
|
+
"""Get diagnostics.
|
|
113
|
+
|
|
114
|
+
Required scopes: openid conve:diagnostics_workshop
|
|
115
|
+
"""
|
|
116
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "diagnostics")
|
|
117
|
+
|
|
118
|
+
async def async_get_doors_status(self) -> dict[str, VolvoCarsValueField | None]:
|
|
119
|
+
"""Get doors status.
|
|
120
|
+
|
|
121
|
+
Required scopes: openid conve:doors_status conve:lock_status
|
|
122
|
+
"""
|
|
123
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "doors")
|
|
124
|
+
|
|
125
|
+
async def async_get_engine_status(self) -> dict[str, VolvoCarsValueField | None]:
|
|
126
|
+
"""Get engine status.
|
|
127
|
+
|
|
128
|
+
Required scopes: openid conve:engine_status
|
|
129
|
+
"""
|
|
130
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "engine-status")
|
|
131
|
+
|
|
132
|
+
async def async_get_engine_warnings(self) -> dict[str, VolvoCarsValueField | None]:
|
|
133
|
+
"""Get engine warnings.
|
|
134
|
+
|
|
135
|
+
Required scopes: openid conve:diagnostics_engine_status
|
|
136
|
+
"""
|
|
137
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "engine")
|
|
138
|
+
|
|
139
|
+
async def async_get_fuel_status(self) -> dict[str, VolvoCarsValueField | None]:
|
|
140
|
+
"""Get fuel status.
|
|
141
|
+
|
|
142
|
+
Required scopes: openid conve:fuel_status conve:battery_charge_level
|
|
143
|
+
"""
|
|
144
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "fuel")
|
|
145
|
+
|
|
146
|
+
async def async_get_location(self) -> dict[str, VolvoCarsLocation | None]:
|
|
147
|
+
"""Get location.
|
|
148
|
+
|
|
149
|
+
Required scopes: openid location:read
|
|
150
|
+
"""
|
|
151
|
+
data = await self._async_get_data_dict(_API_LOCATION_ENDPOINT, "location")
|
|
152
|
+
return {"location": VolvoCarsLocation.from_dict(data)}
|
|
153
|
+
|
|
154
|
+
async def async_get_odometer(self) -> dict[str, VolvoCarsValueField | None]:
|
|
155
|
+
"""Get odometer.
|
|
156
|
+
|
|
157
|
+
Required scopes: openid conve:odometer_status
|
|
158
|
+
"""
|
|
159
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "odometer")
|
|
160
|
+
|
|
161
|
+
async def async_get_recharge_status(self) -> dict[str, VolvoCarsValueField | None]:
|
|
162
|
+
"""Get recharge status.
|
|
163
|
+
|
|
164
|
+
Required scopes: openid
|
|
165
|
+
|
|
166
|
+
And at least one of:
|
|
167
|
+
energy:recharge_status energy:battery_charge_level energy:electric_range
|
|
168
|
+
energy:estimated_charging_time energy:charging_connection_status
|
|
169
|
+
energy:charging_system_status energy:charging_current_limit
|
|
170
|
+
energy:target_battery_level
|
|
171
|
+
"""
|
|
172
|
+
return await self._async_get_field(_API_ENERGY_ENDPOINT, "recharge-status")
|
|
173
|
+
|
|
174
|
+
async def async_get_statistics(self) -> dict[str, VolvoCarsValueField | None]:
|
|
175
|
+
"""Get statistics.
|
|
176
|
+
|
|
177
|
+
Required scopes: openid conve:trip_statistics
|
|
178
|
+
"""
|
|
179
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "statistics")
|
|
180
|
+
|
|
181
|
+
async def async_get_tyre_states(self) -> dict[str, VolvoCarsValueField | None]:
|
|
182
|
+
"""Get tyre states.
|
|
183
|
+
|
|
184
|
+
Required scopes: openid conve:tyre_status
|
|
185
|
+
"""
|
|
186
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "tyres")
|
|
187
|
+
|
|
188
|
+
async def async_get_vehicles(self) -> list[str]:
|
|
189
|
+
"""Get vehicles.
|
|
190
|
+
|
|
191
|
+
Required scopes: openid conve:vehicle_relation
|
|
192
|
+
"""
|
|
193
|
+
url = f"{_API_URL}{_API_CONNECTED_ENDPOINT}"
|
|
194
|
+
body = await self._async_request(hdrs.METH_GET, url, name="vehicles")
|
|
195
|
+
items = self._get_data_list(body)
|
|
196
|
+
return [item["vin"] for item in items]
|
|
197
|
+
|
|
198
|
+
async def async_get_vehicle_details(self) -> VolvoCarsVehicle | None:
|
|
199
|
+
"""Get vehicle details.
|
|
200
|
+
|
|
201
|
+
Required scopes: openid conve:vehicle_relation
|
|
202
|
+
"""
|
|
203
|
+
data = await self._async_get_data_dict(_API_CONNECTED_ENDPOINT, "")
|
|
204
|
+
return VolvoCarsVehicle.from_dict(data)
|
|
205
|
+
|
|
206
|
+
async def async_get_warnings(self) -> dict[str, VolvoCarsValueField | None]:
|
|
207
|
+
"""Get warnings.
|
|
208
|
+
|
|
209
|
+
Required scopes: openid conve:warnings
|
|
210
|
+
"""
|
|
211
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "warnings")
|
|
212
|
+
|
|
213
|
+
async def async_get_window_states(self) -> dict[str, VolvoCarsValueField | None]:
|
|
214
|
+
"""Get window states.
|
|
215
|
+
|
|
216
|
+
Required scopes: openid conve:windows_status
|
|
217
|
+
"""
|
|
218
|
+
return await self._async_get_field(_API_CONNECTED_ENDPOINT, "windows")
|
|
219
|
+
|
|
220
|
+
async def async_execute_command(
|
|
221
|
+
self, command: str, body: dict[str, Any] | None = None
|
|
222
|
+
) -> VolvoCarsCommandResult | None:
|
|
223
|
+
"""Execute a command.
|
|
224
|
+
|
|
225
|
+
Required scopes: openid
|
|
226
|
+
|
|
227
|
+
Per command:
|
|
228
|
+
- engine-start: conve:engine_start_stop
|
|
229
|
+
- engine-stop: conve:engine_start_stop
|
|
230
|
+
- flash: conve:honk_flash
|
|
231
|
+
- honk: conve:honk_flash
|
|
232
|
+
- honk-flash: conve:honk_flash
|
|
233
|
+
- lock: conve:lock
|
|
234
|
+
- lock-reduced-guard: conve:lock
|
|
235
|
+
- unlock: conve:unlock
|
|
236
|
+
"""
|
|
237
|
+
body = await self._async_post(
|
|
238
|
+
_API_CONNECTED_ENDPOINT, f"commands/{command}", body
|
|
239
|
+
)
|
|
240
|
+
data: dict = body.get("data", {})
|
|
241
|
+
data["invoke_status"] = data.pop("invokeStatus", None)
|
|
242
|
+
return VolvoCarsCommandResult.from_dict(data)
|
|
243
|
+
|
|
244
|
+
async def _async_get_field(
|
|
245
|
+
self, endpoint: str, operation: str
|
|
246
|
+
) -> dict[str, VolvoCarsValueField | None]:
|
|
247
|
+
body = await self._async_get(endpoint, operation)
|
|
248
|
+
data: dict = body.get("data", {})
|
|
249
|
+
return {
|
|
250
|
+
key: VolvoCarsValueField.from_dict(value) for key, value in data.items()
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async def _async_get_data_dict(
|
|
254
|
+
self, endpoint: str, operation: str
|
|
255
|
+
) -> dict[str, Any]:
|
|
256
|
+
body = await self._async_get(endpoint, operation)
|
|
257
|
+
return cast(dict[str, Any], body.get("data", {}))
|
|
258
|
+
|
|
259
|
+
async def _async_get(self, endpoint: str, operation: str) -> dict[str, Any]:
|
|
260
|
+
url = self._create_vin_url(endpoint, operation)
|
|
261
|
+
return await self._async_request(hdrs.METH_GET, url, name=operation)
|
|
262
|
+
|
|
263
|
+
async def _async_post(
|
|
264
|
+
self, endpoint: str, operation: str, body: dict[str, Any] | None = None
|
|
265
|
+
) -> dict[str, Any]:
|
|
266
|
+
url = self._create_vin_url(endpoint, operation)
|
|
267
|
+
return await self._async_request(hdrs.METH_POST, url, name=operation, body=body)
|
|
268
|
+
|
|
269
|
+
def _get_data_list(self, body: dict[str, Any]) -> list[Any]:
|
|
270
|
+
return cast(list[Any], body.get("data", []))
|
|
271
|
+
|
|
272
|
+
def _create_vin_url(self, endpoint: str, operation: str) -> str:
|
|
273
|
+
return (
|
|
274
|
+
f"{_API_URL}{endpoint}/{self._vin}/{operation}"
|
|
275
|
+
if operation
|
|
276
|
+
else f"{_API_URL}{endpoint}/{self._vin}"
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
async def _async_request(
|
|
280
|
+
self,
|
|
281
|
+
method: str,
|
|
282
|
+
url: str,
|
|
283
|
+
*,
|
|
284
|
+
name: str,
|
|
285
|
+
body: dict[str, Any] | None = None,
|
|
286
|
+
) -> dict[str, Any]:
|
|
287
|
+
access_token = await self._token_manager.async_get_access_token()
|
|
288
|
+
headers = {
|
|
289
|
+
hdrs.AUTHORIZATION: f"Bearer {access_token}",
|
|
290
|
+
"vcc-api-key": self._api_key,
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if method == hdrs.METH_POST:
|
|
294
|
+
headers[hdrs.CONTENT_TYPE] = "application/json"
|
|
295
|
+
|
|
296
|
+
data: dict[str, Any]
|
|
297
|
+
|
|
298
|
+
try:
|
|
299
|
+
_LOGGER.debug(
|
|
300
|
+
"Request [%s]: %s %s",
|
|
301
|
+
name,
|
|
302
|
+
method,
|
|
303
|
+
redact_url(url, self._vin),
|
|
304
|
+
)
|
|
305
|
+
async with self._client.request(
|
|
306
|
+
method, url, headers=headers, json=body, timeout=_API_REQUEST_TIMEOUT
|
|
307
|
+
) as response:
|
|
308
|
+
_LOGGER.debug("Request [%s] status: %s", name, response.status)
|
|
309
|
+
json = await response.json()
|
|
310
|
+
data = cast(dict[str, Any], json)
|
|
311
|
+
_LOGGER.debug(
|
|
312
|
+
"Request [%s] response: %s",
|
|
313
|
+
name,
|
|
314
|
+
redact_data(data, _DATA_TO_REDACT),
|
|
315
|
+
)
|
|
316
|
+
response.raise_for_status()
|
|
317
|
+
return data
|
|
318
|
+
except ClientResponseError as ex:
|
|
319
|
+
if ex.status == 404:
|
|
320
|
+
return {}
|
|
321
|
+
|
|
322
|
+
_LOGGER.debug("Request [%s] error: %s", name, ex.message)
|
|
323
|
+
|
|
324
|
+
if ex.status == 422 and "/commands" in url:
|
|
325
|
+
return {
|
|
326
|
+
"data": {
|
|
327
|
+
"vin": self._vin,
|
|
328
|
+
"invokeStatus": "UNKNOWN",
|
|
329
|
+
"message": "",
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
redacted_exception = RedactedClientResponseError(ex, self._vin)
|
|
334
|
+
message = redacted_exception.message
|
|
335
|
+
|
|
336
|
+
if data and (error_data := data.get("error")):
|
|
337
|
+
error = VolvoCarsErrorResult.from_dict(error_data)
|
|
338
|
+
|
|
339
|
+
if error is not None:
|
|
340
|
+
message = f"{error.message} {error.description}".strip()
|
|
341
|
+
|
|
342
|
+
if ex.status in (401, 403):
|
|
343
|
+
raise VolvoAuthException(message) from redacted_exception
|
|
344
|
+
|
|
345
|
+
raise VolvoApiException(message) from redacted_exception
|
|
346
|
+
|
|
347
|
+
except (ClientError, TimeoutError) as ex:
|
|
348
|
+
_LOGGER.debug("Request [%s] error: %s", name, ex.__class__.__name__)
|
|
349
|
+
raise VolvoApiException(ex.__class__.__name__) from ex
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
class RedactedClientResponseError(ClientResponseError):
|
|
353
|
+
"""Exception class that redacts sensitive data."""
|
|
354
|
+
|
|
355
|
+
def __init__(self, exception: ClientResponseError, vin: str) -> None:
|
|
356
|
+
"""Initialize class."""
|
|
357
|
+
|
|
358
|
+
redacted_url = self._redact_url(exception.request_info.url, vin)
|
|
359
|
+
redacted_real_url = self._redact_url(exception.request_info.real_url, vin)
|
|
360
|
+
redacted_request_info = RequestInfo(
|
|
361
|
+
redacted_url,
|
|
362
|
+
exception.request_info.method,
|
|
363
|
+
exception.request_info.headers,
|
|
364
|
+
redacted_real_url,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
super().__init__(
|
|
368
|
+
redacted_request_info,
|
|
369
|
+
exception.history,
|
|
370
|
+
status=exception.status,
|
|
371
|
+
message=exception.message,
|
|
372
|
+
headers=exception.headers,
|
|
373
|
+
)
|
|
374
|
+
|
|
375
|
+
def _redact_url(self, url: URL, vin: str) -> URL:
|
|
376
|
+
redacted_url = redact_url(str(url), vin)
|
|
377
|
+
return URL(redacted_url)
|
volvocarsapi/auth.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Volvo Cars Auth API."""
|
|
2
|
+
|
|
3
|
+
from abc import ABC, abstractmethod
|
|
4
|
+
from asyncio import Lock
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import logging
|
|
8
|
+
import secrets
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, cast
|
|
11
|
+
|
|
12
|
+
from aiohttp import ClientError, ClientSession, ClientTimeout, hdrs
|
|
13
|
+
from yarl import URL
|
|
14
|
+
|
|
15
|
+
from .models import TokenResponse, VolvoAuthException
|
|
16
|
+
from .util import redact_data
|
|
17
|
+
|
|
18
|
+
AUTHORIZE_URL = "https://volvoid.eu.volvocars.com/as/authorization.oauth2"
|
|
19
|
+
TOKEN_URL = "https://volvoid.eu.volvocars.com/as/token.oauth2"
|
|
20
|
+
|
|
21
|
+
_LOGGER = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
_API_REQUEST_TIMEOUT = ClientTimeout(total=30)
|
|
24
|
+
_CLOCK_OUT_OF_SYNC_MAX_SEC = 20
|
|
25
|
+
_DATA_TO_REDACT = [
|
|
26
|
+
"access_token",
|
|
27
|
+
"code",
|
|
28
|
+
"id",
|
|
29
|
+
"id_token",
|
|
30
|
+
"href",
|
|
31
|
+
"refresh_token",
|
|
32
|
+
"target",
|
|
33
|
+
"username",
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _generate_code_verifier(code_verifier_length: int = 128) -> str:
|
|
38
|
+
if not 43 <= code_verifier_length <= 128:
|
|
39
|
+
msg = (
|
|
40
|
+
"Parameter `code_verifier_length` must validate"
|
|
41
|
+
"`43 <= code_verifier_length <= 128`."
|
|
42
|
+
)
|
|
43
|
+
raise ValueError(msg)
|
|
44
|
+
return secrets.token_urlsafe(96)[:code_verifier_length]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _compute_code_challenge(code_verifier: str) -> str:
|
|
48
|
+
if not 43 <= len(code_verifier) <= 128:
|
|
49
|
+
msg = (
|
|
50
|
+
"Parameter `code_verifier` must validate `43 <= len(code_verifier) <= 128`."
|
|
51
|
+
)
|
|
52
|
+
raise ValueError(msg)
|
|
53
|
+
|
|
54
|
+
hashed = hashlib.sha256(code_verifier.encode("ascii")).digest()
|
|
55
|
+
encoded = base64.urlsafe_b64encode(hashed)
|
|
56
|
+
return encoded.decode("ascii").replace("=", "")
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AccessTokenManager(ABC):
|
|
60
|
+
"""Access Token manager."""
|
|
61
|
+
|
|
62
|
+
def __init__(self, websession: ClientSession) -> None:
|
|
63
|
+
"""Initialize the auth."""
|
|
64
|
+
self.websession = websession
|
|
65
|
+
|
|
66
|
+
@abstractmethod
|
|
67
|
+
async def async_get_access_token(self) -> str:
|
|
68
|
+
"""Return a valid access token."""
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class VolvoCarsAuth(AccessTokenManager):
|
|
72
|
+
"""Volvo Cars authentication."""
|
|
73
|
+
|
|
74
|
+
def __init__(
|
|
75
|
+
self,
|
|
76
|
+
websession: ClientSession,
|
|
77
|
+
*,
|
|
78
|
+
client_id: str,
|
|
79
|
+
client_secret: str,
|
|
80
|
+
scopes: list[str],
|
|
81
|
+
redirect_uri: str,
|
|
82
|
+
code_verifier_length: int = 128,
|
|
83
|
+
) -> None:
|
|
84
|
+
"""Initialize the auth."""
|
|
85
|
+
super().__init__(websession)
|
|
86
|
+
|
|
87
|
+
self._client_id = client_id
|
|
88
|
+
self._client_secret = client_secret
|
|
89
|
+
self._scopes = scopes
|
|
90
|
+
self._redirect_uri = redirect_uri
|
|
91
|
+
|
|
92
|
+
self._code_verifier = _generate_code_verifier(code_verifier_length)
|
|
93
|
+
|
|
94
|
+
credentials = f"{self._client_id}:{self._client_secret}"
|
|
95
|
+
self._encoded_credentials = base64.b64encode(
|
|
96
|
+
credentials.encode("utf-8")
|
|
97
|
+
).decode("utf-8")
|
|
98
|
+
|
|
99
|
+
self._token: TokenResponse
|
|
100
|
+
self._token_lock = Lock()
|
|
101
|
+
|
|
102
|
+
@property
|
|
103
|
+
def token(self) -> TokenResponse | None:
|
|
104
|
+
"""Get current token."""
|
|
105
|
+
return self._token
|
|
106
|
+
|
|
107
|
+
@property
|
|
108
|
+
def valid_token(self) -> bool:
|
|
109
|
+
"""Return if token is still valid."""
|
|
110
|
+
return (
|
|
111
|
+
self._token.expires_at > time.time() + _CLOCK_OUT_OF_SYNC_MAX_SEC
|
|
112
|
+
if self._token
|
|
113
|
+
else False
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
async def async_get_access_token(self) -> str:
|
|
117
|
+
"""Return a valid access token, refresh if needed."""
|
|
118
|
+
await self.async_ensure_token_valid()
|
|
119
|
+
return self._token.access_token
|
|
120
|
+
|
|
121
|
+
async def async_ensure_token_valid(self) -> None:
|
|
122
|
+
"""Ensure that the current token is valid."""
|
|
123
|
+
async with self._token_lock:
|
|
124
|
+
if self.valid_token:
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
if not self._token:
|
|
128
|
+
raise ValueError("No token available.")
|
|
129
|
+
|
|
130
|
+
await self.async_refresh_token(self._token.refresh_token)
|
|
131
|
+
|
|
132
|
+
def get_auth_uri(self, state: str | None = None) -> str:
|
|
133
|
+
"""Get the full authorization URL."""
|
|
134
|
+
query = {
|
|
135
|
+
"response_type": "code",
|
|
136
|
+
"client_id": self._client_id,
|
|
137
|
+
"redirect_uri": self._redirect_uri,
|
|
138
|
+
"scope": " ".join(self._scopes),
|
|
139
|
+
"code_challenge": _compute_code_challenge(self._code_verifier),
|
|
140
|
+
"code_challenge_method": "S256",
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
if state:
|
|
144
|
+
query |= {"state": state}
|
|
145
|
+
|
|
146
|
+
return str(URL(AUTHORIZE_URL).with_query(query))
|
|
147
|
+
|
|
148
|
+
async def async_request_token(self, code: str) -> TokenResponse:
|
|
149
|
+
"""Request access token."""
|
|
150
|
+
data = {
|
|
151
|
+
"grant_type": "authorization_code",
|
|
152
|
+
"code": code,
|
|
153
|
+
"redirect_uri": self._redirect_uri,
|
|
154
|
+
"code_verifier": self._code_verifier,
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
response = await self._async_request(
|
|
158
|
+
hdrs.METH_POST,
|
|
159
|
+
TOKEN_URL,
|
|
160
|
+
headers={"Authorization": f"Basic {self._encoded_credentials}"},
|
|
161
|
+
data=data,
|
|
162
|
+
name="tokens",
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
self._token = self._create_token_response(response)
|
|
166
|
+
return self._token
|
|
167
|
+
|
|
168
|
+
async def async_refresh_token(self, refresh_token: str) -> TokenResponse:
|
|
169
|
+
"""Refresh token."""
|
|
170
|
+
data = {
|
|
171
|
+
"grant_type": "refresh_token",
|
|
172
|
+
"refresh_token": refresh_token,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
response = await self._async_request(
|
|
176
|
+
hdrs.METH_POST,
|
|
177
|
+
TOKEN_URL,
|
|
178
|
+
headers={"Authorization": f"Basic {self._encoded_credentials}"},
|
|
179
|
+
data=data,
|
|
180
|
+
name="token refresh",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
self._token = self._create_token_response(response)
|
|
184
|
+
return self._token
|
|
185
|
+
|
|
186
|
+
def _create_token_response(self, token: dict[str, Any]) -> TokenResponse:
|
|
187
|
+
token["expires_in"] = int(token["expires_in"])
|
|
188
|
+
token["expires_at"] = time.time() + token["expires_in"]
|
|
189
|
+
|
|
190
|
+
token_response = TokenResponse.from_dict(token)
|
|
191
|
+
|
|
192
|
+
if token_response is None:
|
|
193
|
+
raise ValueError("Could not create token response.")
|
|
194
|
+
|
|
195
|
+
return token_response
|
|
196
|
+
|
|
197
|
+
async def _async_request(
|
|
198
|
+
self,
|
|
199
|
+
method: str,
|
|
200
|
+
url: str,
|
|
201
|
+
*,
|
|
202
|
+
params: dict[str, str] | None = None,
|
|
203
|
+
headers: dict[str, str] | None = None,
|
|
204
|
+
data: dict[str, str] | None = None,
|
|
205
|
+
json: dict[str, Any] | None = None,
|
|
206
|
+
name: str = "",
|
|
207
|
+
) -> dict[str, Any]:
|
|
208
|
+
_LOGGER.debug("Request [%s]", name)
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
async with self.websession.request(
|
|
212
|
+
method,
|
|
213
|
+
url,
|
|
214
|
+
params=params,
|
|
215
|
+
headers=headers,
|
|
216
|
+
data=data,
|
|
217
|
+
json=json,
|
|
218
|
+
timeout=_API_REQUEST_TIMEOUT,
|
|
219
|
+
) as response:
|
|
220
|
+
_LOGGER.debug("Request [%s] status: %s", name, response.status)
|
|
221
|
+
|
|
222
|
+
json = await response.json()
|
|
223
|
+
data = cast(dict[str, Any], json)
|
|
224
|
+
|
|
225
|
+
_LOGGER.debug(
|
|
226
|
+
"Request [%s] response: %s",
|
|
227
|
+
name,
|
|
228
|
+
redact_data(data, _DATA_TO_REDACT),
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
response.raise_for_status()
|
|
232
|
+
return data
|
|
233
|
+
|
|
234
|
+
except (ClientError, TimeoutError) as ex:
|
|
235
|
+
_LOGGER.debug("Request [%s] error: %s", name, ex.__class__.__name__)
|
|
236
|
+
raise VolvoAuthException(ex.__class__.__name__) from ex
|