tescmd 0.1.2__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.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,564 @@
|
|
|
1
|
+
"""Minimal protobuf message definitions for Tesla Vehicle Command Protocol.
|
|
2
|
+
|
|
3
|
+
These are hand-written dataclasses with ``serialize()`` and ``parse()``
|
|
4
|
+
methods that produce wire-compatible bytes using ``google.protobuf``.
|
|
5
|
+
When full ``.proto`` generation is available, these can be replaced by the
|
|
6
|
+
generated ``_pb2`` modules.
|
|
7
|
+
|
|
8
|
+
Wire format reference:
|
|
9
|
+
- Tesla vehicle-command proto definitions
|
|
10
|
+
- https://github.com/teslamotors/vehicle-command
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import contextlib
|
|
16
|
+
import enum
|
|
17
|
+
import struct
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
from google.protobuf import descriptor_pb2, message # noqa: F401
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------------------
|
|
24
|
+
# Domain enum — matches Universal_message.proto → Domain
|
|
25
|
+
# ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class Domain(enum.IntEnum):
|
|
29
|
+
"""Routing domain for RoutableMessage."""
|
|
30
|
+
|
|
31
|
+
DOMAIN_BROADCAST = 0
|
|
32
|
+
DOMAIN_VEHICLE_SECURITY = 2 # VCSEC — lock/unlock/key
|
|
33
|
+
DOMAIN_INFOTAINMENT = 3 # Car-Server — charge/climate/media
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class OperationStatus(enum.IntEnum):
|
|
37
|
+
"""Operation status from MessageStatus (universal_message.proto)."""
|
|
38
|
+
|
|
39
|
+
OPERATIONSTATUS_OK = 0
|
|
40
|
+
OPERATIONSTATUS_WAIT = 1
|
|
41
|
+
OPERATIONSTATUS_ERROR = 2
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MessageFault(enum.IntEnum):
|
|
45
|
+
"""Vehicle command protocol fault codes (universal_message.proto)."""
|
|
46
|
+
|
|
47
|
+
ERROR_NONE = 0
|
|
48
|
+
ERROR_BUSY = 1
|
|
49
|
+
ERROR_TIMEOUT = 2
|
|
50
|
+
ERROR_UNKNOWN_KEY_ID = 3
|
|
51
|
+
ERROR_INACTIVE_KEY = 4
|
|
52
|
+
ERROR_INVALID_SIGNATURE = 5
|
|
53
|
+
ERROR_INVALID_TOKEN_OR_COUNTER = 6
|
|
54
|
+
ERROR_INSUFFICIENT_PRIVILEGES = 7
|
|
55
|
+
ERROR_INVALID_DOMAINS = 8
|
|
56
|
+
ERROR_INVALID_COMMAND = 9
|
|
57
|
+
ERROR_DECODING = 10
|
|
58
|
+
ERROR_INTERNAL = 11
|
|
59
|
+
ERROR_WRONG_PERSONALIZATION = 12
|
|
60
|
+
ERROR_BAD_PARAMETER = 13
|
|
61
|
+
ERROR_KEYCHAIN_IS_FULL = 14
|
|
62
|
+
ERROR_INCORRECT_EPOCH = 15
|
|
63
|
+
ERROR_IV_INCORRECT_LENGTH = 16
|
|
64
|
+
ERROR_TIME_EXPIRED = 17
|
|
65
|
+
ERROR_NOT_PROVISIONED_WITH_IDENTITY = 18
|
|
66
|
+
ERROR_COULD_NOT_HASH_METADATA = 19
|
|
67
|
+
ERROR_TIME_TO_LIVE_TOO_LONG = 20
|
|
68
|
+
ERROR_REMOTE_ACCESS_DISABLED = 21
|
|
69
|
+
ERROR_REMOTE_SERVICE_ACCESS_DISABLED = 22
|
|
70
|
+
ERROR_COMMAND_REQUIRES_ACCOUNT_CREDENTIALS = 23
|
|
71
|
+
ERROR_REQUEST_MTU_EXCEEDED = 24
|
|
72
|
+
ERROR_RESPONSE_MTU_EXCEEDED = 25
|
|
73
|
+
ERROR_REPEATED_COUNTER = 26
|
|
74
|
+
ERROR_INVALID_KEY_HANDLE = 27
|
|
75
|
+
ERROR_REQUIRES_RESPONSE_ENCRYPTION = 28
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# Faults that indicate a transient problem — retry after a short delay.
|
|
79
|
+
TRANSIENT_FAULTS: frozenset[MessageFault] = frozenset(
|
|
80
|
+
{
|
|
81
|
+
MessageFault.ERROR_BUSY,
|
|
82
|
+
MessageFault.ERROR_TIMEOUT,
|
|
83
|
+
MessageFault.ERROR_INTERNAL,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
# Faults that indicate the key is not recognized / enrolled.
|
|
88
|
+
KEY_FAULTS: frozenset[MessageFault] = frozenset(
|
|
89
|
+
{
|
|
90
|
+
MessageFault.ERROR_UNKNOWN_KEY_ID,
|
|
91
|
+
MessageFault.ERROR_INACTIVE_KEY,
|
|
92
|
+
}
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Human-readable descriptions for common faults.
|
|
96
|
+
FAULT_DESCRIPTIONS: dict[MessageFault, str] = {
|
|
97
|
+
MessageFault.ERROR_NONE: "No error",
|
|
98
|
+
MessageFault.ERROR_BUSY: "Vehicle subsystem is busy (try again)",
|
|
99
|
+
MessageFault.ERROR_TIMEOUT: "Vehicle subsystem did not respond (try again)",
|
|
100
|
+
MessageFault.ERROR_UNKNOWN_KEY_ID: (
|
|
101
|
+
"Vehicle does not recognize this key — run 'tescmd key enroll'"
|
|
102
|
+
),
|
|
103
|
+
MessageFault.ERROR_INACTIVE_KEY: "Key has been disabled on this vehicle",
|
|
104
|
+
MessageFault.ERROR_INVALID_SIGNATURE: "Invalid signature — session may be stale",
|
|
105
|
+
MessageFault.ERROR_INVALID_TOKEN_OR_COUNTER: (
|
|
106
|
+
"Anti-replay counter mismatch — session may be stale"
|
|
107
|
+
),
|
|
108
|
+
MessageFault.ERROR_INSUFFICIENT_PRIVILEGES: "Insufficient privileges for this command",
|
|
109
|
+
MessageFault.ERROR_INVALID_DOMAINS: "Command addressed to unrecognized vehicle system",
|
|
110
|
+
MessageFault.ERROR_INVALID_COMMAND: "Unrecognized command",
|
|
111
|
+
MessageFault.ERROR_DECODING: "Vehicle could not parse the command",
|
|
112
|
+
MessageFault.ERROR_INTERNAL: "Internal vehicle error (vehicle may still be booting)",
|
|
113
|
+
MessageFault.ERROR_BAD_PARAMETER: "Invalid command parameter or malformed protobuf",
|
|
114
|
+
MessageFault.ERROR_WRONG_PERSONALIZATION: "Command sent to wrong VIN",
|
|
115
|
+
MessageFault.ERROR_KEYCHAIN_IS_FULL: "Vehicle keychain is full — delete a key first",
|
|
116
|
+
MessageFault.ERROR_INCORRECT_EPOCH: "Session epoch mismatch",
|
|
117
|
+
MessageFault.ERROR_TIME_EXPIRED: "Command expired — clock may be desynchronized",
|
|
118
|
+
MessageFault.ERROR_REMOTE_ACCESS_DISABLED: "Vehicle owner has disabled Mobile Access",
|
|
119
|
+
MessageFault.ERROR_REMOTE_SERVICE_ACCESS_DISABLED: "Remote service commands not permitted",
|
|
120
|
+
MessageFault.ERROR_COMMAND_REQUIRES_ACCOUNT_CREDENTIALS: (
|
|
121
|
+
"Command requires account credentials — use Fleet API"
|
|
122
|
+
),
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# ---------------------------------------------------------------------------
|
|
127
|
+
# Tag constants for manual protobuf encoding
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
|
|
130
|
+
# RoutableMessage field tags (from universal_message.proto)
|
|
131
|
+
# NOTE: fields 1-5, 11, 16-40 are reserved in the proto definition.
|
|
132
|
+
_TAG_TO_DESTINATION = 6 # SubMessage — Destination
|
|
133
|
+
_TAG_FROM_DESTINATION = 7 # SubMessage — Destination
|
|
134
|
+
_TAG_PROTOBUF_MESSAGE_AS_BYTES = 10 # bytes — oneof payload
|
|
135
|
+
_TAG_SIGNED_MESSAGE_STATUS = 12 # SubMessage — MessageStatus
|
|
136
|
+
_TAG_SIGNATURE_DATA = 13 # SubMessage — Signatures.SignatureData
|
|
137
|
+
_TAG_SESSION_INFO_REQUEST = 14 # SubMessage — oneof payload — SessionInfoRequest
|
|
138
|
+
_TAG_SESSION_INFO = 15 # bytes — oneof payload — SessionInfo response
|
|
139
|
+
_TAG_REQUEST_UUID = 50 # bytes
|
|
140
|
+
_TAG_UUID = 51 # bytes
|
|
141
|
+
_TAG_FLAGS = 52 # uint32
|
|
142
|
+
|
|
143
|
+
# Destination field tags
|
|
144
|
+
_TAG_DEST_DOMAIN = 1 # enum → Domain
|
|
145
|
+
_TAG_DEST_ROUTING_ADDRESS = 2 # bytes
|
|
146
|
+
|
|
147
|
+
# SessionInfoRequest field tags
|
|
148
|
+
_TAG_SIR_PUBLIC_KEY = 1 # bytes — 65-byte uncompressed EC point
|
|
149
|
+
|
|
150
|
+
# SignatureData field tags (from signatures.proto)
|
|
151
|
+
_TAG_SD_SIGNER_IDENTITY = 1 # SubMessage — KeyIdentity
|
|
152
|
+
_TAG_SD_SESSION_INFO_TAG = 6 # SubMessage — HMAC_Signature_Data (oneof sig_type)
|
|
153
|
+
_TAG_SD_HMAC_TAG = 8 # SubMessage — HMAC_Personalized_Signature_Data (oneof sig_type)
|
|
154
|
+
|
|
155
|
+
# HMAC_Personalized_Signature_Data field tags (from signatures.proto)
|
|
156
|
+
_TAG_HMAC_EPOCH = 1 # bytes — epoch identifier
|
|
157
|
+
_TAG_HMAC_COUNTER = 2 # uint32 — anti-replay counter
|
|
158
|
+
_TAG_HMAC_EXPIRES_AT = 3 # fixed32 — seconds since epoch
|
|
159
|
+
_TAG_HMAC_TAG = 4 # bytes — HMAC output
|
|
160
|
+
|
|
161
|
+
# KeyIdentity field tags (from signatures.proto — field 2 is reserved)
|
|
162
|
+
_TAG_KI_PUBLIC_KEY = 1 # bytes — 65-byte uncompressed EC point (oneof identity_type)
|
|
163
|
+
|
|
164
|
+
# SessionInfo field tags (from signatures.proto)
|
|
165
|
+
_TAG_SI_COUNTER = 1 # uint32
|
|
166
|
+
_TAG_SI_PUBLIC_KEY = 2 # bytes
|
|
167
|
+
_TAG_SI_EPOCH = 3 # bytes
|
|
168
|
+
_TAG_SI_CLOCK_TIME = 4 # uint32
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
# ---------------------------------------------------------------------------
|
|
172
|
+
# Low-level protobuf encoding helpers
|
|
173
|
+
# ---------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _encode_varint(value: int) -> bytes:
|
|
177
|
+
"""Encode a varint (protobuf base-128)."""
|
|
178
|
+
result = bytearray()
|
|
179
|
+
while value > 0x7F:
|
|
180
|
+
result.append((value & 0x7F) | 0x80)
|
|
181
|
+
value >>= 7
|
|
182
|
+
result.append(value & 0x7F)
|
|
183
|
+
return bytes(result)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _encode_tag(field_number: int, wire_type: int) -> bytes:
|
|
187
|
+
"""Encode a protobuf field tag."""
|
|
188
|
+
return _encode_varint((field_number << 3) | wire_type)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _encode_length_delimited(field_number: int, data: bytes) -> bytes:
|
|
192
|
+
"""Encode a length-delimited field (wire type 2)."""
|
|
193
|
+
tag = _encode_tag(field_number, 2)
|
|
194
|
+
return tag + _encode_varint(len(data)) + data
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _encode_varint_field(field_number: int, value: int) -> bytes:
|
|
198
|
+
"""Encode a varint field (wire type 0)."""
|
|
199
|
+
tag = _encode_tag(field_number, 0)
|
|
200
|
+
return tag + _encode_varint(value)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _encode_fixed32_field(field_number: int, value: int) -> bytes:
|
|
204
|
+
"""Encode a fixed32 field (wire type 5)."""
|
|
205
|
+
tag = _encode_tag(field_number, 5)
|
|
206
|
+
return tag + struct.pack("<I", value)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _decode_varint(data: bytes, pos: int) -> tuple[int, int]:
|
|
210
|
+
"""Decode a varint, returning (value, new_pos)."""
|
|
211
|
+
result = 0
|
|
212
|
+
shift = 0
|
|
213
|
+
while pos < len(data):
|
|
214
|
+
b = data[pos]
|
|
215
|
+
result |= (b & 0x7F) << shift
|
|
216
|
+
pos += 1
|
|
217
|
+
if not (b & 0x80):
|
|
218
|
+
return result, pos
|
|
219
|
+
shift += 7
|
|
220
|
+
raise ValueError("Truncated varint")
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _decode_field(data: bytes, pos: int) -> tuple[int, int, Any, int]:
|
|
224
|
+
"""Decode one protobuf field, returning (field_number, wire_type, value, new_pos)."""
|
|
225
|
+
tag, pos = _decode_varint(data, pos)
|
|
226
|
+
field_number = tag >> 3
|
|
227
|
+
wire_type = tag & 0x07
|
|
228
|
+
|
|
229
|
+
if wire_type == 0: # varint
|
|
230
|
+
value, pos = _decode_varint(data, pos)
|
|
231
|
+
return field_number, wire_type, value, pos
|
|
232
|
+
elif wire_type == 2: # length-delimited
|
|
233
|
+
length, pos = _decode_varint(data, pos)
|
|
234
|
+
value = data[pos : pos + length]
|
|
235
|
+
return field_number, wire_type, value, pos + length
|
|
236
|
+
elif wire_type == 5: # 32-bit
|
|
237
|
+
value = struct.unpack("<I", data[pos : pos + 4])[0]
|
|
238
|
+
return field_number, wire_type, value, pos + 4
|
|
239
|
+
elif wire_type == 1: # 64-bit
|
|
240
|
+
value = struct.unpack("<Q", data[pos : pos + 8])[0]
|
|
241
|
+
return field_number, wire_type, value, pos + 8
|
|
242
|
+
else:
|
|
243
|
+
raise ValueError(f"Unsupported wire type {wire_type}")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
# ---------------------------------------------------------------------------
|
|
247
|
+
# MessageStatus — wraps fault code in RoutableMessage field 12
|
|
248
|
+
# ---------------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
# MessageStatus field tags (from universal_message.proto)
|
|
251
|
+
_TAG_MS_OPERATION_STATUS = 1 # enum → OperationStatus_E
|
|
252
|
+
_TAG_MS_SIGNED_MESSAGE_FAULT = 2 # enum → MessageFault_E
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
@dataclass
|
|
256
|
+
class MessageStatus:
|
|
257
|
+
"""Vehicle-side status/fault code from a RoutableMessage response.
|
|
258
|
+
|
|
259
|
+
In the protobuf schema, ``signedMessageStatus`` (field 12) is a
|
|
260
|
+
``MessageStatus`` sub-message containing:
|
|
261
|
+
- ``operation_status`` (field 1) — OperationStatus_E enum
|
|
262
|
+
- ``signed_message_fault`` (field 2) — MessageFault_E enum
|
|
263
|
+
"""
|
|
264
|
+
|
|
265
|
+
operation_status: OperationStatus = OperationStatus.OPERATIONSTATUS_OK
|
|
266
|
+
signed_message_fault: MessageFault = MessageFault.ERROR_NONE
|
|
267
|
+
|
|
268
|
+
def serialize(self) -> bytes:
|
|
269
|
+
parts = bytearray()
|
|
270
|
+
if self.operation_status != OperationStatus.OPERATIONSTATUS_OK:
|
|
271
|
+
parts.extend(_encode_varint_field(_TAG_MS_OPERATION_STATUS, self.operation_status))
|
|
272
|
+
if self.signed_message_fault != MessageFault.ERROR_NONE:
|
|
273
|
+
parts.extend(
|
|
274
|
+
_encode_varint_field(_TAG_MS_SIGNED_MESSAGE_FAULT, self.signed_message_fault)
|
|
275
|
+
)
|
|
276
|
+
return bytes(parts)
|
|
277
|
+
|
|
278
|
+
@staticmethod
|
|
279
|
+
def parse(data: bytes) -> MessageStatus:
|
|
280
|
+
"""Parse MessageStatus from protobuf bytes."""
|
|
281
|
+
result = MessageStatus()
|
|
282
|
+
pos = 0
|
|
283
|
+
while pos < len(data):
|
|
284
|
+
fn, _wt, val, pos = _decode_field(data, pos)
|
|
285
|
+
if fn == _TAG_MS_OPERATION_STATUS and isinstance(val, int):
|
|
286
|
+
with contextlib.suppress(ValueError):
|
|
287
|
+
result.operation_status = OperationStatus(val)
|
|
288
|
+
elif fn == _TAG_MS_SIGNED_MESSAGE_FAULT and isinstance(val, int):
|
|
289
|
+
try:
|
|
290
|
+
result.signed_message_fault = MessageFault(val)
|
|
291
|
+
except ValueError:
|
|
292
|
+
result.signed_message_fault = MessageFault.ERROR_INTERNAL
|
|
293
|
+
return result
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
# ---------------------------------------------------------------------------
|
|
297
|
+
# SessionInfo — parsed from vehicle response
|
|
298
|
+
# ---------------------------------------------------------------------------
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
@dataclass
|
|
302
|
+
class SessionInfo:
|
|
303
|
+
"""Session parameters returned by the vehicle after handshake."""
|
|
304
|
+
|
|
305
|
+
counter: int = 0
|
|
306
|
+
public_key: bytes = b""
|
|
307
|
+
epoch: bytes = b""
|
|
308
|
+
clock_time: int = 0
|
|
309
|
+
|
|
310
|
+
@staticmethod
|
|
311
|
+
def parse(data: bytes) -> SessionInfo:
|
|
312
|
+
"""Parse SessionInfo from protobuf bytes."""
|
|
313
|
+
info = SessionInfo()
|
|
314
|
+
pos = 0
|
|
315
|
+
while pos < len(data):
|
|
316
|
+
fn, _wt, val, pos = _decode_field(data, pos)
|
|
317
|
+
if fn == _TAG_SI_COUNTER:
|
|
318
|
+
info.counter = val
|
|
319
|
+
elif fn == _TAG_SI_PUBLIC_KEY:
|
|
320
|
+
info.public_key = val
|
|
321
|
+
elif fn == _TAG_SI_EPOCH:
|
|
322
|
+
info.epoch = val
|
|
323
|
+
elif fn == _TAG_SI_CLOCK_TIME:
|
|
324
|
+
info.clock_time = val
|
|
325
|
+
return info
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
# ---------------------------------------------------------------------------
|
|
329
|
+
# Destination — part of RoutableMessage
|
|
330
|
+
# ---------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
@dataclass
|
|
334
|
+
class Destination:
|
|
335
|
+
"""Routing destination within a RoutableMessage."""
|
|
336
|
+
|
|
337
|
+
domain: Domain = Domain.DOMAIN_BROADCAST
|
|
338
|
+
routing_address: bytes = b""
|
|
339
|
+
|
|
340
|
+
def serialize(self) -> bytes:
|
|
341
|
+
parts = bytearray()
|
|
342
|
+
if self.domain != Domain.DOMAIN_BROADCAST:
|
|
343
|
+
parts.extend(_encode_varint_field(_TAG_DEST_DOMAIN, self.domain))
|
|
344
|
+
if self.routing_address:
|
|
345
|
+
parts.extend(_encode_length_delimited(_TAG_DEST_ROUTING_ADDRESS, self.routing_address))
|
|
346
|
+
return bytes(parts)
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
# ---------------------------------------------------------------------------
|
|
350
|
+
# SessionInfoRequest — sent to initiate ECDH handshake
|
|
351
|
+
# ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
@dataclass
|
|
355
|
+
class SessionInfoRequest:
|
|
356
|
+
"""Request to establish an ECDH session."""
|
|
357
|
+
|
|
358
|
+
public_key: bytes = b""
|
|
359
|
+
|
|
360
|
+
def serialize(self) -> bytes:
|
|
361
|
+
if self.public_key:
|
|
362
|
+
return _encode_length_delimited(_TAG_SIR_PUBLIC_KEY, self.public_key)
|
|
363
|
+
return b""
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
# HMAC_PersonalizedData — authentication tag
|
|
368
|
+
# ---------------------------------------------------------------------------
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
@dataclass
|
|
372
|
+
class HMACPersonalizedData:
|
|
373
|
+
"""HMAC authentication data within SignatureData."""
|
|
374
|
+
|
|
375
|
+
epoch: bytes = b""
|
|
376
|
+
counter: int = 0
|
|
377
|
+
expires_at: int = 0
|
|
378
|
+
tag: bytes = b""
|
|
379
|
+
|
|
380
|
+
def serialize(self) -> bytes:
|
|
381
|
+
parts = bytearray()
|
|
382
|
+
if self.epoch:
|
|
383
|
+
parts.extend(_encode_length_delimited(_TAG_HMAC_EPOCH, self.epoch))
|
|
384
|
+
if self.counter:
|
|
385
|
+
parts.extend(_encode_varint_field(_TAG_HMAC_COUNTER, self.counter))
|
|
386
|
+
if self.expires_at:
|
|
387
|
+
parts.extend(_encode_fixed32_field(_TAG_HMAC_EXPIRES_AT, self.expires_at))
|
|
388
|
+
if self.tag:
|
|
389
|
+
parts.extend(_encode_length_delimited(_TAG_HMAC_TAG, self.tag))
|
|
390
|
+
return bytes(parts)
|
|
391
|
+
|
|
392
|
+
@staticmethod
|
|
393
|
+
def parse(data: bytes) -> HMACPersonalizedData:
|
|
394
|
+
"""Parse HMACPersonalizedData from protobuf bytes."""
|
|
395
|
+
result = HMACPersonalizedData()
|
|
396
|
+
pos = 0
|
|
397
|
+
while pos < len(data):
|
|
398
|
+
fn, _wt, val, pos = _decode_field(data, pos)
|
|
399
|
+
if fn == _TAG_HMAC_EPOCH and isinstance(val, bytes):
|
|
400
|
+
result.epoch = val
|
|
401
|
+
elif fn == _TAG_HMAC_COUNTER and isinstance(val, int):
|
|
402
|
+
result.counter = val
|
|
403
|
+
elif fn == _TAG_HMAC_EXPIRES_AT and isinstance(val, int):
|
|
404
|
+
result.expires_at = val
|
|
405
|
+
elif fn == _TAG_HMAC_TAG and isinstance(val, bytes):
|
|
406
|
+
result.tag = val
|
|
407
|
+
return result
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
# ---------------------------------------------------------------------------
|
|
411
|
+
# KeyIdentity — identifies the signing key
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
@dataclass
|
|
416
|
+
class KeyIdentity:
|
|
417
|
+
"""Identifies the client public key for the vehicle."""
|
|
418
|
+
|
|
419
|
+
public_key: bytes = b""
|
|
420
|
+
|
|
421
|
+
def serialize(self) -> bytes:
|
|
422
|
+
if self.public_key:
|
|
423
|
+
return _encode_length_delimited(_TAG_KI_PUBLIC_KEY, self.public_key)
|
|
424
|
+
return b""
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# ---------------------------------------------------------------------------
|
|
428
|
+
# SignatureData — wraps HMAC auth for RoutableMessage
|
|
429
|
+
# ---------------------------------------------------------------------------
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
@dataclass
|
|
433
|
+
class SignatureData:
|
|
434
|
+
"""Signature/authentication data for a RoutableMessage."""
|
|
435
|
+
|
|
436
|
+
signer_identity: KeyIdentity | None = None
|
|
437
|
+
hmac_personalized_data: HMACPersonalizedData | None = None
|
|
438
|
+
session_info_tag: HMACPersonalizedData | None = None
|
|
439
|
+
|
|
440
|
+
def serialize(self) -> bytes:
|
|
441
|
+
parts = bytearray()
|
|
442
|
+
if self.signer_identity:
|
|
443
|
+
parts.extend(
|
|
444
|
+
_encode_length_delimited(_TAG_SD_SIGNER_IDENTITY, self.signer_identity.serialize())
|
|
445
|
+
)
|
|
446
|
+
if self.hmac_personalized_data:
|
|
447
|
+
parts.extend(
|
|
448
|
+
_encode_length_delimited(_TAG_SD_HMAC_TAG, self.hmac_personalized_data.serialize())
|
|
449
|
+
)
|
|
450
|
+
if self.session_info_tag:
|
|
451
|
+
parts.extend(
|
|
452
|
+
_encode_length_delimited(
|
|
453
|
+
_TAG_SD_SESSION_INFO_TAG, self.session_info_tag.serialize()
|
|
454
|
+
)
|
|
455
|
+
)
|
|
456
|
+
return bytes(parts)
|
|
457
|
+
|
|
458
|
+
@staticmethod
|
|
459
|
+
def parse(data: bytes) -> SignatureData:
|
|
460
|
+
"""Parse SignatureData from protobuf bytes."""
|
|
461
|
+
result = SignatureData()
|
|
462
|
+
pos = 0
|
|
463
|
+
while pos < len(data):
|
|
464
|
+
fn, _wt, val, pos = _decode_field(data, pos)
|
|
465
|
+
if fn == _TAG_SD_HMAC_TAG and isinstance(val, bytes):
|
|
466
|
+
result.hmac_personalized_data = HMACPersonalizedData.parse(val)
|
|
467
|
+
elif fn == _TAG_SD_SESSION_INFO_TAG and isinstance(val, bytes):
|
|
468
|
+
result.session_info_tag = HMACPersonalizedData.parse(val)
|
|
469
|
+
return result
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# ---------------------------------------------------------------------------
|
|
473
|
+
# RoutableMessage — top-level envelope
|
|
474
|
+
# ---------------------------------------------------------------------------
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
@dataclass
|
|
478
|
+
class RoutableMessage:
|
|
479
|
+
"""Top-level protobuf envelope for the Vehicle Command Protocol."""
|
|
480
|
+
|
|
481
|
+
to_destination: Destination | None = None
|
|
482
|
+
from_destination: Destination | None = None
|
|
483
|
+
protobuf_message_as_bytes: bytes = b""
|
|
484
|
+
session_info: bytes = b""
|
|
485
|
+
message_status: MessageStatus | None = None
|
|
486
|
+
session_info_request: SessionInfoRequest | None = None
|
|
487
|
+
signature_data: SignatureData | None = None
|
|
488
|
+
request_uuid: bytes = b""
|
|
489
|
+
uuid: bytes = b""
|
|
490
|
+
flags: int = 0
|
|
491
|
+
|
|
492
|
+
@property
|
|
493
|
+
def signed_message_fault(self) -> MessageFault:
|
|
494
|
+
"""Convenience: extract the fault code from message_status."""
|
|
495
|
+
if self.message_status is not None:
|
|
496
|
+
return self.message_status.signed_message_fault
|
|
497
|
+
return MessageFault.ERROR_NONE
|
|
498
|
+
|
|
499
|
+
def serialize(self) -> bytes:
|
|
500
|
+
"""Serialize to protobuf wire format."""
|
|
501
|
+
parts = bytearray()
|
|
502
|
+
|
|
503
|
+
if self.to_destination:
|
|
504
|
+
parts.extend(
|
|
505
|
+
_encode_length_delimited(_TAG_TO_DESTINATION, self.to_destination.serialize())
|
|
506
|
+
)
|
|
507
|
+
if self.from_destination:
|
|
508
|
+
parts.extend(
|
|
509
|
+
_encode_length_delimited(_TAG_FROM_DESTINATION, self.from_destination.serialize())
|
|
510
|
+
)
|
|
511
|
+
if self.protobuf_message_as_bytes:
|
|
512
|
+
parts.extend(
|
|
513
|
+
_encode_length_delimited(
|
|
514
|
+
_TAG_PROTOBUF_MESSAGE_AS_BYTES, self.protobuf_message_as_bytes
|
|
515
|
+
)
|
|
516
|
+
)
|
|
517
|
+
if self.session_info:
|
|
518
|
+
parts.extend(_encode_length_delimited(_TAG_SESSION_INFO, self.session_info))
|
|
519
|
+
if self.message_status is not None:
|
|
520
|
+
ms_bytes = self.message_status.serialize()
|
|
521
|
+
if ms_bytes:
|
|
522
|
+
parts.extend(_encode_length_delimited(_TAG_SIGNED_MESSAGE_STATUS, ms_bytes))
|
|
523
|
+
if self.session_info_request:
|
|
524
|
+
parts.extend(
|
|
525
|
+
_encode_length_delimited(
|
|
526
|
+
_TAG_SESSION_INFO_REQUEST, self.session_info_request.serialize()
|
|
527
|
+
)
|
|
528
|
+
)
|
|
529
|
+
if self.signature_data:
|
|
530
|
+
parts.extend(
|
|
531
|
+
_encode_length_delimited(_TAG_SIGNATURE_DATA, self.signature_data.serialize())
|
|
532
|
+
)
|
|
533
|
+
if self.request_uuid:
|
|
534
|
+
parts.extend(_encode_length_delimited(_TAG_REQUEST_UUID, self.request_uuid))
|
|
535
|
+
if self.uuid:
|
|
536
|
+
parts.extend(_encode_length_delimited(_TAG_UUID, self.uuid))
|
|
537
|
+
if self.flags:
|
|
538
|
+
parts.extend(_encode_varint_field(_TAG_FLAGS, self.flags))
|
|
539
|
+
|
|
540
|
+
return bytes(parts)
|
|
541
|
+
|
|
542
|
+
@staticmethod
|
|
543
|
+
def parse(data: bytes) -> RoutableMessage:
|
|
544
|
+
"""Parse a RoutableMessage from protobuf bytes.
|
|
545
|
+
|
|
546
|
+
Only extracts fields needed for session handshake response.
|
|
547
|
+
"""
|
|
548
|
+
msg = RoutableMessage()
|
|
549
|
+
pos = 0
|
|
550
|
+
while pos < len(data):
|
|
551
|
+
fn, _wt, val, pos = _decode_field(data, pos)
|
|
552
|
+
if fn == _TAG_SESSION_INFO and isinstance(val, bytes):
|
|
553
|
+
msg.session_info = val
|
|
554
|
+
elif fn == _TAG_SIGNED_MESSAGE_STATUS and isinstance(val, bytes):
|
|
555
|
+
msg.message_status = MessageStatus.parse(val)
|
|
556
|
+
elif fn == _TAG_SIGNATURE_DATA and isinstance(val, bytes):
|
|
557
|
+
msg.signature_data = SignatureData.parse(val)
|
|
558
|
+
elif fn == _TAG_REQUEST_UUID and isinstance(val, bytes):
|
|
559
|
+
msg.request_uuid = val
|
|
560
|
+
elif fn == _TAG_UUID and isinstance(val, bytes):
|
|
561
|
+
msg.uuid = val
|
|
562
|
+
elif fn == _TAG_PROTOBUF_MESSAGE_AS_BYTES and isinstance(val, bytes):
|
|
563
|
+
msg.protobuf_message_as_bytes = val
|
|
564
|
+
return msg
|