py-livewire 0.1.0__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.
- py_livewire/__init__.py +39 -0
- py_livewire/__main__.py +253 -0
- py_livewire/auth.py +118 -0
- py_livewire/client.py +971 -0
- py_livewire/const.py +36 -0
- py_livewire/exceptions.py +33 -0
- py_livewire/models.py +245 -0
- py_livewire/py.typed +0 -0
- py_livewire/utils.py +87 -0
- py_livewire-0.1.0.dist-info/METADATA +180 -0
- py_livewire-0.1.0.dist-info/RECORD +15 -0
- py_livewire-0.1.0.dist-info/WHEEL +5 -0
- py_livewire-0.1.0.dist-info/entry_points.txt +2 -0
- py_livewire-0.1.0.dist-info/licenses/LICENSE +201 -0
- py_livewire-0.1.0.dist-info/top_level.txt +1 -0
py_livewire/client.py
ADDED
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
"""Async client of the LiveWire mobile API, modeled on the LiveWire EV app 1.9.0.
|
|
2
|
+
|
|
3
|
+
The backend only answers the charging status for device UUIDs that are paired with
|
|
4
|
+
the bike (other vehicle data like the position was served to unpaired UUIDs in
|
|
5
|
+
testing). The UUID is generated by the client; pairing it is a cloud flow where the
|
|
6
|
+
bike shows a code on its display that is sent back to the backend, see Client.pair().
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import inspect
|
|
11
|
+
import json
|
|
12
|
+
import uuid
|
|
13
|
+
from collections.abc import Awaitable, Callable, Collection, Iterable
|
|
14
|
+
from datetime import UTC, datetime, time
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
import aiohttp
|
|
18
|
+
|
|
19
|
+
from . import const
|
|
20
|
+
from .auth import Authenticator, jwt_subject
|
|
21
|
+
from .exceptions import ApiError, NotPairedError, PairingError
|
|
22
|
+
from .models import (
|
|
23
|
+
Bike,
|
|
24
|
+
ChargingSchedule,
|
|
25
|
+
ChargingStatus,
|
|
26
|
+
Location,
|
|
27
|
+
PairedUser,
|
|
28
|
+
SoftwareVersion,
|
|
29
|
+
TirePressure,
|
|
30
|
+
)
|
|
31
|
+
from .utils import to_bool, to_int
|
|
32
|
+
|
|
33
|
+
BikeId = str | int
|
|
34
|
+
|
|
35
|
+
_PAIRING_ERRORS = {
|
|
36
|
+
400: "the bike must be switched on, not in propulsion mode and not charging",
|
|
37
|
+
402: "the bike has no cellular connection",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
_MAINTENANCE_OK = (const.CODE_OK, const.CODE_MAINTENANCE_OK)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _field(data: Any, *keys: str) -> Any:
|
|
44
|
+
for key in keys:
|
|
45
|
+
if not isinstance(data, dict):
|
|
46
|
+
return None
|
|
47
|
+
data = data.get(key)
|
|
48
|
+
return data
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _dict(data: Any, *keys: str) -> dict[str, Any] | None:
|
|
52
|
+
res = _field(data, *keys)
|
|
53
|
+
return res if isinstance(res, dict) else None
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _list(data: Any, *keys: str) -> list[Any]:
|
|
57
|
+
res = _field(data, *keys)
|
|
58
|
+
return res if isinstance(res, list) else []
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _query(params: dict[str, Any] | None) -> list[tuple[str, str]]:
|
|
62
|
+
"""Encode params like Retrofit: skip None, lowercase booleans, repeat lists."""
|
|
63
|
+
res = []
|
|
64
|
+
for key, value in (params or {}).items():
|
|
65
|
+
for v in value if isinstance(value, (list, tuple)) else [value]:
|
|
66
|
+
if v is not None:
|
|
67
|
+
res.append((key, str(v).lower() if isinstance(v, bool) else str(v)))
|
|
68
|
+
return res
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _parse(text: str) -> Any:
|
|
72
|
+
if not text.strip():
|
|
73
|
+
return None
|
|
74
|
+
try:
|
|
75
|
+
return json.loads(text)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return text
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _error_message(data: Any, default: str) -> str:
|
|
81
|
+
if isinstance(data, dict):
|
|
82
|
+
error = data["error"] if isinstance(data.get("error"), dict) else data
|
|
83
|
+
for key in ("description", "detail", "message", "title"):
|
|
84
|
+
if error.get(key):
|
|
85
|
+
return str(error[key])
|
|
86
|
+
return default
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _raise_for_error(
|
|
90
|
+
status: int, data: Any, ok_codes: Collection[str] = (const.CODE_OK,)
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Raise for an error envelope, which mostly comes with HTTP 200, or HTTP error."""
|
|
93
|
+
error = _field(data, "error")
|
|
94
|
+
if isinstance(error, dict) and error:
|
|
95
|
+
code = None if error.get("code") is None else str(error["code"])
|
|
96
|
+
if code not in ok_codes:
|
|
97
|
+
message = f"{_error_message(data, 'unknown error')} (code {code})"
|
|
98
|
+
if code == const.CODE_NOT_PAIRED:
|
|
99
|
+
raise NotPairedError(
|
|
100
|
+
f"{message}: device UUID not paired with the bike "
|
|
101
|
+
"or bike not reachable",
|
|
102
|
+
code=code,
|
|
103
|
+
status=status,
|
|
104
|
+
)
|
|
105
|
+
raise ApiError(message, code=code, status=status)
|
|
106
|
+
|
|
107
|
+
if status >= 400:
|
|
108
|
+
raise ApiError(_error_message(data, f"HTTP {status}"), status=status)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _local_utc_offset() -> int:
|
|
112
|
+
offset = datetime.now().astimezone().utcoffset()
|
|
113
|
+
return int(offset.total_seconds() // 60) if offset else 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _day(day: str) -> str:
|
|
117
|
+
name = day.strip()[:3].capitalize()
|
|
118
|
+
if name not in const.DAYS:
|
|
119
|
+
raise ValueError(
|
|
120
|
+
f"invalid day {day!r}, expected one of {', '.join(const.DAYS)}"
|
|
121
|
+
)
|
|
122
|
+
return name
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _schedule_body(
|
|
126
|
+
days: Iterable[str], start: time, end: time, utc_offset_minutes: int | None
|
|
127
|
+
) -> dict[str, Any]:
|
|
128
|
+
return {
|
|
129
|
+
"timezoneOffsetMinutes": _local_utc_offset()
|
|
130
|
+
if utc_offset_minutes is None
|
|
131
|
+
else utc_offset_minutes,
|
|
132
|
+
"days": [_day(d) for d in days],
|
|
133
|
+
"time": {"start": start.strftime("%H:%M"), "end": end.strftime("%H:%M")},
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _now() -> str:
|
|
138
|
+
# The app sends the current instant, the backend returns all periods up to it
|
|
139
|
+
return datetime.now(UTC).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class Client:
|
|
143
|
+
"""Client of the LiveWire mobile API.
|
|
144
|
+
|
|
145
|
+
device_uuid identifies this client to the backend. Without one a random UUID is
|
|
146
|
+
generated, which has to be paired with the bike (see pair()) before the charging
|
|
147
|
+
status is available. Keep the UUID after pairing and pass it in again later. The
|
|
148
|
+
UUID of a phone that is already paired works as well.
|
|
149
|
+
"""
|
|
150
|
+
|
|
151
|
+
def __init__(
|
|
152
|
+
self,
|
|
153
|
+
email: str,
|
|
154
|
+
password: str,
|
|
155
|
+
device_uuid: str | None = None,
|
|
156
|
+
*,
|
|
157
|
+
session: aiohttp.ClientSession | None = None,
|
|
158
|
+
device_model: str = const.DEVICE_MODEL,
|
|
159
|
+
os_version: str = const.OS_VERSION,
|
|
160
|
+
language: str = const.LANGUAGE,
|
|
161
|
+
api_url: str = const.API_URL,
|
|
162
|
+
gigya_url: str = const.GIGYA_URL,
|
|
163
|
+
):
|
|
164
|
+
if device_uuid is None:
|
|
165
|
+
device_uuid = str(uuid.uuid4())
|
|
166
|
+
else:
|
|
167
|
+
# validate, but keep the spelling: the backend may compare it as string
|
|
168
|
+
uuid.UUID(device_uuid)
|
|
169
|
+
|
|
170
|
+
self._device_uuid = device_uuid
|
|
171
|
+
self._session = session
|
|
172
|
+
self._owns_session = session is None
|
|
173
|
+
self._api_url = api_url.rstrip("/")
|
|
174
|
+
self._language = language
|
|
175
|
+
self._headers = {
|
|
176
|
+
"User-Agent": "Android",
|
|
177
|
+
"Content-Type": "application/json",
|
|
178
|
+
"Accept-Language": language,
|
|
179
|
+
"model": device_model,
|
|
180
|
+
"osVersion": os_version,
|
|
181
|
+
}
|
|
182
|
+
self._auth = Authenticator(
|
|
183
|
+
email,
|
|
184
|
+
password,
|
|
185
|
+
device_uuid,
|
|
186
|
+
self._headers,
|
|
187
|
+
gigya_url=gigya_url.rstrip("/"),
|
|
188
|
+
api_url=self._api_url,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
async def __aenter__(self) -> "Client":
|
|
192
|
+
return self
|
|
193
|
+
|
|
194
|
+
async def __aexit__(self, *exc: object) -> None:
|
|
195
|
+
await self.close()
|
|
196
|
+
|
|
197
|
+
async def close(self) -> None:
|
|
198
|
+
if self._owns_session and self._session is not None:
|
|
199
|
+
await self._session.close()
|
|
200
|
+
self._session = None
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def device_uuid(self) -> str:
|
|
204
|
+
return self._device_uuid
|
|
205
|
+
|
|
206
|
+
@property
|
|
207
|
+
def user_id(self) -> str | None:
|
|
208
|
+
"""The backend's UUID of the account, known after logging in."""
|
|
209
|
+
token = self._auth.token
|
|
210
|
+
return jwt_subject(token) if token else None
|
|
211
|
+
|
|
212
|
+
async def login(self) -> None:
|
|
213
|
+
"""Log in. Optional, the first request logs in on demand."""
|
|
214
|
+
await self._auth.get_token(self._get_session())
|
|
215
|
+
|
|
216
|
+
def _get_session(self) -> aiohttp.ClientSession:
|
|
217
|
+
if self._session is None:
|
|
218
|
+
self._session = aiohttp.ClientSession()
|
|
219
|
+
self._owns_session = True
|
|
220
|
+
return self._session
|
|
221
|
+
|
|
222
|
+
async def _fetch(
|
|
223
|
+
self,
|
|
224
|
+
method: str,
|
|
225
|
+
url: str,
|
|
226
|
+
query: list[tuple[str, str]],
|
|
227
|
+
body: Any,
|
|
228
|
+
headers: dict[str, str] | None,
|
|
229
|
+
) -> tuple[int, Any]:
|
|
230
|
+
async with self._get_session().request(
|
|
231
|
+
method, url, params=query, json=body, headers=headers
|
|
232
|
+
) as resp:
|
|
233
|
+
return resp.status, _parse(await resp.text())
|
|
234
|
+
|
|
235
|
+
async def _send(
|
|
236
|
+
self,
|
|
237
|
+
method: str,
|
|
238
|
+
path: str,
|
|
239
|
+
*,
|
|
240
|
+
params: dict[str, Any] | None = None,
|
|
241
|
+
body: Any = None,
|
|
242
|
+
authed: bool = True,
|
|
243
|
+
) -> tuple[int, Any]:
|
|
244
|
+
url = f"{self._api_url}/{path}"
|
|
245
|
+
if not authed:
|
|
246
|
+
return await self._fetch(method, url, _query(params), body, None)
|
|
247
|
+
|
|
248
|
+
query = _query({**(params or {}), "brand": const.BRAND})
|
|
249
|
+
token = await self._auth.get_token(self._get_session())
|
|
250
|
+
status, data = await self._fetch(
|
|
251
|
+
method,
|
|
252
|
+
url,
|
|
253
|
+
query,
|
|
254
|
+
body,
|
|
255
|
+
{**self._headers, "Authorization": f"Bearer {token}"},
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
if status == 401:
|
|
259
|
+
# The token expired, log in again once
|
|
260
|
+
token = await self._auth.refresh(self._get_session(), token)
|
|
261
|
+
status, data = await self._fetch(
|
|
262
|
+
method,
|
|
263
|
+
url,
|
|
264
|
+
query,
|
|
265
|
+
body,
|
|
266
|
+
{**self._headers, "Authorization": f"Bearer {token}"},
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
return status, data
|
|
270
|
+
|
|
271
|
+
async def _request(
|
|
272
|
+
self,
|
|
273
|
+
method: str,
|
|
274
|
+
path: str,
|
|
275
|
+
*,
|
|
276
|
+
params: dict[str, Any] | None = None,
|
|
277
|
+
body: Any = None,
|
|
278
|
+
ok_codes: Collection[str] = (const.CODE_OK,),
|
|
279
|
+
authed: bool = True,
|
|
280
|
+
) -> Any:
|
|
281
|
+
status, data = await self._send(
|
|
282
|
+
method, path, params=params, body=body, authed=authed
|
|
283
|
+
)
|
|
284
|
+
_raise_for_error(status, data, ok_codes)
|
|
285
|
+
return data
|
|
286
|
+
|
|
287
|
+
# Bikes
|
|
288
|
+
|
|
289
|
+
async def get_bikes(self) -> list[Bike]:
|
|
290
|
+
"""Bikes of the account, with whether this device UUID is paired with each."""
|
|
291
|
+
data = await self._request(
|
|
292
|
+
"GET", "getAllbikes/pairingStatus", params={"deviceUUID": self._device_uuid}
|
|
293
|
+
)
|
|
294
|
+
return [Bike.from_dict(b) for b in _list(data, "bikes")]
|
|
295
|
+
|
|
296
|
+
async def get_vin_info(self, vin: str) -> dict[str, Any] | None:
|
|
297
|
+
"""Model, year and color of a VIN, as used for adding a bike."""
|
|
298
|
+
return _dict(await self._request("GET", f"newvinApi/lookup/{vin}"), "vin")
|
|
299
|
+
|
|
300
|
+
async def add_bike(self, bike: dict[str, Any]) -> dict[str, Any] | None:
|
|
301
|
+
"""Add a bike to the account.
|
|
302
|
+
|
|
303
|
+
The app fills name, vin, make, model, modelNumber, modelCode, brandShortName,
|
|
304
|
+
year and color from get_vin_info(), and ownerProfileId with user_id.
|
|
305
|
+
"""
|
|
306
|
+
return _dict(await self._request("POST", "bike", body=bike), "data", "bike")
|
|
307
|
+
|
|
308
|
+
async def update_bike(
|
|
309
|
+
self, bike_id: BikeId, bike: dict[str, Any]
|
|
310
|
+
) -> dict[str, Any] | None:
|
|
311
|
+
"""Change a bike, e.g. its name. bike carries all fields as for add_bike()."""
|
|
312
|
+
return _dict(
|
|
313
|
+
await self._request("PUT", f"bike/{bike_id}", body=bike), "data", "bike"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
async def delete_bike(self, bike_id: BikeId) -> None:
|
|
317
|
+
"""Remove a bike from the account."""
|
|
318
|
+
await self._request("DELETE", f"bike/{bike_id}")
|
|
319
|
+
|
|
320
|
+
# Pairing
|
|
321
|
+
|
|
322
|
+
async def is_paired(self, bike_id: BikeId) -> bool:
|
|
323
|
+
"""Whether this device UUID is paired with the bike."""
|
|
324
|
+
data = await self._request(
|
|
325
|
+
"GET",
|
|
326
|
+
f"bikes/pairing/status/{bike_id}",
|
|
327
|
+
params={"deviceUUID": self._device_uuid},
|
|
328
|
+
)
|
|
329
|
+
return bool(to_bool(_field(data, "pairingStatus")))
|
|
330
|
+
|
|
331
|
+
async def request_pairing_code(self, bike_id: BikeId) -> None:
|
|
332
|
+
"""Make the bike show a code on its display to pair this device UUID.
|
|
333
|
+
|
|
334
|
+
The bike must be switched on, not in propulsion mode and not charging, and
|
|
335
|
+
needs cellular reception. There is no working endpoint to cancel the request,
|
|
336
|
+
an unused code just expires.
|
|
337
|
+
"""
|
|
338
|
+
data = await self._request(
|
|
339
|
+
"POST", f"bikes/{bike_id}/pair", params={"deviceUUID": self._device_uuid}
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
status = to_int(_field(data, "status"))
|
|
343
|
+
if status not in (None, 200):
|
|
344
|
+
reason = _PAIRING_ERRORS.get(status) or _error_message(
|
|
345
|
+
data, "pairing request failed"
|
|
346
|
+
)
|
|
347
|
+
raise PairingError(f"{reason} (status {status})", code=status)
|
|
348
|
+
|
|
349
|
+
async def verify_pairing_code(self, bike_id: BikeId, code: str) -> None:
|
|
350
|
+
"""Send back the code the bike shows to complete pairing this device UUID."""
|
|
351
|
+
status, data = await self._send(
|
|
352
|
+
"POST",
|
|
353
|
+
f"bikes/{bike_id}/verify/code",
|
|
354
|
+
params={"deviceUUID": self._device_uuid},
|
|
355
|
+
body={"code": code},
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
result = to_int(_field(data, "code")) or to_int(_field(data, "error", "code"))
|
|
359
|
+
if result == const.CODE_ATTEMPTS_EXCEEDED:
|
|
360
|
+
raise PairingError("too many wrong codes, request a new one", code=result)
|
|
361
|
+
if result == const.CODE_WRONG_CODE:
|
|
362
|
+
raise PairingError("wrong code", code=result)
|
|
363
|
+
|
|
364
|
+
_raise_for_error(status, data)
|
|
365
|
+
|
|
366
|
+
async def pair(
|
|
367
|
+
self,
|
|
368
|
+
bike_id: BikeId,
|
|
369
|
+
get_code: Callable[[], str | Awaitable[str]],
|
|
370
|
+
*,
|
|
371
|
+
timeout: float = 60,
|
|
372
|
+
interval: float = 5,
|
|
373
|
+
) -> None:
|
|
374
|
+
"""Pair this device UUID with a bike.
|
|
375
|
+
|
|
376
|
+
Requests a pairing code, calls get_code() (plain or coroutine function) to
|
|
377
|
+
obtain the code shown on the bike's display, verifies it and waits until the
|
|
378
|
+
backend confirms the pairing. See request_pairing_code() for the bike's
|
|
379
|
+
required state.
|
|
380
|
+
"""
|
|
381
|
+
await self.request_pairing_code(bike_id)
|
|
382
|
+
|
|
383
|
+
code = get_code()
|
|
384
|
+
if inspect.isawaitable(code):
|
|
385
|
+
code = await code
|
|
386
|
+
await self.verify_pairing_code(bike_id, str(code).strip())
|
|
387
|
+
|
|
388
|
+
loop = asyncio.get_running_loop()
|
|
389
|
+
deadline = loop.time() + timeout
|
|
390
|
+
while not await self.is_paired(bike_id):
|
|
391
|
+
if loop.time() >= deadline:
|
|
392
|
+
raise PairingError("the bike did not confirm the pairing")
|
|
393
|
+
await asyncio.sleep(interval)
|
|
394
|
+
|
|
395
|
+
async def get_paired_users(self, bike_id: BikeId) -> list[PairedUser]:
|
|
396
|
+
"""Users and devices paired with a bike.
|
|
397
|
+
|
|
398
|
+
The backend answered this with HTTP 401 in testing, even for a paired device
|
|
399
|
+
UUID, so expect an ApiError.
|
|
400
|
+
"""
|
|
401
|
+
data = await self._request(
|
|
402
|
+
"GET",
|
|
403
|
+
f"bikes/paired/users/{bike_id}",
|
|
404
|
+
params={"deviceUUID": self._device_uuid},
|
|
405
|
+
)
|
|
406
|
+
return [PairedUser.from_dict(u) for u in _list(data, "users")]
|
|
407
|
+
|
|
408
|
+
async def _unpair_params(
|
|
409
|
+
self, device_uuid: str | None, user_id: str | None
|
|
410
|
+
) -> dict[str, Any]:
|
|
411
|
+
if user_id is None:
|
|
412
|
+
await self.login()
|
|
413
|
+
user_id = self.user_id
|
|
414
|
+
return {"deviceUUID": device_uuid or self._device_uuid, "userUUID": user_id}
|
|
415
|
+
|
|
416
|
+
async def request_unpairing_code(
|
|
417
|
+
self,
|
|
418
|
+
bike_id: BikeId,
|
|
419
|
+
device_uuid: str | None = None,
|
|
420
|
+
user_id: str | None = None,
|
|
421
|
+
) -> None:
|
|
422
|
+
"""Make the bike show a code to unpair a device, by default this client's own.
|
|
423
|
+
|
|
424
|
+
Even though this is a GET request, it triggers the code on the bike's display.
|
|
425
|
+
"""
|
|
426
|
+
params = await self._unpair_params(device_uuid, user_id)
|
|
427
|
+
data = await self._request("GET", f"bikes/{bike_id}/unpair", params=params)
|
|
428
|
+
|
|
429
|
+
status = to_int(_field(data, "status"))
|
|
430
|
+
if status not in (None, 200):
|
|
431
|
+
raise PairingError(
|
|
432
|
+
f"{_error_message(data, 'unpairing request failed')} (status {status})",
|
|
433
|
+
code=status,
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
async def verify_unpairing_code(
|
|
437
|
+
self,
|
|
438
|
+
bike_id: BikeId,
|
|
439
|
+
code: str,
|
|
440
|
+
device_uuid: str | None = None,
|
|
441
|
+
user_id: str | None = None,
|
|
442
|
+
) -> None:
|
|
443
|
+
"""Send back the code the bike shows to complete unpairing a device."""
|
|
444
|
+
params = await self._unpair_params(device_uuid, user_id)
|
|
445
|
+
data = await self._request(
|
|
446
|
+
"POST",
|
|
447
|
+
f"bikes/{bike_id}/verify/unpair/code",
|
|
448
|
+
params=params,
|
|
449
|
+
body={"code": code},
|
|
450
|
+
)
|
|
451
|
+
|
|
452
|
+
status = to_int(_field(data, "status"))
|
|
453
|
+
if status not in (None, 200):
|
|
454
|
+
raise PairingError(
|
|
455
|
+
f"{_error_message(data, 'unpairing failed')} (status {status})",
|
|
456
|
+
code=status,
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
# Vehicle data
|
|
460
|
+
|
|
461
|
+
async def get_charging_status(self, bike_id: BikeId) -> ChargingStatus:
|
|
462
|
+
"""Battery, charging, range and odometer data, queried from the bike.
|
|
463
|
+
|
|
464
|
+
Raises NotPairedError if this device UUID is not paired with the bike, or if the
|
|
465
|
+
backend cannot reach the bike.
|
|
466
|
+
"""
|
|
467
|
+
data = await self._request(
|
|
468
|
+
"GET",
|
|
469
|
+
f"bikes/{bike_id}/charging/status",
|
|
470
|
+
ok_codes=(const.CODE_OK, const.CODE_PARTIAL),
|
|
471
|
+
)
|
|
472
|
+
return ChargingStatus.from_dict(data if isinstance(data, dict) else {})
|
|
473
|
+
|
|
474
|
+
async def get_location(self, bike_id: BikeId) -> Location:
|
|
475
|
+
"""The last GPS position the bike reported."""
|
|
476
|
+
data = await self._request("GET", f"bike/{bike_id}/location")
|
|
477
|
+
return Location.from_dict(_dict(data, "data") or {})
|
|
478
|
+
|
|
479
|
+
async def get_tire_pressure(
|
|
480
|
+
self, bike_id: BikeId, model_code: str | None = None
|
|
481
|
+
) -> TirePressure | None:
|
|
482
|
+
"""Tire pressures, None if none reported. The app passes Bike.model_code."""
|
|
483
|
+
data = await self._request(
|
|
484
|
+
"GET", f"bikes/{bike_id}/status/tpms", params={"modelCode": model_code}
|
|
485
|
+
)
|
|
486
|
+
if not isinstance(data, dict) or to_int(data.get("code")) == const.CODE_NO_DATA:
|
|
487
|
+
return None
|
|
488
|
+
return TirePressure.from_dict(data)
|
|
489
|
+
|
|
490
|
+
async def get_software_versions(self) -> list[SoftwareVersion]:
|
|
491
|
+
"""Installed and available vehicle software versions of the paired bikes."""
|
|
492
|
+
data = await self._request("GET", "bike/vsb-versions")
|
|
493
|
+
return [SoftwareVersion.from_dict(b) for b in _list(data, "bikes")]
|
|
494
|
+
|
|
495
|
+
async def get_software_release_notes(
|
|
496
|
+
self, bike_id: BikeId, versions: Iterable[str]
|
|
497
|
+
) -> list[dict[str, Any]]:
|
|
498
|
+
"""Release notes of vehicle software versions, e.g. SoftwareVersion.latest."""
|
|
499
|
+
data = await self._request(
|
|
500
|
+
"POST",
|
|
501
|
+
f"bike/{bike_id}/vsb-descriptions",
|
|
502
|
+
body={"vsbVersions": list(versions)},
|
|
503
|
+
)
|
|
504
|
+
return _list(data, "data")
|
|
505
|
+
|
|
506
|
+
async def get_charging_analytics(self, bike_id: BikeId) -> dict[str, Any] | None:
|
|
507
|
+
"""Charging sessions and energy for the WEEK, MONTH, 3M and 6M periods."""
|
|
508
|
+
return _dict(
|
|
509
|
+
await self._request(
|
|
510
|
+
"GET", f"bikes/{bike_id}/charging/analytics", params={"date": _now()}
|
|
511
|
+
)
|
|
512
|
+
)
|
|
513
|
+
|
|
514
|
+
async def get_ride_analytics(self, bike_id: BikeId) -> dict[str, Any] | None:
|
|
515
|
+
"""Rides, distance and efficiency for the WEEK, MONTH, 3M and 6M periods."""
|
|
516
|
+
return _dict(
|
|
517
|
+
await self._request(
|
|
518
|
+
"GET", f"bikes/{bike_id}/ride/analytics", params={"date": _now()}
|
|
519
|
+
)
|
|
520
|
+
)
|
|
521
|
+
|
|
522
|
+
async def set_enhanced_tracking(self, bike_id: BikeId, enabled: bool) -> str | None:
|
|
523
|
+
"""Switch enhanced vehicle tracking of a stolen bike on or off.
|
|
524
|
+
|
|
525
|
+
While on, the bike reports its position every few minutes. Returns the tracking
|
|
526
|
+
status (STOLEN, SAFE or PENDING), which get_charging_status() reports as well.
|
|
527
|
+
"""
|
|
528
|
+
data = await self._request(
|
|
529
|
+
"POST",
|
|
530
|
+
f"bikes/{bike_id}/tracking",
|
|
531
|
+
body={
|
|
532
|
+
"bikeId": str(bike_id),
|
|
533
|
+
"securityType": "STOLEN" if enabled else "SAFE",
|
|
534
|
+
},
|
|
535
|
+
)
|
|
536
|
+
status = _field(data, "securityType")
|
|
537
|
+
return None if status is None else str(status)
|
|
538
|
+
|
|
539
|
+
async def set_tracking_consent(self, bike_id: BikeId, consent: bool) -> None:
|
|
540
|
+
"""Accept or decline the terms of enhanced vehicle tracking."""
|
|
541
|
+
await self._request(
|
|
542
|
+
"POST",
|
|
543
|
+
f"bikes/{bike_id}/terms",
|
|
544
|
+
body={"bikeId": str(bike_id), "consent": consent},
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
# Charging control
|
|
548
|
+
|
|
549
|
+
async def set_charge_limit(self, bike_id: BikeId, limit: int) -> None:
|
|
550
|
+
"""Set the charge limit in percent, the app offers 70 to 100."""
|
|
551
|
+
await self._request(
|
|
552
|
+
"POST", f"bikes/{bike_id}/charging/limit", params={"maxLimit": limit}
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
async def start_charging(self, bike_id: BikeId) -> None:
|
|
556
|
+
"""Start charging right away instead of waiting for a charging schedule."""
|
|
557
|
+
data = await self._request("POST", f"bikes/{bike_id}/charging/schedules/start")
|
|
558
|
+
|
|
559
|
+
status = to_int(_field(data, "status"))
|
|
560
|
+
if status is not None and status >= 400:
|
|
561
|
+
raise ApiError(
|
|
562
|
+
_error_message(data, "starting to charge failed"), status=status
|
|
563
|
+
)
|
|
564
|
+
|
|
565
|
+
async def get_charging_schedules(self, bike_id: BikeId) -> list[ChargingSchedule]:
|
|
566
|
+
data = await self._request("GET", f"bikes/{bike_id}/charging/schedules")
|
|
567
|
+
return [ChargingSchedule.from_dict(s) for s in _list(data, "schedules")]
|
|
568
|
+
|
|
569
|
+
async def add_charging_schedule(
|
|
570
|
+
self,
|
|
571
|
+
bike_id: BikeId,
|
|
572
|
+
days: Iterable[str],
|
|
573
|
+
start: time,
|
|
574
|
+
end: time,
|
|
575
|
+
*,
|
|
576
|
+
utc_offset_minutes: int | None = None,
|
|
577
|
+
) -> ChargingSchedule:
|
|
578
|
+
"""Add a charging window on the given days ("Mon" to "Sun").
|
|
579
|
+
|
|
580
|
+
start and end are local times at utc_offset_minutes, which defaults to the
|
|
581
|
+
offset of this machine. The window may cross midnight. The app allows up to 5
|
|
582
|
+
schedules.
|
|
583
|
+
"""
|
|
584
|
+
data = await self._request(
|
|
585
|
+
"POST",
|
|
586
|
+
f"bikes/{bike_id}/charging/schedules",
|
|
587
|
+
body=_schedule_body(days, start, end, utc_offset_minutes),
|
|
588
|
+
)
|
|
589
|
+
return ChargingSchedule.from_dict(data if isinstance(data, dict) else {})
|
|
590
|
+
|
|
591
|
+
async def update_charging_schedule(
|
|
592
|
+
self,
|
|
593
|
+
bike_id: BikeId,
|
|
594
|
+
schedule_id: str,
|
|
595
|
+
days: Iterable[str],
|
|
596
|
+
start: time,
|
|
597
|
+
end: time,
|
|
598
|
+
*,
|
|
599
|
+
utc_offset_minutes: int | None = None,
|
|
600
|
+
) -> ChargingSchedule:
|
|
601
|
+
"""Change a charging schedule, see add_charging_schedule().
|
|
602
|
+
|
|
603
|
+
The app disables a schedule by setting no days.
|
|
604
|
+
"""
|
|
605
|
+
data = await self._request(
|
|
606
|
+
"PUT",
|
|
607
|
+
f"bikes/{bike_id}/charging/schedules/{schedule_id}",
|
|
608
|
+
body=_schedule_body(days, start, end, utc_offset_minutes),
|
|
609
|
+
)
|
|
610
|
+
return ChargingSchedule.from_dict(data if isinstance(data, dict) else {})
|
|
611
|
+
|
|
612
|
+
async def delete_charging_schedule(self, bike_id: BikeId, schedule_id: str) -> bool:
|
|
613
|
+
"""Delete a charging schedule, False if the bike has yet to confirm it."""
|
|
614
|
+
status, data = await self._send(
|
|
615
|
+
"DELETE", f"bikes/{bike_id}/charging/schedules/{schedule_id}"
|
|
616
|
+
)
|
|
617
|
+
_raise_for_error(status, data)
|
|
618
|
+
return status == 204
|
|
619
|
+
|
|
620
|
+
# Maintenance and roadside assistance
|
|
621
|
+
|
|
622
|
+
async def get_maintenance_records(self, bike_id: BikeId) -> list[dict[str, Any]]:
|
|
623
|
+
data = await self._request("GET", f"bikes/{bike_id}/maintenance/services")
|
|
624
|
+
return _list(data, "maintenances")
|
|
625
|
+
|
|
626
|
+
async def get_maintenance_schedule(
|
|
627
|
+
self, bike_id: BikeId, model_code: str, model_name: str
|
|
628
|
+
) -> dict[str, Any] | None:
|
|
629
|
+
"""The next service due, for Bike.model_code and Bike.model."""
|
|
630
|
+
data = await self._request(
|
|
631
|
+
"GET",
|
|
632
|
+
f"bikes/{bike_id}/maintenance-schedule",
|
|
633
|
+
params={"modelCode": model_code, "modelName": model_name},
|
|
634
|
+
)
|
|
635
|
+
return _dict(data, "maintenanceDto")
|
|
636
|
+
|
|
637
|
+
async def add_maintenance_record(
|
|
638
|
+
self, bike_id: BikeId, record: dict[str, Any]
|
|
639
|
+
) -> list[dict[str, Any]]:
|
|
640
|
+
"""Add a service record and return all records.
|
|
641
|
+
|
|
642
|
+
Keys: description, modelCode, modelName, odometerReadingOnService (miles),
|
|
643
|
+
nextServiceMiles and servicedDate as "dd-mm-yyyy".
|
|
644
|
+
"""
|
|
645
|
+
data = await self._request(
|
|
646
|
+
"POST",
|
|
647
|
+
f"bikes/{bike_id}/maintenance/service",
|
|
648
|
+
body=record,
|
|
649
|
+
ok_codes=_MAINTENANCE_OK,
|
|
650
|
+
)
|
|
651
|
+
return _list(data, "maintenances")
|
|
652
|
+
|
|
653
|
+
async def update_maintenance_record(
|
|
654
|
+
self, record_id: int, record: dict[str, Any]
|
|
655
|
+
) -> list[dict[str, Any]]:
|
|
656
|
+
"""Change a service record, see add_maintenance_record()."""
|
|
657
|
+
data = await self._request(
|
|
658
|
+
"PUT",
|
|
659
|
+
f"bikes/maintenance/{record_id}",
|
|
660
|
+
body=record,
|
|
661
|
+
ok_codes=_MAINTENANCE_OK,
|
|
662
|
+
)
|
|
663
|
+
return _list(data, "maintenances")
|
|
664
|
+
|
|
665
|
+
async def delete_maintenance_record(self, record_id: int) -> None:
|
|
666
|
+
await self._request(
|
|
667
|
+
"DELETE", f"bikes/maintenance/{record_id}", ok_codes=_MAINTENANCE_OK
|
|
668
|
+
)
|
|
669
|
+
|
|
670
|
+
async def get_roadside_assistance(self, bike_id: BikeId) -> dict[str, Any] | None:
|
|
671
|
+
"""The roadside assistance policy stored for a bike."""
|
|
672
|
+
return _dict(
|
|
673
|
+
await self._request("GET", f"bikes/{bike_id}/road-side-assistance"),
|
|
674
|
+
"roadAssistance",
|
|
675
|
+
)
|
|
676
|
+
|
|
677
|
+
async def add_roadside_assistance(
|
|
678
|
+
self,
|
|
679
|
+
bike_id: BikeId,
|
|
680
|
+
*,
|
|
681
|
+
vin: str,
|
|
682
|
+
company_name: str,
|
|
683
|
+
contact_number: str,
|
|
684
|
+
policy_number: str,
|
|
685
|
+
) -> dict[str, Any] | None:
|
|
686
|
+
body = {
|
|
687
|
+
"vin": vin,
|
|
688
|
+
"companyName": company_name,
|
|
689
|
+
"contactNumber": contact_number,
|
|
690
|
+
"policyNumber": policy_number,
|
|
691
|
+
}
|
|
692
|
+
return _dict(
|
|
693
|
+
await self._request(
|
|
694
|
+
"POST", f"bikes/{bike_id}/road-side-assistance", body=body
|
|
695
|
+
),
|
|
696
|
+
"roadAssistance",
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
async def update_roadside_assistance(
|
|
700
|
+
self,
|
|
701
|
+
record_id: int,
|
|
702
|
+
*,
|
|
703
|
+
vin: str,
|
|
704
|
+
company_name: str,
|
|
705
|
+
contact_number: str,
|
|
706
|
+
policy_number: str,
|
|
707
|
+
) -> dict[str, Any] | None:
|
|
708
|
+
body = {
|
|
709
|
+
"vin": vin,
|
|
710
|
+
"companyName": company_name,
|
|
711
|
+
"contactNumber": contact_number,
|
|
712
|
+
"policyNumber": policy_number,
|
|
713
|
+
}
|
|
714
|
+
return _dict(
|
|
715
|
+
await self._request(
|
|
716
|
+
"PUT", f"bikes/road-side-assistance/{record_id}", body=body
|
|
717
|
+
),
|
|
718
|
+
"roadAssistance",
|
|
719
|
+
)
|
|
720
|
+
|
|
721
|
+
async def delete_roadside_assistance(self, record_id: int) -> None:
|
|
722
|
+
await self._request("DELETE", f"bikes/road-side-assistance/{record_id}")
|
|
723
|
+
|
|
724
|
+
# Rides
|
|
725
|
+
|
|
726
|
+
async def get_rides(
|
|
727
|
+
self,
|
|
728
|
+
latitude: float,
|
|
729
|
+
longitude: float,
|
|
730
|
+
*,
|
|
731
|
+
radius_miles: float | None = None,
|
|
732
|
+
limit: int | None = None,
|
|
733
|
+
sub_types: Iterable[str] = (),
|
|
734
|
+
tags: Iterable[str] = (),
|
|
735
|
+
include_location_history: bool = False,
|
|
736
|
+
include_bookmarked: bool = True,
|
|
737
|
+
) -> list[dict[str, Any]]:
|
|
738
|
+
"""Curated rides around a position.
|
|
739
|
+
|
|
740
|
+
sub_types are HD or DEALER. tags are "tag=<name>" strings, the app knows the
|
|
741
|
+
names eaglerider, short-rides, day-rides, overnight-rides, lets-ride,
|
|
742
|
+
sturgis-rides, milwaukee-rides and daytona-rides.
|
|
743
|
+
"""
|
|
744
|
+
data = await self._request(
|
|
745
|
+
"GET",
|
|
746
|
+
"rides",
|
|
747
|
+
params={
|
|
748
|
+
"lat": latitude,
|
|
749
|
+
"lon": longitude,
|
|
750
|
+
"radiusInMiles": radius_miles,
|
|
751
|
+
"limit": limit,
|
|
752
|
+
"type": "CURATED",
|
|
753
|
+
"subType": list(sub_types),
|
|
754
|
+
"tag": list(tags),
|
|
755
|
+
"includeLocationHistory": include_location_history,
|
|
756
|
+
"includeBookmarked": include_bookmarked,
|
|
757
|
+
},
|
|
758
|
+
)
|
|
759
|
+
return _list(data, "rides")
|
|
760
|
+
|
|
761
|
+
async def get_my_rides(self, units: str = "metric") -> list[dict[str, Any]]:
|
|
762
|
+
"""Rides the user recorded or planned.
|
|
763
|
+
|
|
764
|
+
units is "metric" or "imperial", for the weather data.
|
|
765
|
+
"""
|
|
766
|
+
data = await self._request(
|
|
767
|
+
"GET", "rides", params={"type": "CUSTOM", "includeWeather": units}
|
|
768
|
+
)
|
|
769
|
+
return _list(data, "rides")
|
|
770
|
+
|
|
771
|
+
async def get_ride(
|
|
772
|
+
self, ride_id: str, include_location_history: bool = False
|
|
773
|
+
) -> dict[str, Any] | None:
|
|
774
|
+
data = await self._request(
|
|
775
|
+
"GET",
|
|
776
|
+
f"rides/{ride_id}",
|
|
777
|
+
params={"includeLocationHistory": include_location_history},
|
|
778
|
+
)
|
|
779
|
+
return _dict(data, "ride")
|
|
780
|
+
|
|
781
|
+
async def get_shared_ride(
|
|
782
|
+
self,
|
|
783
|
+
short_id: str,
|
|
784
|
+
*,
|
|
785
|
+
include_location_history: bool = True,
|
|
786
|
+
units: str = "metric",
|
|
787
|
+
) -> dict[str, Any] | None:
|
|
788
|
+
"""A ride by the short id of a share link."""
|
|
789
|
+
data = await self._request(
|
|
790
|
+
"GET",
|
|
791
|
+
f"rides/byShortId/{short_id}",
|
|
792
|
+
params={
|
|
793
|
+
"includeLocationHistory": include_location_history,
|
|
794
|
+
"includeWeather": units,
|
|
795
|
+
},
|
|
796
|
+
)
|
|
797
|
+
return _dict(data, "ride")
|
|
798
|
+
|
|
799
|
+
async def save_ride(
|
|
800
|
+
self,
|
|
801
|
+
ride: dict[str, Any],
|
|
802
|
+
*,
|
|
803
|
+
bookmark: bool = False,
|
|
804
|
+
imported: bool = False,
|
|
805
|
+
dont_save: bool = False,
|
|
806
|
+
) -> dict[str, Any] | None:
|
|
807
|
+
"""Save a new ride.
|
|
808
|
+
|
|
809
|
+
ride is shaped like the app's RidePostRequest: name, description, type
|
|
810
|
+
("CUSTOM"), subType ("RECORDED" or "PLANNED"), waypoints, locationHistory,
|
|
811
|
+
rideAvoidances and so on, with dates as "yyyy-mm-ddThh:mm:ss+0000". Returns
|
|
812
|
+
the ride and record summaries.
|
|
813
|
+
"""
|
|
814
|
+
data = await self._request(
|
|
815
|
+
"POST",
|
|
816
|
+
"ride",
|
|
817
|
+
params={
|
|
818
|
+
"dontSave": dont_save,
|
|
819
|
+
"imported": imported or None,
|
|
820
|
+
"bookmarkStatus": bookmark,
|
|
821
|
+
},
|
|
822
|
+
# the backend expects the ride as JSON encoded string
|
|
823
|
+
body={"ride": json.dumps(ride)},
|
|
824
|
+
)
|
|
825
|
+
return _dict(data, "data")
|
|
826
|
+
|
|
827
|
+
async def update_ride(
|
|
828
|
+
self, ride_id: str, ride: dict[str, Any]
|
|
829
|
+
) -> dict[str, Any] | None:
|
|
830
|
+
"""Replace a ride with a complete ride object, e.g. changed from get_ride()."""
|
|
831
|
+
# the app sends the ride as JSON in the query string
|
|
832
|
+
data = await self._request(
|
|
833
|
+
"PUT", f"ride/{ride_id}", params={"ride": json.dumps(ride)}
|
|
834
|
+
)
|
|
835
|
+
return _dict(data, "data", "ride")
|
|
836
|
+
|
|
837
|
+
async def rename_ride(
|
|
838
|
+
self, ride_id: str, name: str, description: str = ""
|
|
839
|
+
) -> dict[str, Any] | None:
|
|
840
|
+
data = await self._request(
|
|
841
|
+
"PUT",
|
|
842
|
+
"update/ride",
|
|
843
|
+
params={
|
|
844
|
+
"rideString": json.dumps(
|
|
845
|
+
{"id": ride_id, "name": name, "description": description}
|
|
846
|
+
),
|
|
847
|
+
},
|
|
848
|
+
)
|
|
849
|
+
return _dict(data, "data", "ride")
|
|
850
|
+
|
|
851
|
+
async def delete_ride(self, ride_id: str) -> None:
|
|
852
|
+
await self._request("DELETE", f"ride/{ride_id}")
|
|
853
|
+
|
|
854
|
+
async def convert_recorded_ride(
|
|
855
|
+
self, ride_id: str, max_waypoints: int = 100
|
|
856
|
+
) -> dict[str, Any] | None:
|
|
857
|
+
"""Turn a recorded on-road ride into a route that can be navigated."""
|
|
858
|
+
data = await self._request(
|
|
859
|
+
"POST",
|
|
860
|
+
f"rides/record/convert/{ride_id}",
|
|
861
|
+
params={"maxWaypoints": max_waypoints},
|
|
862
|
+
)
|
|
863
|
+
return _dict(data, "data")
|
|
864
|
+
|
|
865
|
+
async def get_bookmarked_rides(self) -> list[dict[str, Any]]:
|
|
866
|
+
"""Bookmarks with id, rideId and the ride."""
|
|
867
|
+
return _list(await self._request("GET", "rides/bookmarks"), "rideBookmarks")
|
|
868
|
+
|
|
869
|
+
async def bookmark_ride(self, ride_id: str) -> dict[str, Any] | None:
|
|
870
|
+
data = await self._request("POST", "rides/bookmark", body={"rideId": ride_id})
|
|
871
|
+
return _dict(data, "data", "rideBookmark")
|
|
872
|
+
|
|
873
|
+
async def remove_bookmark(self, bookmark_id: str) -> None:
|
|
874
|
+
"""Remove a bookmark by its id, which is not the ride id."""
|
|
875
|
+
await self._request("DELETE", f"rides/bookmark/{bookmark_id}")
|
|
876
|
+
|
|
877
|
+
# Dealers
|
|
878
|
+
|
|
879
|
+
async def get_dealers(
|
|
880
|
+
self,
|
|
881
|
+
latitude: float,
|
|
882
|
+
longitude: float,
|
|
883
|
+
*,
|
|
884
|
+
radius_miles: float | None = 3000,
|
|
885
|
+
name: str | None = None,
|
|
886
|
+
max_results: int | None = None,
|
|
887
|
+
model_code: str | None = None,
|
|
888
|
+
service_only: bool = False,
|
|
889
|
+
) -> list[dict[str, Any]]:
|
|
890
|
+
"""Dealers around a position, optionally by name or only those with service."""
|
|
891
|
+
data = await self._request(
|
|
892
|
+
"GET",
|
|
893
|
+
"dealers/providesService" if service_only else "dealers",
|
|
894
|
+
params={
|
|
895
|
+
"name": name,
|
|
896
|
+
"lat": latitude,
|
|
897
|
+
"lon": longitude,
|
|
898
|
+
"radiusInMiles": radius_miles,
|
|
899
|
+
"maxResults": max_results,
|
|
900
|
+
"modelCode": model_code,
|
|
901
|
+
},
|
|
902
|
+
)
|
|
903
|
+
return _list(data, "dealers")
|
|
904
|
+
|
|
905
|
+
# Account and app information
|
|
906
|
+
|
|
907
|
+
async def get_notification_settings(self) -> list[dict[str, Any]]:
|
|
908
|
+
return _list(await self._request("GET", "setting"), "notificationConfig")
|
|
909
|
+
|
|
910
|
+
async def update_notification_setting(
|
|
911
|
+
self, setting: dict[str, Any]
|
|
912
|
+
) -> dict[str, Any] | None:
|
|
913
|
+
"""Change an entry of get_notification_settings().
|
|
914
|
+
|
|
915
|
+
Set its status to 1 (on) or 0 (off).
|
|
916
|
+
"""
|
|
917
|
+
return _dict(await self._request("PUT", "setting", body=setting))
|
|
918
|
+
|
|
919
|
+
async def set_language(self, language: str, country: str) -> None:
|
|
920
|
+
"""Set the account's app language, e.g. "en-US", and country, e.g. "US"."""
|
|
921
|
+
data = await self._request(
|
|
922
|
+
"PUT",
|
|
923
|
+
"update/language",
|
|
924
|
+
body={"appCountry": country, "appLanguage": language},
|
|
925
|
+
)
|
|
926
|
+
|
|
927
|
+
result = _field(data, "data", "responseType")
|
|
928
|
+
if result not in (None, "SUCCESS"):
|
|
929
|
+
raise ApiError(f"setting the language failed: {result}")
|
|
930
|
+
|
|
931
|
+
async def get_external_urls(
|
|
932
|
+
self, language: str | None = None
|
|
933
|
+
) -> list[dict[str, Any]]:
|
|
934
|
+
"""Links like owner manuals per model, with urlId such as "ownerManual_S2MH"."""
|
|
935
|
+
data = await self._request(
|
|
936
|
+
"GET", f"externalUrl/getByLang/{language or self._language}"
|
|
937
|
+
)
|
|
938
|
+
return _list(data, "response")
|
|
939
|
+
|
|
940
|
+
async def get_app_config(self) -> dict[str, Any] | None:
|
|
941
|
+
return _dict(await self._request("GET", "appConfig"), "config")
|
|
942
|
+
|
|
943
|
+
async def get_minimum_app_version(self) -> dict[str, Any] | None:
|
|
944
|
+
return _dict(await self._request("GET", "versions"), "data")
|
|
945
|
+
|
|
946
|
+
async def get_release_notes(
|
|
947
|
+
self, app_version: str = const.APP_VERSION
|
|
948
|
+
) -> list[dict[str, Any]]:
|
|
949
|
+
"""Release notes of an app version."""
|
|
950
|
+
data = await self._request(
|
|
951
|
+
"GET", f"appReleaseNote/{app_version}", params={"brandName": "S2"}
|
|
952
|
+
)
|
|
953
|
+
return _list(data, "response")
|
|
954
|
+
|
|
955
|
+
async def get_countries(self, language: str | None = None) -> dict[str, Any] | None:
|
|
956
|
+
"""Countries with emergency numbers and supported languages, needs no login."""
|
|
957
|
+
return _dict(
|
|
958
|
+
await self._request(
|
|
959
|
+
"GET", f"countries/language/{language or self._language}", authed=False
|
|
960
|
+
)
|
|
961
|
+
)
|
|
962
|
+
|
|
963
|
+
async def delete_account(self) -> None:
|
|
964
|
+
"""Delete the LiveWire account. This cannot be undone."""
|
|
965
|
+
data = await self._request("DELETE", "deleteUser")
|
|
966
|
+
|
|
967
|
+
code = to_int(_field(data, "code"))
|
|
968
|
+
if code not in (None, 200):
|
|
969
|
+
raise ApiError(
|
|
970
|
+
_error_message(data, "deleting the account failed"), code=str(code)
|
|
971
|
+
)
|