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.
@@ -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