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/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
"""Async Python client for the LiveWire electric motorcycle cloud API"""
|
|
2
|
+
|
|
3
|
+
from .client import Client
|
|
4
|
+
from .exceptions import (
|
|
5
|
+
ApiError,
|
|
6
|
+
AuthenticationError,
|
|
7
|
+
LivewireError,
|
|
8
|
+
NotPairedError,
|
|
9
|
+
PairingError,
|
|
10
|
+
)
|
|
11
|
+
from .models import (
|
|
12
|
+
Bike,
|
|
13
|
+
ChargingSchedule,
|
|
14
|
+
ChargingStatus,
|
|
15
|
+
Location,
|
|
16
|
+
PairedUser,
|
|
17
|
+
SoftwareVersion,
|
|
18
|
+
TirePressure,
|
|
19
|
+
)
|
|
20
|
+
from .utils import uuid_from_android_id
|
|
21
|
+
|
|
22
|
+
__version__ = "0.1.0"
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
"ApiError",
|
|
26
|
+
"AuthenticationError",
|
|
27
|
+
"Bike",
|
|
28
|
+
"ChargingSchedule",
|
|
29
|
+
"ChargingStatus",
|
|
30
|
+
"Client",
|
|
31
|
+
"LivewireError",
|
|
32
|
+
"Location",
|
|
33
|
+
"NotPairedError",
|
|
34
|
+
"PairedUser",
|
|
35
|
+
"PairingError",
|
|
36
|
+
"SoftwareVersion",
|
|
37
|
+
"TirePressure",
|
|
38
|
+
"uuid_from_android_id",
|
|
39
|
+
]
|
py_livewire/__main__.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""Command line access to the LiveWire API.
|
|
2
|
+
|
|
3
|
+
livewire bikes
|
|
4
|
+
livewire status [--bike ID_OR_VIN]
|
|
5
|
+
livewire pair [--bike ID_OR_VIN]
|
|
6
|
+
livewire phone-uuid [--timeout SECONDS]
|
|
7
|
+
|
|
8
|
+
Credentials are read from LW_EMAIL and LW_PASSWORD, or prompted for. The device UUID is
|
|
9
|
+
read from LW_DEVICE_UUID or --device-uuid; pair generates a new one if none is given.
|
|
10
|
+
phone-uuid reads the UUID the phone app logs, through adb, and needs no credentials.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import asyncio
|
|
15
|
+
import getpass
|
|
16
|
+
import os
|
|
17
|
+
import sys
|
|
18
|
+
import uuid
|
|
19
|
+
from datetime import timedelta
|
|
20
|
+
|
|
21
|
+
from . import Bike, Client, LivewireError, NotPairedError
|
|
22
|
+
from .utils import device_uuids_in_log
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def device_uuid(value: str) -> str:
|
|
26
|
+
uuid.UUID(value)
|
|
27
|
+
return value
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _choose_bike(bikes: list[Bike], selector: str | None) -> Bike:
|
|
31
|
+
if selector:
|
|
32
|
+
for bike in bikes:
|
|
33
|
+
if selector in (bike.id, bike.vin):
|
|
34
|
+
return bike
|
|
35
|
+
sys.exit(f"No bike with id or VIN {selector} on this account.")
|
|
36
|
+
|
|
37
|
+
if not bikes:
|
|
38
|
+
sys.exit("No bikes on this account.")
|
|
39
|
+
if len(bikes) > 1:
|
|
40
|
+
sys.exit("Several bikes on this account, choose one with --bike.")
|
|
41
|
+
return bikes[0]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ago(age: timedelta) -> str:
|
|
45
|
+
n, unit = int(age.total_seconds()), "second"
|
|
46
|
+
for name, size in (("day", 86400), ("hour", 3600), ("minute", 60)):
|
|
47
|
+
if n >= size:
|
|
48
|
+
n, unit = n // size, name
|
|
49
|
+
break
|
|
50
|
+
return f"{n} {unit}{'' if n == 1 else 's'} ago"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _num(value: float | None, unit: str) -> str:
|
|
54
|
+
return "-" if value is None else f"{value:.0f}{unit}"
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _distance(km: float | None, miles: float | None) -> str:
|
|
58
|
+
return f"{_num(km, ' km')} ({_num(miles, ' mi')})"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _describe(bike: Bike) -> str:
|
|
62
|
+
return f'{bike.year} {bike.model} "{bike.name}" (id {bike.id}, VIN {bike.vin})'
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
async def _bikes(client: Client, args: argparse.Namespace) -> None:
|
|
66
|
+
for bike in await client.get_bikes():
|
|
67
|
+
print(f"{_describe(bike)}, {'paired' if bike.paired else 'not paired'}")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
async def _status(client: Client, args: argparse.Namespace) -> None:
|
|
71
|
+
bike = _choose_bike(await client.get_bikes(), args.bike)
|
|
72
|
+
print(_describe(bike))
|
|
73
|
+
|
|
74
|
+
try:
|
|
75
|
+
status = await client.get_charging_status(bike.id)
|
|
76
|
+
except NotPairedError:
|
|
77
|
+
sys.exit(
|
|
78
|
+
"This device UUID is not paired with the bike, run `livewire pair` first."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
if status.charging:
|
|
82
|
+
charging = "yes" + (
|
|
83
|
+
f", {status.time_to_limit} to the limit" if status.time_to_limit else ""
|
|
84
|
+
)
|
|
85
|
+
else:
|
|
86
|
+
charging = "no, " + ("plugged in" if status.plugged_in else "not plugged in")
|
|
87
|
+
|
|
88
|
+
limit = _num(status.charge_limit, "%")
|
|
89
|
+
print(f"Battery: {_num(status.battery_level, '%')} (limit {limit})")
|
|
90
|
+
print(f"Charging: {charging}")
|
|
91
|
+
print(f"Range: {_distance(status.range_km, status.range_miles)}")
|
|
92
|
+
print(f"Odometer: {_distance(status.odometer_km, status.odometer_miles)}")
|
|
93
|
+
print(f"Reported: {_ago(status.data_age) if status.data_age else '-'}")
|
|
94
|
+
print(f"Tracking: {status.tracking_status or '-'}")
|
|
95
|
+
|
|
96
|
+
location = await client.get_location(bike.id)
|
|
97
|
+
if location.latitude is not None:
|
|
98
|
+
position = f"{location.latitude:.5f}, {location.longitude:.5f}"
|
|
99
|
+
print(f"Position: {position} at {location.gps_time or '-'}")
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
async def _pair(client: Client, args: argparse.Namespace) -> None:
|
|
103
|
+
bike = _choose_bike(await client.get_bikes(), args.bike)
|
|
104
|
+
print(_describe(bike))
|
|
105
|
+
|
|
106
|
+
if bike.paired:
|
|
107
|
+
print("This device UUID is already paired with the bike.")
|
|
108
|
+
else:
|
|
109
|
+
print("\nAt the bike, switch the ignition on and wait for the display.")
|
|
110
|
+
print("The bike must not be in propulsion mode or charging, and needs")
|
|
111
|
+
print("cellular reception.")
|
|
112
|
+
await asyncio.to_thread(input, "Press Enter to request a pairing code... ")
|
|
113
|
+
|
|
114
|
+
await client.pair(
|
|
115
|
+
bike.id, lambda: asyncio.to_thread(input, "Code shown on the display: ")
|
|
116
|
+
)
|
|
117
|
+
print("Paired.")
|
|
118
|
+
|
|
119
|
+
print(f"\nDevice UUID: {client.device_uuid}")
|
|
120
|
+
print("Keep it, e.g. as LW_DEVICE_UUID. A different UUID needs pairing again.")
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
async def _adb(*args: str) -> tuple[int, str]:
|
|
124
|
+
proc = await asyncio.create_subprocess_exec(
|
|
125
|
+
"adb",
|
|
126
|
+
*args,
|
|
127
|
+
stdout=asyncio.subprocess.PIPE,
|
|
128
|
+
stderr=asyncio.subprocess.STDOUT,
|
|
129
|
+
)
|
|
130
|
+
out, _ = await proc.communicate()
|
|
131
|
+
return await proc.wait(), out.decode(errors="replace")
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
async def _watch_log(timeout: float) -> list[str]:
|
|
135
|
+
# The tag is matched here rather than with "logcat -s", which would pass it
|
|
136
|
+
# through the phone's shell
|
|
137
|
+
proc = await asyncio.create_subprocess_exec(
|
|
138
|
+
"adb",
|
|
139
|
+
"logcat",
|
|
140
|
+
"-v",
|
|
141
|
+
"tag",
|
|
142
|
+
stdout=asyncio.subprocess.PIPE,
|
|
143
|
+
stderr=asyncio.subprocess.DEVNULL,
|
|
144
|
+
)
|
|
145
|
+
assert proc.stdout is not None # a pipe, as requested
|
|
146
|
+
try:
|
|
147
|
+
async with asyncio.timeout(timeout):
|
|
148
|
+
async for line in proc.stdout:
|
|
149
|
+
if found := device_uuids_in_log(line.decode(errors="replace")):
|
|
150
|
+
return found
|
|
151
|
+
except TimeoutError:
|
|
152
|
+
pass
|
|
153
|
+
finally:
|
|
154
|
+
proc.kill()
|
|
155
|
+
await proc.wait()
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def _phone_uuid(args: argparse.Namespace) -> None:
|
|
160
|
+
try:
|
|
161
|
+
code, state = await _adb("get-state")
|
|
162
|
+
except FileNotFoundError:
|
|
163
|
+
sys.exit("adb not found, install the Android platform tools.")
|
|
164
|
+
if code != 0 or state.strip() != "device":
|
|
165
|
+
sys.exit(
|
|
166
|
+
f"No phone ready for adb ({state.strip() or 'no output'}). Enable USB or "
|
|
167
|
+
"wireless debugging and connect the phone; with several devices, choose "
|
|
168
|
+
"one with ANDROID_SERIAL."
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
_, log = await _adb("logcat", "-d", "-v", "tag")
|
|
172
|
+
found = device_uuids_in_log(log)
|
|
173
|
+
if not found:
|
|
174
|
+
print(
|
|
175
|
+
"Open the LiveWire app on the phone, close it first if it runs. "
|
|
176
|
+
f"Waiting {args.timeout:.0f} s..."
|
|
177
|
+
)
|
|
178
|
+
found = await _watch_log(args.timeout)
|
|
179
|
+
if not found:
|
|
180
|
+
sys.exit(
|
|
181
|
+
"The LiveWire app logged no device UUID. "
|
|
182
|
+
"Some phones drop the app's debug log messages."
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
print(f"Device UUID: {found[-1]}")
|
|
186
|
+
if len(found) > 1:
|
|
187
|
+
print(f"(also logged earlier: {', '.join(found[:-1])})")
|
|
188
|
+
print(f"Check it with: livewire --device-uuid {found[-1]} status")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
async def _run(args: argparse.Namespace) -> None:
|
|
192
|
+
email = os.environ.get("LW_EMAIL")
|
|
193
|
+
if not email:
|
|
194
|
+
email = await asyncio.to_thread(input, "LiveWire account email: ")
|
|
195
|
+
email = email.strip()
|
|
196
|
+
password = os.environ.get("LW_PASSWORD")
|
|
197
|
+
if not password:
|
|
198
|
+
password = await asyncio.to_thread(getpass.getpass, "Password: ")
|
|
199
|
+
|
|
200
|
+
if args.device_uuid is None and args.command != "pair":
|
|
201
|
+
print(
|
|
202
|
+
"No device UUID given (LW_DEVICE_UUID or --device-uuid), "
|
|
203
|
+
"using an unpaired one.",
|
|
204
|
+
file=sys.stderr,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
async with Client(email, password, args.device_uuid) as client:
|
|
208
|
+
await {"bikes": _bikes, "status": _status, "pair": _pair}[args.command](
|
|
209
|
+
client, args
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def main() -> None:
|
|
214
|
+
parser = argparse.ArgumentParser(
|
|
215
|
+
prog="livewire", description="LiveWire motorcycle cloud API"
|
|
216
|
+
)
|
|
217
|
+
parser.add_argument(
|
|
218
|
+
"--device-uuid",
|
|
219
|
+
type=device_uuid,
|
|
220
|
+
default=os.environ.get("LW_DEVICE_UUID"),
|
|
221
|
+
help="device UUID paired with the bike (default: $LW_DEVICE_UUID)",
|
|
222
|
+
)
|
|
223
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
224
|
+
commands.add_parser("bikes", help="list the bikes of the account")
|
|
225
|
+
for name, help in (
|
|
226
|
+
("status", "show charging status and position"),
|
|
227
|
+
("pair", "pair the device UUID with a bike"),
|
|
228
|
+
):
|
|
229
|
+
command = commands.add_parser(name, help=help)
|
|
230
|
+
command.add_argument(
|
|
231
|
+
"--bike", help="id or VIN of the bike, needed with several bikes"
|
|
232
|
+
)
|
|
233
|
+
command = commands.add_parser(
|
|
234
|
+
"phone-uuid", help="read the device UUID the phone app logs, through adb"
|
|
235
|
+
)
|
|
236
|
+
command.add_argument(
|
|
237
|
+
"--timeout",
|
|
238
|
+
type=float,
|
|
239
|
+
default=120,
|
|
240
|
+
help="seconds to wait for the app (default: 120)",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
args = parser.parse_args()
|
|
244
|
+
try:
|
|
245
|
+
asyncio.run(_phone_uuid(args) if args.command == "phone-uuid" else _run(args))
|
|
246
|
+
except LivewireError as e:
|
|
247
|
+
sys.exit(f"Error: {e}")
|
|
248
|
+
except (KeyboardInterrupt, EOFError):
|
|
249
|
+
sys.exit("\nAborted.")
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
if __name__ == "__main__":
|
|
253
|
+
main()
|
py_livewire/auth.py
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import base64
|
|
3
|
+
import json
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import aiohttp
|
|
7
|
+
|
|
8
|
+
from . import const
|
|
9
|
+
from .exceptions import AuthenticationError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def jwt_subject(token: str) -> str | None:
|
|
13
|
+
"""Return the "sub" claim of the session token, which is the backend's user UUID."""
|
|
14
|
+
try:
|
|
15
|
+
payload = token.split(".")[1]
|
|
16
|
+
claims = json.loads(
|
|
17
|
+
base64.urlsafe_b64decode(payload + "=" * (-len(payload) % 4))
|
|
18
|
+
)
|
|
19
|
+
except (IndexError, ValueError):
|
|
20
|
+
return None
|
|
21
|
+
return claims.get("sub") if isinstance(claims, dict) else None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Authenticator:
|
|
25
|
+
"""Logs in to Gigya and exchanges the account UID for a LiveWire session token.
|
|
26
|
+
|
|
27
|
+
The token carries no usable expiry and there is no refresh flow, so it is used until
|
|
28
|
+
the backend rejects it, which triggers a full login again.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
def __init__(
|
|
32
|
+
self,
|
|
33
|
+
email: str,
|
|
34
|
+
password: str,
|
|
35
|
+
device_uuid: str,
|
|
36
|
+
headers: dict[str, str],
|
|
37
|
+
*,
|
|
38
|
+
gigya_url: str,
|
|
39
|
+
api_url: str,
|
|
40
|
+
):
|
|
41
|
+
self._email = email
|
|
42
|
+
self._password = password
|
|
43
|
+
self._device_uuid = device_uuid
|
|
44
|
+
self._headers = headers
|
|
45
|
+
self._gigya_url = gigya_url
|
|
46
|
+
self._api_url = api_url
|
|
47
|
+
|
|
48
|
+
self._token: str | None = None
|
|
49
|
+
self._lock = asyncio.Lock()
|
|
50
|
+
self.terms_accepted: bool | None = None
|
|
51
|
+
|
|
52
|
+
@property
|
|
53
|
+
def token(self) -> str | None:
|
|
54
|
+
return self._token
|
|
55
|
+
|
|
56
|
+
async def get_token(self, session: aiohttp.ClientSession) -> str:
|
|
57
|
+
async with self._lock:
|
|
58
|
+
if self._token is None:
|
|
59
|
+
self._token = await self._login(session)
|
|
60
|
+
return self._token
|
|
61
|
+
|
|
62
|
+
async def refresh(self, session: aiohttp.ClientSession, rejected: str) -> str:
|
|
63
|
+
"""Log in again, unless a concurrent request already replaced the token."""
|
|
64
|
+
async with self._lock:
|
|
65
|
+
if self._token in (None, rejected):
|
|
66
|
+
self._token = await self._login(session)
|
|
67
|
+
return self._token
|
|
68
|
+
|
|
69
|
+
async def _login(self, session: aiohttp.ClientSession) -> str:
|
|
70
|
+
uid = await self._gigya_uid(session)
|
|
71
|
+
return await self._session_token(session, uid)
|
|
72
|
+
|
|
73
|
+
async def _gigya_uid(self, session: aiohttp.ClientSession) -> str:
|
|
74
|
+
# Gigya expects a form and reports errors in a body with HTTP 200
|
|
75
|
+
async with session.post(
|
|
76
|
+
f"{self._gigya_url}/accounts.login",
|
|
77
|
+
data={
|
|
78
|
+
"apiKey": const.GIGYA_API_KEY,
|
|
79
|
+
"loginID": self._email,
|
|
80
|
+
"password": self._password,
|
|
81
|
+
"include": "profile,data",
|
|
82
|
+
"format": "json",
|
|
83
|
+
},
|
|
84
|
+
) as resp:
|
|
85
|
+
res: dict[str, Any] = await resp.json(content_type=None)
|
|
86
|
+
|
|
87
|
+
if res.get("errorCode", 0) != 0:
|
|
88
|
+
raise AuthenticationError(
|
|
89
|
+
f"Gigya login failed: {res.get('errorMessage')} "
|
|
90
|
+
f"({res.get('errorCode')})"
|
|
91
|
+
)
|
|
92
|
+
if not res.get("UID"):
|
|
93
|
+
raise AuthenticationError("Gigya login returned no UID")
|
|
94
|
+
|
|
95
|
+
return str(res["UID"])
|
|
96
|
+
|
|
97
|
+
async def _session_token(self, session: aiohttp.ClientSession, uid: str) -> str:
|
|
98
|
+
async with session.post(
|
|
99
|
+
f"{self._api_url}/session",
|
|
100
|
+
params={"brand": const.BRAND},
|
|
101
|
+
json={
|
|
102
|
+
"UID": uid,
|
|
103
|
+
"deviceUUID": self._device_uuid,
|
|
104
|
+
"dataCenter": const.DATA_CENTER,
|
|
105
|
+
},
|
|
106
|
+
headers=self._headers,
|
|
107
|
+
) as resp:
|
|
108
|
+
status = resp.status
|
|
109
|
+
res = await resp.json(content_type=None)
|
|
110
|
+
|
|
111
|
+
if not isinstance(res, dict):
|
|
112
|
+
res = {}
|
|
113
|
+
if not res.get("jwt"):
|
|
114
|
+
detail = (res.get("error") or {}).get("description") or f"HTTP {status}"
|
|
115
|
+
raise AuthenticationError(f"session request failed: {detail}")
|
|
116
|
+
|
|
117
|
+
self.terms_accepted = res.get("termsAccepted")
|
|
118
|
+
return str(res["jwt"])
|