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.
Files changed (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. 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