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/const.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"""Constants of the LiveWire mobile API, as used by the LiveWire EV app 1.9.0."""
|
|
2
|
+
|
|
3
|
+
API_URL = "https://mobileapi.livewire.com/api"
|
|
4
|
+
GIGYA_URL = "https://accounts.us1.gigya.com"
|
|
5
|
+
|
|
6
|
+
# The app logs in with the global Gigya key and data center, regardless of the
|
|
7
|
+
# user's region
|
|
8
|
+
GIGYA_API_KEY = "4_6aX8cf8RFVt6F3JQQZaF3A"
|
|
9
|
+
DATA_CENTER = "us1"
|
|
10
|
+
|
|
11
|
+
BRAND = "LiveWire"
|
|
12
|
+
APP_VERSION = "1.9.0"
|
|
13
|
+
|
|
14
|
+
# What the app reports about the phone: "<manufacturer> <model>" and the
|
|
15
|
+
# Android SDK level
|
|
16
|
+
DEVICE_MODEL = "Google Pixel 8"
|
|
17
|
+
OS_VERSION = "34"
|
|
18
|
+
LANGUAGE = "en-US"
|
|
19
|
+
|
|
20
|
+
KM_PER_MILE = 1.609344
|
|
21
|
+
|
|
22
|
+
# Values of error.code in the response envelope
|
|
23
|
+
CODE_OK = "0"
|
|
24
|
+
CODE_PARTIAL = "-300" # the app still shows the charging data that comes with it
|
|
25
|
+
CODE_NOT_PAIRED = "3000"
|
|
26
|
+
CODE_MAINTENANCE_OK = "5101"
|
|
27
|
+
|
|
28
|
+
# Values of the top-level "code" when verifying a pairing code
|
|
29
|
+
CODE_ATTEMPTS_EXCEEDED = 4003
|
|
30
|
+
CODE_WRONG_CODE = 4004
|
|
31
|
+
|
|
32
|
+
# Top-level "code" of the tire pressure response when the bike reported none
|
|
33
|
+
CODE_NO_DATA = 3000
|
|
34
|
+
|
|
35
|
+
# Charging schedules use English short day names
|
|
36
|
+
DAYS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
class LivewireError(Exception):
|
|
2
|
+
"""Base class of all errors raised by this library."""
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class AuthenticationError(LivewireError):
|
|
6
|
+
"""Logging in failed."""
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ApiError(LivewireError):
|
|
10
|
+
"""The backend answered with an error, as HTTP status or as error envelope."""
|
|
11
|
+
|
|
12
|
+
def __init__(
|
|
13
|
+
self, message: str, *, code: str | None = None, status: int | None = None
|
|
14
|
+
):
|
|
15
|
+
super().__init__(message)
|
|
16
|
+
self.code = code
|
|
17
|
+
self.status = status
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class NotPairedError(ApiError):
|
|
21
|
+
"""The bike did not answer for this device UUID (error code 3000).
|
|
22
|
+
|
|
23
|
+
The backend reports this when the device UUID is not paired with the bike,
|
|
24
|
+
and also when it cannot reach the bike at all.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class PairingError(LivewireError):
|
|
29
|
+
"""Requesting or verifying a pairing code failed."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, message: str, *, code: int | None = None):
|
|
32
|
+
super().__init__(message)
|
|
33
|
+
self.code = code
|
py_livewire/models.py
ADDED
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
from dataclasses import dataclass, field
|
|
2
|
+
from datetime import datetime, time, timedelta
|
|
3
|
+
from typing import Any
|
|
4
|
+
|
|
5
|
+
from .const import KM_PER_MILE
|
|
6
|
+
from .utils import to_bool, to_datetime, to_float, to_int, to_timedelta
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _str(value: Any) -> str | None:
|
|
10
|
+
return None if value is None else str(value)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _miles_to_km(value: float | None) -> float | None:
|
|
14
|
+
return None if value is None else value * KM_PER_MILE
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@dataclass
|
|
18
|
+
class Bike:
|
|
19
|
+
id: str
|
|
20
|
+
vin: str | None
|
|
21
|
+
name: str | None
|
|
22
|
+
make: str | None
|
|
23
|
+
model: str | None
|
|
24
|
+
model_code: str | None
|
|
25
|
+
model_number: str | None
|
|
26
|
+
year: int | None
|
|
27
|
+
color: str | None
|
|
28
|
+
created: datetime | None
|
|
29
|
+
primary: bool
|
|
30
|
+
# Whether the client's device UUID is paired with the bike, None if the
|
|
31
|
+
# response did not tell
|
|
32
|
+
paired: bool | None
|
|
33
|
+
raw: dict[str, Any] = field(repr=False)
|
|
34
|
+
|
|
35
|
+
@classmethod
|
|
36
|
+
def from_dict(cls, data: dict[str, Any]) -> "Bike":
|
|
37
|
+
return cls(
|
|
38
|
+
id=str(data["id"]),
|
|
39
|
+
vin=data.get("vin"),
|
|
40
|
+
name=data.get("name"),
|
|
41
|
+
make=data.get("make"),
|
|
42
|
+
model=data.get("model"),
|
|
43
|
+
model_code=data.get("modelCode"),
|
|
44
|
+
model_number=data.get("modelNumber"),
|
|
45
|
+
year=to_int(data.get("year")),
|
|
46
|
+
color=data.get("color"),
|
|
47
|
+
created=to_datetime(data.get("createdTime")),
|
|
48
|
+
primary=bool(to_bool(data.get("primaryBike"))),
|
|
49
|
+
paired=to_bool(data.get("pairingStatus")),
|
|
50
|
+
raw=data,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class ChargingStatus:
|
|
56
|
+
battery_level: float | None # percent
|
|
57
|
+
charging: bool
|
|
58
|
+
plugged_in: bool
|
|
59
|
+
charge_limit: int | None # percent
|
|
60
|
+
time_to_limit: timedelta | None
|
|
61
|
+
range_miles: float | None
|
|
62
|
+
odometer_miles: float | None
|
|
63
|
+
# How long ago the bike reported this data
|
|
64
|
+
data_age: timedelta | None
|
|
65
|
+
# Enhanced vehicle tracking: SAFE, STOLEN or PENDING
|
|
66
|
+
tracking_status: str | None
|
|
67
|
+
raw: dict[str, Any] = field(repr=False)
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def range_km(self) -> float | None:
|
|
71
|
+
return _miles_to_km(self.range_miles)
|
|
72
|
+
|
|
73
|
+
@property
|
|
74
|
+
def odometer_km(self) -> float | None:
|
|
75
|
+
return _miles_to_km(self.odometer_miles)
|
|
76
|
+
|
|
77
|
+
@classmethod
|
|
78
|
+
def from_dict(cls, data: dict[str, Any]) -> "ChargingStatus":
|
|
79
|
+
status = data.get("bikeChargingData") or {}
|
|
80
|
+
time_to_limit = to_float(status.get("timeToMaxLimit"))
|
|
81
|
+
|
|
82
|
+
return cls(
|
|
83
|
+
battery_level=to_float(status.get("batteryPercentage")),
|
|
84
|
+
charging=bool(to_bool(status.get("chargingStatus"))),
|
|
85
|
+
plugged_in=bool(to_bool(status.get("pluggedIn"))),
|
|
86
|
+
charge_limit=to_int(status.get("maxLimit")) or None,
|
|
87
|
+
time_to_limit=None
|
|
88
|
+
if time_to_limit is None
|
|
89
|
+
else timedelta(minutes=time_to_limit),
|
|
90
|
+
range_miles=to_float(status.get("range")),
|
|
91
|
+
odometer_miles=to_float(status.get("odometer")),
|
|
92
|
+
data_age=to_timedelta(
|
|
93
|
+
status.get("durationElapsed"), status.get("durationUnit")
|
|
94
|
+
),
|
|
95
|
+
tracking_status=data.get("trackingStatus"),
|
|
96
|
+
raw=data,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
@dataclass
|
|
101
|
+
class Location:
|
|
102
|
+
latitude: float | None
|
|
103
|
+
longitude: float | None
|
|
104
|
+
altitude: float | None # meters
|
|
105
|
+
speed_kmh: float | None
|
|
106
|
+
gps_time: datetime | None
|
|
107
|
+
reported_at: datetime | None
|
|
108
|
+
fix_type: int | None
|
|
109
|
+
raw: dict[str, Any] = field(repr=False)
|
|
110
|
+
|
|
111
|
+
@classmethod
|
|
112
|
+
def from_dict(cls, data: dict[str, Any]) -> "Location":
|
|
113
|
+
return cls(
|
|
114
|
+
latitude=to_float(data.get("latitude")),
|
|
115
|
+
longitude=to_float(data.get("longitude")),
|
|
116
|
+
altitude=to_float(data.get("altitude")),
|
|
117
|
+
speed_kmh=to_float(data.get("speed_km")),
|
|
118
|
+
gps_time=to_datetime(data.get("gps_time")),
|
|
119
|
+
reported_at=to_datetime(data.get("tcu_timestamp")),
|
|
120
|
+
fix_type=to_int(data.get("fix_type")),
|
|
121
|
+
raw=data,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
@dataclass
|
|
126
|
+
class TirePressure:
|
|
127
|
+
front: float | None
|
|
128
|
+
rear: float | None
|
|
129
|
+
front_recommended: float | None
|
|
130
|
+
rear_recommended: float | None
|
|
131
|
+
unit: str | None
|
|
132
|
+
front_low: bool
|
|
133
|
+
rear_low: bool
|
|
134
|
+
raw: dict[str, Any] = field(repr=False)
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_dict(cls, data: dict[str, Any]) -> "TirePressure":
|
|
138
|
+
front = data.get("TirePressureFront") or {}
|
|
139
|
+
rear = data.get("TirePressureRear") or {}
|
|
140
|
+
|
|
141
|
+
def value(key: str) -> float | None:
|
|
142
|
+
return to_float((data.get(key) or {}).get("value"))
|
|
143
|
+
|
|
144
|
+
return cls(
|
|
145
|
+
front=to_float(front.get("value")),
|
|
146
|
+
rear=to_float(rear.get("value")),
|
|
147
|
+
# sic, the backend misspells these
|
|
148
|
+
front_recommended=value("RecomendedTirePressureFront"),
|
|
149
|
+
rear_recommended=value("RecomendedTirePressureRear"),
|
|
150
|
+
unit=front.get("UnitOfMeasure") or rear.get("UnitOfMeasure"),
|
|
151
|
+
front_low=bool(to_int(data.get("FrontTireLow"))),
|
|
152
|
+
rear_low=bool(to_int(data.get("RearTireLow"))),
|
|
153
|
+
raw=data,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _to_time(value: Any) -> time | None:
|
|
158
|
+
try:
|
|
159
|
+
return time.fromisoformat(value)
|
|
160
|
+
except (TypeError, ValueError):
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@dataclass
|
|
165
|
+
class ChargingSchedule:
|
|
166
|
+
id: str
|
|
167
|
+
# ACTIVE, INACTIVE or PENDING while the bike has not confirmed a change yet
|
|
168
|
+
status: str | None
|
|
169
|
+
days: list[str]
|
|
170
|
+
# Local time of the offset below, the window may cross midnight
|
|
171
|
+
start: time | None
|
|
172
|
+
end: time | None
|
|
173
|
+
timezone_offset_minutes: int | None
|
|
174
|
+
raw: dict[str, Any] = field(repr=False)
|
|
175
|
+
|
|
176
|
+
@property
|
|
177
|
+
def enabled(self) -> bool:
|
|
178
|
+
return (self.status or "").upper() == "ACTIVE"
|
|
179
|
+
|
|
180
|
+
@classmethod
|
|
181
|
+
def from_dict(cls, data: dict[str, Any]) -> "ChargingSchedule":
|
|
182
|
+
window = data.get("time") or {}
|
|
183
|
+
|
|
184
|
+
return cls(
|
|
185
|
+
id=str(data.get("scheduleId")),
|
|
186
|
+
status=data.get("status"),
|
|
187
|
+
days=list(data.get("days") or []),
|
|
188
|
+
start=_to_time(window.get("start")),
|
|
189
|
+
end=_to_time(window.get("end")),
|
|
190
|
+
timezone_offset_minutes=to_int(data.get("timezoneOffsetMinutes")),
|
|
191
|
+
raw=data,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@dataclass
|
|
196
|
+
class PairedUser:
|
|
197
|
+
user_id: str | None
|
|
198
|
+
device_uuid: str | None
|
|
199
|
+
device_model: str | None
|
|
200
|
+
device_type: str | None
|
|
201
|
+
email: str | None
|
|
202
|
+
first_name: str | None
|
|
203
|
+
last_name: str | None
|
|
204
|
+
paired_since: datetime | None
|
|
205
|
+
ready_to_unpair: bool
|
|
206
|
+
raw: dict[str, Any] = field(repr=False)
|
|
207
|
+
|
|
208
|
+
@classmethod
|
|
209
|
+
def from_dict(cls, data: dict[str, Any]) -> "PairedUser":
|
|
210
|
+
return cls(
|
|
211
|
+
user_id=_str(data.get("userId")),
|
|
212
|
+
device_uuid=data.get("deviceUUID"),
|
|
213
|
+
device_model=data.get("deviceModel"),
|
|
214
|
+
device_type=data.get("deviceType"),
|
|
215
|
+
email=data.get("userEmail"),
|
|
216
|
+
first_name=data.get("userFirstName"),
|
|
217
|
+
last_name=data.get("userLastName"),
|
|
218
|
+
paired_since=to_datetime(data.get("createdTime")),
|
|
219
|
+
ready_to_unpair=bool(to_bool(data.get("readyToUnpair"))),
|
|
220
|
+
raw=data,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass
|
|
225
|
+
class SoftwareVersion:
|
|
226
|
+
bike_id: str
|
|
227
|
+
vin: str | None
|
|
228
|
+
current: str | None
|
|
229
|
+
latest: str | None
|
|
230
|
+
# MATCH, MISMATCH or NEW_FEATURE_AVAILABLE
|
|
231
|
+
status: str | None
|
|
232
|
+
feature_ids: list[str]
|
|
233
|
+
raw: dict[str, Any] = field(repr=False)
|
|
234
|
+
|
|
235
|
+
@classmethod
|
|
236
|
+
def from_dict(cls, data: dict[str, Any]) -> "SoftwareVersion":
|
|
237
|
+
return cls(
|
|
238
|
+
bike_id=str(data.get("bikeId")),
|
|
239
|
+
vin=data.get("vin"),
|
|
240
|
+
current=data.get("currentVsbVersion"),
|
|
241
|
+
latest=data.get("maxAvailableVsbVersion"),
|
|
242
|
+
status=data.get("vsbMatchStatus"),
|
|
243
|
+
feature_ids=list(data.get("featureIds") or []),
|
|
244
|
+
raw=data,
|
|
245
|
+
)
|
py_livewire/py.typed
ADDED
|
File without changes
|
py_livewire/utils.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import math
|
|
3
|
+
import re
|
|
4
|
+
import uuid
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def to_float(value: Any) -> float | None:
|
|
10
|
+
"""Convert a number arriving as JSON number or string, None if unparseable."""
|
|
11
|
+
if value is None or isinstance(value, bool):
|
|
12
|
+
return None
|
|
13
|
+
try:
|
|
14
|
+
res = float(value)
|
|
15
|
+
except (TypeError, ValueError):
|
|
16
|
+
return None
|
|
17
|
+
return res if math.isfinite(res) else None
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def to_int(value: Any) -> int | None:
|
|
21
|
+
res = to_float(value)
|
|
22
|
+
return None if res is None else int(res)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def to_bool(value: Any) -> bool | None:
|
|
26
|
+
if isinstance(value, bool) or value is None:
|
|
27
|
+
return value
|
|
28
|
+
if isinstance(value, str):
|
|
29
|
+
return {"true": True, "false": False}.get(value.strip().lower())
|
|
30
|
+
if isinstance(value, (int, float)):
|
|
31
|
+
return bool(value)
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def to_datetime(value: Any) -> datetime | None:
|
|
36
|
+
if not isinstance(value, str) or not value:
|
|
37
|
+
return None
|
|
38
|
+
try:
|
|
39
|
+
return datetime.fromisoformat(value)
|
|
40
|
+
except ValueError:
|
|
41
|
+
return None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
_DURATION_UNITS = {
|
|
45
|
+
"second": "seconds",
|
|
46
|
+
"minute": "minutes",
|
|
47
|
+
"hour": "hours",
|
|
48
|
+
"day": "days",
|
|
49
|
+
"week": "weeks",
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_timedelta(value: Any, unit: str | None) -> timedelta | None:
|
|
54
|
+
"""Convert a value like ("2", "days") as used by the charging status."""
|
|
55
|
+
amount = to_float(value)
|
|
56
|
+
if amount is None or not unit:
|
|
57
|
+
return None
|
|
58
|
+
name = _DURATION_UNITS.get(unit.strip().lower().rstrip("s"))
|
|
59
|
+
return timedelta(**{name: amount}) if name else None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def uuid_from_android_id(android_id: str) -> str:
|
|
63
|
+
"""Derive the device UUID the Android app uses from an ANDROID_ID.
|
|
64
|
+
|
|
65
|
+
The app computes UUID.nameUUIDFromBytes(ANDROID_ID), i.e. an MD5 based version 3
|
|
66
|
+
UUID without namespace. Since Android 8 the ANDROID_ID differs per app signing key,
|
|
67
|
+
so the value of "adb shell settings get secure android_id" is not the one the app
|
|
68
|
+
sees; with root it is in /data/system/users/0/settings_ssaid.xml. Easier is reading
|
|
69
|
+
the UUID the app logs, see device_uuids_in_log().
|
|
70
|
+
"""
|
|
71
|
+
digest = hashlib.md5(android_id.encode()).digest()
|
|
72
|
+
return str(uuid.UUID(bytes=digest, version=3))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# The app logs its device UUID with this tag whenever it uses it, e.g. on launch
|
|
76
|
+
LOG_TAG = "## UUID ##"
|
|
77
|
+
|
|
78
|
+
_LOGGED_UUID = re.compile(
|
|
79
|
+
re.escape(LOG_TAG)
|
|
80
|
+
+ r".*?\b([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\b",
|
|
81
|
+
re.IGNORECASE,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def device_uuids_in_log(log: str) -> list[str]:
|
|
86
|
+
"""Device UUIDs the app logged in logcat output, in order, without repeats."""
|
|
87
|
+
return list(dict.fromkeys(_LOGGED_UUID.findall(log)))
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: py-livewire
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Async Python client for the LiveWire electric motorcycle cloud API
|
|
5
|
+
Author-email: Igor Novgorodov <igor@novg.net>
|
|
6
|
+
License-Expression: Apache-2.0
|
|
7
|
+
Project-URL: Homepage, https://github.com/hass-livewire/py-livewire
|
|
8
|
+
Project-URL: Issues, https://github.com/hass-livewire/py-livewire/issues
|
|
9
|
+
Keywords: livewire,harley-davidson,motorcycle,electric,ev,api,cloud
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Framework :: AsyncIO
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Natural Language :: English
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
19
|
+
Classifier: Typing :: Typed
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: aiohttp>=3.10
|
|
24
|
+
Provides-Extra: test
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
26
|
+
Provides-Extra: dev
|
|
27
|
+
Requires-Dist: py-livewire[test]; extra == "dev"
|
|
28
|
+
Requires-Dist: ruff>=0.6; extra == "dev"
|
|
29
|
+
Requires-Dist: mypy>=1.10; extra == "dev"
|
|
30
|
+
Requires-Dist: build; extra == "dev"
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# py-livewire
|
|
34
|
+
|
|
35
|
+
Async Python client for the LiveWire motorcycle cloud: the same API the LiveWire EV app
|
|
36
|
+
(Android 1.9.0) uses, covering bikes, pairing, charging status and control, position, tire
|
|
37
|
+
pressure, analytics, charging schedules, maintenance, roadside assistance, rides and dealers.
|
|
38
|
+
|
|
39
|
+
> This project is not affiliated with LiveWire or Harley-Davidson. It uses an undocumented
|
|
40
|
+
> API that may change or stop working at any time.
|
|
41
|
+
|
|
42
|
+
## The device UUID
|
|
43
|
+
|
|
44
|
+
Every session with the backend is opened for a *device UUID*. The app generates it on the
|
|
45
|
+
phone (`UUID.nameUUIDFromBytes(ANDROID_ID)`); it is no secret, just an identifier. The backend
|
|
46
|
+
only answers the **charging status** for device UUIDs that are **paired** with the bike, and
|
|
47
|
+
otherwise returns error code `3000` ("Command manager api failed"), raised as `NotPairedError`.
|
|
48
|
+
In testing, other data like the position, tire pressure and analytics was also served to
|
|
49
|
+
unpaired UUIDs.
|
|
50
|
+
|
|
51
|
+
There are two ways to get a paired UUID:
|
|
52
|
+
|
|
53
|
+
- **Pair a new one** (recommended). Pairing happens through the cloud, no Bluetooth needed:
|
|
54
|
+
the backend makes the bike show a 6-digit code on its display, which is sent back. You have
|
|
55
|
+
to be at the bike once, with the ignition on, not in propulsion mode, not charging and with
|
|
56
|
+
cellular reception. `livewire pair` does this and prints the UUID to keep.
|
|
57
|
+
- **Reuse an already paired UUID**, e.g. the one of your phone, see below.
|
|
58
|
+
|
|
59
|
+
A paired UUID keeps working across logins. A new UUID needs pairing again.
|
|
60
|
+
|
|
61
|
+
### Finding your phone's UUID
|
|
62
|
+
|
|
63
|
+
The app doesn't show the UUID anywhere, but it writes it to the Android system log (logcat)
|
|
64
|
+
with the tag `## UUID ##` whenever it uses it, including on launch. The log can be read without
|
|
65
|
+
root:
|
|
66
|
+
|
|
67
|
+
- **From a computer:** enable USB (or wireless) debugging on the phone, connect it, and run
|
|
68
|
+
`livewire phone-uuid`, then open the LiveWire app. This needs `adb` from the
|
|
69
|
+
Android platform tools. By hand, `adb logcat -v tag | grep "## UUID ##"` shows the same.
|
|
70
|
+
- **On the phone alone** (Android 11+): enable wireless debugging and run `adb logcat` from a
|
|
71
|
+
local adb client such as LADB or `adb` in Termux, or use a log viewer app after granting it
|
|
72
|
+
access once with `adb shell pm grant <app package> android.permission.READ_LOGS`.
|
|
73
|
+
|
|
74
|
+
With root, the UUID is in `/data/data/com.livewire.arrow/shared_prefs/locale.xml`, key
|
|
75
|
+
`device_uuid`. The UUID survives reinstalling the app, but changes with a factory reset.
|
|
76
|
+
|
|
77
|
+
Other routes don't work. Another app can't compute the UUID, because the device ID it is
|
|
78
|
+
derived from differs per app since Android 8. The app is excluded from backups, and it trusts
|
|
79
|
+
no user-installed certificates, so a TLS proxy needs root. Also, the backend's list of paired
|
|
80
|
+
devices answers HTTP 401.
|
|
81
|
+
|
|
82
|
+
Some phone makers' builds drop the debug log messages this relies on.
|
|
83
|
+
|
|
84
|
+
## Installation
|
|
85
|
+
|
|
86
|
+
Python 3.11 or newer.
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
pip install py-livewire
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
This also installs the `livewire` command line tool (or run `python -m py_livewire`).
|
|
93
|
+
|
|
94
|
+
## Command line
|
|
95
|
+
|
|
96
|
+
Credentials come from `LW_EMAIL` and `LW_PASSWORD` (prompted for otherwise), the device UUID
|
|
97
|
+
from `LW_DEVICE_UUID` or `--device-uuid`.
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
livewire pair # once, at the bike: pairs a new UUID and prints it
|
|
101
|
+
export LW_DEVICE_UUID=<the printed UUID>
|
|
102
|
+
livewire status # battery, charging, range, odometer, position
|
|
103
|
+
livewire bikes # bikes of the account and their pairing state
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
With several bikes on the account, choose one with `--bike <id or VIN>`.
|
|
107
|
+
|
|
108
|
+
## Library
|
|
109
|
+
|
|
110
|
+
```python
|
|
111
|
+
import asyncio
|
|
112
|
+
|
|
113
|
+
from py_livewire import Client
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
async def main():
|
|
117
|
+
async with Client("user@example.org", "password", device_uuid="…") as client:
|
|
118
|
+
bike = (await client.get_bikes())[0]
|
|
119
|
+
|
|
120
|
+
status = await client.get_charging_status(bike.id)
|
|
121
|
+
print(status.battery_level, status.range_km, status.charging)
|
|
122
|
+
|
|
123
|
+
location = await client.get_location(bike.id)
|
|
124
|
+
print(location.latitude, location.longitude)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
asyncio.run(main())
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
Pairing from code: `await client.pair(bike.id, get_code)`, where `get_code` is a plain or
|
|
131
|
+
coroutine function returning the code from the bike's display. Afterwards `client.device_uuid`
|
|
132
|
+
is the UUID to keep.
|
|
133
|
+
|
|
134
|
+
`Client` logs in on the first request, and logs in again once if the backend rejects the
|
|
135
|
+
session token. Pass `session=` to use your own `aiohttp.ClientSession`.
|
|
136
|
+
|
|
137
|
+
### Methods
|
|
138
|
+
|
|
139
|
+
| Area | Methods |
|
|
140
|
+
|---|---|
|
|
141
|
+
| Bikes | `get_bikes`, `get_vin_info`, `add_bike`, `update_bike`, `delete_bike` |
|
|
142
|
+
| Pairing | `is_paired`, `pair`, `request_pairing_code`, `verify_pairing_code`, `get_paired_users`, `request_unpairing_code`, `verify_unpairing_code` |
|
|
143
|
+
| Vehicle data | `get_charging_status`, `get_location`, `get_tire_pressure`, `get_software_versions`, `get_software_release_notes`, `get_charging_analytics`, `get_ride_analytics` |
|
|
144
|
+
| Charging | `set_charge_limit`, `start_charging`, `get_charging_schedules`, `add_charging_schedule`, `update_charging_schedule`, `delete_charging_schedule` |
|
|
145
|
+
| Tracking | `set_enhanced_tracking`, `set_tracking_consent` |
|
|
146
|
+
| Maintenance | `get_maintenance_records`, `get_maintenance_schedule`, `add/update/delete_maintenance_record`, `get/add/update/delete_roadside_assistance` |
|
|
147
|
+
| Rides | `get_rides`, `get_my_rides`, `get_ride`, `get_shared_ride`, `save_ride`, `update_ride`, `rename_ride`, `delete_ride`, `convert_recorded_ride`, `get_bookmarked_rides`, `bookmark_ride`, `remove_bookmark` |
|
|
148
|
+
| Dealers | `get_dealers` |
|
|
149
|
+
| Account and app | `get_notification_settings`, `update_notification_setting`, `set_language`, `get_external_urls`, `get_app_config`, `get_minimum_app_version`, `get_release_notes`, `get_countries`, `delete_account` |
|
|
150
|
+
|
|
151
|
+
Vehicle data comes as dataclasses (`Bike`, `ChargingStatus`, `Location`, `TirePressure`,
|
|
152
|
+
`ChargingSchedule`, `PairedUser`, `SoftwareVersion`), each with the full response in `raw`.
|
|
153
|
+
Range and odometer arrive in miles; `ChargingStatus` also offers them in km. The rest returns
|
|
154
|
+
the parsed JSON.
|
|
155
|
+
|
|
156
|
+
Errors are `AuthenticationError`, `ApiError` (with `code` and `status`), its subclass
|
|
157
|
+
`NotPairedError`, and `PairingError`, all derived from `LivewireError`.
|
|
158
|
+
|
|
159
|
+
### Backend quirks
|
|
160
|
+
|
|
161
|
+
- Errors mostly arrive with HTTP 200 as `{"error": {"code": …, "description": …}}`.
|
|
162
|
+
- Numbers like the battery level arrive as JSON numbers or strings; both are handled.
|
|
163
|
+
- `get_paired_users` was answered with HTTP 401 in testing, even for a paired UUID.
|
|
164
|
+
- There is no working endpoint to cancel a pairing request; the app's is broken. An unused code
|
|
165
|
+
expires.
|
|
166
|
+
- The app's endpoints for ride tags and the home screen battery status no longer exist on the
|
|
167
|
+
backend and are left out.
|
|
168
|
+
|
|
169
|
+
## Development
|
|
170
|
+
|
|
171
|
+
```sh
|
|
172
|
+
python -m venv .venv && . .venv/bin/activate
|
|
173
|
+
pip install -e ".[dev]"
|
|
174
|
+
ruff check . && ruff format --check . && mypy && pytest
|
|
175
|
+
```
|
|
176
|
+
|
|
177
|
+
The tests run against a local fake backend and need no account.
|
|
178
|
+
|
|
179
|
+
To release, bump `__version__` in `py_livewire/__init__.py` and push a `v*` tag (or
|
|
180
|
+
publish a GitHub release); the PyPI Release workflow tests, builds and uploads it.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
py_livewire/__init__.py,sha256=_2MM92LJ53Es1MTifh5p_bN_-8MXt_DmLzHZH0TV934,731
|
|
2
|
+
py_livewire/__main__.py,sha256=PCX2FOCmvd5pI59rpBnS8s0RYcHHIx98eMfBGAVldyo,8479
|
|
3
|
+
py_livewire/auth.py,sha256=bAZDNeqnic_dYLNuENx-NKdVUPj2potaLnTyt6Z03mg,3897
|
|
4
|
+
py_livewire/client.py,sha256=GwDYJJkNX7p6aqdSgj2vlBu8BN4y4OcBsuwMsrWk8_g,34416
|
|
5
|
+
py_livewire/const.py,sha256=tBFoHZvgkCrYjPY8mfNz6unSDqaFZOI_EFkSf-AnN5Y,1084
|
|
6
|
+
py_livewire/exceptions.py,sha256=wx7usyjPZH5GPpFVZEwGzBmfnpzK0aLlBcud7z8uxEs,940
|
|
7
|
+
py_livewire/models.py,sha256=7ylhhcHAWlwYtKMdVOBJujO0YGbXOEtP2_aqyAJ1fp4,7683
|
|
8
|
+
py_livewire/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
|
+
py_livewire/utils.py,sha256=Z2_5IaR5j0hEeTgoSeOMKlErlWk3Hb7MmkEAS2kw2BQ,2649
|
|
10
|
+
py_livewire-0.1.0.dist-info/licenses/LICENSE,sha256=xx0jnfkXJvxRnG63LTGOxlggYnIysveWIZ6H3PNdCrQ,11357
|
|
11
|
+
py_livewire-0.1.0.dist-info/METADATA,sha256=4lyiKtvlxhTlvGm2QEdA8tZB9AW8s6AO-xfH6Foea1M,8180
|
|
12
|
+
py_livewire-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
13
|
+
py_livewire-0.1.0.dist-info/entry_points.txt,sha256=F-PA9oTW3Mb4cx-dNYs7kJ7gFAJMuGei6ZNAnqwGtVY,55
|
|
14
|
+
py_livewire-0.1.0.dist-info/top_level.txt,sha256=71YccZC1kY0V-PvjIBZaCnGqxyWb35AlmVVIo1h9uTI,12
|
|
15
|
+
py_livewire-0.1.0.dist-info/RECORD,,
|