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,23 @@
1
+ """Vehicle Command Protocol — ECDH sessions + HMAC-signed protobuf commands."""
2
+
3
+ from tescmd.protocol.commands import (
4
+ COMMAND_REGISTRY,
5
+ CommandSpec,
6
+ get_command_spec,
7
+ get_domain,
8
+ requires_signing,
9
+ )
10
+ from tescmd.protocol.protobuf.messages import Domain, MessageFault
11
+ from tescmd.protocol.session import Session, SessionManager
12
+
13
+ __all__ = [
14
+ "COMMAND_REGISTRY",
15
+ "CommandSpec",
16
+ "Domain",
17
+ "MessageFault",
18
+ "Session",
19
+ "SessionManager",
20
+ "get_command_spec",
21
+ "get_domain",
22
+ "requires_signing",
23
+ ]
@@ -0,0 +1,175 @@
1
+ """Command registry: maps REST command names to protocol domain + payload builders.
2
+
3
+ Each command has a domain (VCSEC, INFOTAINMENT, or UNSIGNED) and a builder
4
+ function that converts the JSON body dict into protobuf payload bytes.
5
+
6
+ For the Vehicle Command Protocol, the payload is either:
7
+ - An Infotainment ``Action`` protobuf (for car-server commands)
8
+ - A VCSEC ``UnsignedMessage`` protobuf (for security commands)
9
+ - None for unsigned commands (wake_up)
10
+
11
+ Since we don't have full protobuf generation, the payload is currently
12
+ passed through as JSON-encoded bytes. The ``signed_command`` endpoint
13
+ accepts both protobuf and JSON payloads — the protobuf wrapping in
14
+ RoutableMessage is what matters for authentication.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from dataclasses import dataclass
20
+
21
+ from tescmd.protocol.protobuf.messages import Domain
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class CommandSpec:
26
+ """Specification for a vehicle command."""
27
+
28
+ domain: Domain
29
+ requires_signing: bool = True
30
+ action_type: str = "" # Protobuf action type identifier
31
+
32
+
33
+ # ---------------------------------------------------------------------------
34
+ # VCSEC commands (security domain)
35
+ # ---------------------------------------------------------------------------
36
+
37
+ _VCSEC_COMMANDS: dict[str, CommandSpec] = {
38
+ "door_lock": CommandSpec(Domain.DOMAIN_VEHICLE_SECURITY, action_type="RKE_ACTION_LOCK"),
39
+ "door_unlock": CommandSpec(Domain.DOMAIN_VEHICLE_SECURITY, action_type="RKE_ACTION_UNLOCK"),
40
+ "actuate_trunk": CommandSpec(Domain.DOMAIN_VEHICLE_SECURITY),
41
+ "open_tonneau": CommandSpec(Domain.DOMAIN_VEHICLE_SECURITY),
42
+ "close_tonneau": CommandSpec(Domain.DOMAIN_VEHICLE_SECURITY),
43
+ "stop_tonneau": CommandSpec(Domain.DOMAIN_VEHICLE_SECURITY),
44
+ "remote_start_drive": CommandSpec(Domain.DOMAIN_VEHICLE_SECURITY),
45
+ "auto_secure_vehicle": CommandSpec(
46
+ Domain.DOMAIN_VEHICLE_SECURITY, action_type="RKE_ACTION_AUTO_SECURE_VEHICLE"
47
+ ),
48
+ }
49
+
50
+ # ---------------------------------------------------------------------------
51
+ # Infotainment commands (car-server domain)
52
+ # ---------------------------------------------------------------------------
53
+
54
+ _INFOTAINMENT_COMMANDS: dict[str, CommandSpec] = {
55
+ # Charging
56
+ "charge_start": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
57
+ "charge_stop": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
58
+ "charge_standard": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
59
+ "charge_max_range": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
60
+ "charge_port_door_open": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
61
+ "charge_port_door_close": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
62
+ "set_charge_limit": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
63
+ "set_charging_amps": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
64
+ "set_scheduled_charging": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
65
+ "set_scheduled_departure": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
66
+ "add_precondition_schedule": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
67
+ "remove_precondition_schedule": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
68
+ "batch_remove_precondition_schedules": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
69
+ "add_charge_schedule": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
70
+ "remove_charge_schedule": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
71
+ "batch_remove_charge_schedules": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
72
+ # Climate
73
+ "auto_conditioning_start": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
74
+ "auto_conditioning_stop": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
75
+ "set_temps": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
76
+ "set_preconditioning_max": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
77
+ "remote_seat_heater_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
78
+ "remote_seat_cooler_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
79
+ "remote_steering_wheel_heater_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
80
+ "set_cabin_overheat_protection": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
81
+ "set_climate_keeper_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
82
+ "set_cop_temp": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
83
+ "remote_auto_seat_climate_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
84
+ "remote_auto_steering_wheel_heat_climate_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
85
+ "remote_steering_wheel_heat_level_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
86
+ "set_bioweapon_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
87
+ # Security (infotainment-routed)
88
+ "set_sentry_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
89
+ "set_valet_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
90
+ "reset_valet_pin": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
91
+ "speed_limit_activate": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
92
+ "speed_limit_deactivate": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
93
+ "speed_limit_set_limit": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
94
+ "speed_limit_clear_pin": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
95
+ "reset_pin_to_drive_pin": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
96
+ "clear_pin_to_drive_admin": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
97
+ "speed_limit_clear_pin_admin": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
98
+ "flash_lights": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
99
+ "honk_horn": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
100
+ "set_pin_to_drive": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
101
+ "guest_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
102
+ "erase_user_data": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
103
+ "remote_boombox": CommandSpec(Domain.DOMAIN_INFOTAINMENT, requires_signing=False),
104
+ # Media
105
+ "media_toggle_playback": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
106
+ "media_next_track": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
107
+ "media_prev_track": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
108
+ "media_next_fav": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
109
+ "media_prev_fav": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
110
+ "media_volume_up": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
111
+ "media_volume_down": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
112
+ "adjust_volume": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
113
+ # Navigation
114
+ "share": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
115
+ "navigation_gps_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
116
+ "navigation_sc_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
117
+ "trigger_homelink": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
118
+ "navigation_waypoints_request": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
119
+ # Software
120
+ "schedule_software_update": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
121
+ "cancel_software_update": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
122
+ # Vehicle name / calendar
123
+ "set_vehicle_name": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
124
+ "upcoming_calendar_entries": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
125
+ # Windows / sunroof
126
+ "window_control": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
127
+ "sun_roof_control": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
128
+ # Power management
129
+ "set_low_power_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
130
+ "keep_accessory_power_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
131
+ }
132
+
133
+ # ---------------------------------------------------------------------------
134
+ # Unsigned commands (no signing required)
135
+ # ---------------------------------------------------------------------------
136
+
137
+ _UNSIGNED_COMMANDS: dict[str, CommandSpec] = {
138
+ "wake_up": CommandSpec(Domain.DOMAIN_BROADCAST, requires_signing=False),
139
+ "set_managed_charge_current_request": CommandSpec(
140
+ Domain.DOMAIN_BROADCAST, requires_signing=False
141
+ ),
142
+ "set_managed_charger_location": CommandSpec(Domain.DOMAIN_BROADCAST, requires_signing=False),
143
+ "set_managed_scheduled_charging_time": CommandSpec(
144
+ Domain.DOMAIN_BROADCAST, requires_signing=False
145
+ ),
146
+ }
147
+
148
+ # ---------------------------------------------------------------------------
149
+ # Unified registry
150
+ # ---------------------------------------------------------------------------
151
+
152
+ COMMAND_REGISTRY: dict[str, CommandSpec] = {
153
+ **_VCSEC_COMMANDS,
154
+ **_INFOTAINMENT_COMMANDS,
155
+ **_UNSIGNED_COMMANDS,
156
+ }
157
+
158
+
159
+ def get_command_spec(command: str) -> CommandSpec | None:
160
+ """Look up a command specification by REST command name."""
161
+ return COMMAND_REGISTRY.get(command)
162
+
163
+
164
+ def get_domain(command: str) -> Domain | None:
165
+ """Return the domain for a command, or None if unknown."""
166
+ spec = COMMAND_REGISTRY.get(command)
167
+ return spec.domain if spec else None
168
+
169
+
170
+ def requires_signing(command: str) -> bool:
171
+ """Return True if the command requires a signed channel."""
172
+ spec = COMMAND_REGISTRY.get(command)
173
+ if spec is None:
174
+ return False # Unknown commands fall through to unsigned path
175
+ return spec.requires_signing
@@ -0,0 +1,122 @@
1
+ """RoutableMessage assembly and base64 encoding for the signed_command endpoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import os
7
+ import time
8
+
9
+ from tescmd.protocol.protobuf.messages import (
10
+ Destination,
11
+ Domain,
12
+ HMACPersonalizedData,
13
+ KeyIdentity,
14
+ RoutableMessage,
15
+ SessionInfoRequest,
16
+ SignatureData,
17
+ )
18
+
19
+
20
+ def build_session_info_request(
21
+ *,
22
+ domain: Domain,
23
+ client_public_key: bytes,
24
+ ) -> RoutableMessage:
25
+ """Build a session handshake request message.
26
+
27
+ Parameters
28
+ ----------
29
+ domain:
30
+ Target domain (VCSEC or INFOTAINMENT).
31
+ client_public_key:
32
+ 65-byte uncompressed EC public key (``0x04 || X || Y``).
33
+
34
+ Returns
35
+ -------
36
+ RoutableMessage
37
+ Ready to serialize → base64 → POST.
38
+ """
39
+ return RoutableMessage(
40
+ to_destination=Destination(domain=domain),
41
+ from_destination=Destination(routing_address=client_public_key),
42
+ session_info_request=SessionInfoRequest(public_key=client_public_key),
43
+ uuid=os.urandom(16),
44
+ )
45
+
46
+
47
+ def build_signed_command(
48
+ *,
49
+ domain: Domain,
50
+ payload: bytes,
51
+ client_public_key: bytes,
52
+ epoch: bytes,
53
+ counter: int,
54
+ expires_at: int,
55
+ hmac_tag: bytes,
56
+ ) -> RoutableMessage:
57
+ """Build a signed command message.
58
+
59
+ Parameters
60
+ ----------
61
+ domain:
62
+ Target domain (VCSEC or INFOTAINMENT).
63
+ payload:
64
+ Serialized protobuf command (Action or UnsignedMessage).
65
+ client_public_key:
66
+ 65-byte uncompressed client public key.
67
+ epoch:
68
+ Session epoch identifier from handshake.
69
+ counter:
70
+ Monotonically increasing counter.
71
+ expires_at:
72
+ Command expiration in vehicle epoch-relative seconds.
73
+ hmac_tag:
74
+ Computed HMAC-SHA256 tag from :func:`~tescmd.protocol.signer.compute_hmac_tag`.
75
+
76
+ Returns
77
+ -------
78
+ RoutableMessage
79
+ Ready to serialize → base64 → POST.
80
+ """
81
+ return RoutableMessage(
82
+ to_destination=Destination(domain=domain),
83
+ from_destination=Destination(routing_address=client_public_key),
84
+ protobuf_message_as_bytes=payload,
85
+ signature_data=SignatureData(
86
+ signer_identity=KeyIdentity(public_key=client_public_key),
87
+ hmac_personalized_data=HMACPersonalizedData(
88
+ epoch=epoch,
89
+ counter=counter,
90
+ expires_at=expires_at,
91
+ tag=hmac_tag,
92
+ ),
93
+ ),
94
+ uuid=os.urandom(16),
95
+ )
96
+
97
+
98
+ def encode_routable_message(msg: RoutableMessage) -> str:
99
+ """Serialize and base64-encode a RoutableMessage for the API."""
100
+ return base64.b64encode(msg.serialize()).decode("ascii")
101
+
102
+
103
+ def default_expiry(clock_offset: int = 0, ttl_seconds: int = 15) -> int:
104
+ """Return a command expiry timestamp in the vehicle's epoch-relative time.
105
+
106
+ The Go SDK computes ``ExpiresAt = clockTime + elapsed + TTL`` where
107
+ ``clockTime`` comes from the vehicle's SessionInfo. Our Session stores
108
+ ``clock_offset = clockTime - local_time_at_handshake``, so::
109
+
110
+ expires_at = now + clock_offset + TTL
111
+ = now + (clockTime - handshake_time) + TTL
112
+ ≈ clockTime + TTL (when sent shortly after handshake)
113
+
114
+ Parameters
115
+ ----------
116
+ clock_offset:
117
+ ``session.clock_offset`` — the difference between the vehicle's
118
+ epoch clock and the local wall clock at handshake time.
119
+ ttl_seconds:
120
+ Time-to-live in seconds (default 15).
121
+ """
122
+ return int(time.time()) + clock_offset + ttl_seconds
@@ -0,0 +1,116 @@
1
+ """TLV (tag-length-value) metadata serialization for command authentication.
2
+
3
+ The metadata block is a sequence of TLV entries that are fed into the HMAC
4
+ alongside the payload. Each entry is ``tag(1B) || length(1B) || value``.
5
+ Tags must appear in ascending order. A TAG_END (0xFF) entry terminates the
6
+ metadata before the payload.
7
+
8
+ Tags (from Tesla's vehicle-command ``signatures.proto`` Tag enum):
9
+ - 0x00: signature_type (1 byte — SignatureType enum value)
10
+ - 0x01: domain (1 byte — numeric Domain enum value, e.g. 3 for INFOTAINMENT)
11
+ - 0x02: personalization (variable-length — VIN string)
12
+ - 0x03: epoch (variable-length bytes from vehicle)
13
+ - 0x04: expires_at (4 bytes, big-endian uint32 — seconds since Unix epoch)
14
+ - 0x05: counter (4 bytes, big-endian uint32 — anti-replay)
15
+ - 0x06: challenge (variable-length — BLE challenge, not used for REST)
16
+ - 0x07: flags (4 bytes, big-endian uint32)
17
+ - 0xFF: end (0 bytes — terminates metadata)
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import struct
23
+ from typing import TYPE_CHECKING
24
+
25
+ if TYPE_CHECKING:
26
+ from tescmd.protocol.protobuf.messages import Domain
27
+
28
+ # TLV tag constants (from signatures.proto Tag enum)
29
+ TAG_SIGNATURE_TYPE = 0x00
30
+ TAG_DOMAIN = 0x01
31
+ TAG_PERSONALIZATION = 0x02
32
+ TAG_EPOCH = 0x03
33
+ TAG_EXPIRES_AT = 0x04
34
+ TAG_COUNTER = 0x05
35
+ TAG_CHALLENGE = 0x06
36
+ TAG_FLAGS = 0x07
37
+ TAG_END = 0xFF
38
+
39
+ # SignatureType values (from signatures.proto SignatureType enum)
40
+ SIGNATURE_TYPE_HMAC_PERSONALIZED = 8
41
+
42
+
43
+ def encode_tlv(tag: int, value: bytes) -> bytes:
44
+ """Encode a single TLV entry: tag(1B) || length(1B) || value."""
45
+ if len(value) > 255:
46
+ raise ValueError(f"TLV value too long ({len(value)} bytes, max 255)")
47
+ return bytes([tag, len(value)]) + value
48
+
49
+
50
+ def encode_metadata(
51
+ *,
52
+ epoch: bytes,
53
+ expires_at: int,
54
+ counter: int,
55
+ domain: Domain,
56
+ vin: str,
57
+ flags: int = 0,
58
+ ) -> bytes:
59
+ """Encode the full metadata block as a sequence of TLV entries.
60
+
61
+ The metadata is fed into the HMAC hash alongside the command payload.
62
+ Tags must appear in ascending order and are terminated by TAG_END.
63
+
64
+ Parameters
65
+ ----------
66
+ epoch:
67
+ Session epoch identifier (variable-length bytes from vehicle).
68
+ expires_at:
69
+ Command expiration time (seconds since Unix epoch).
70
+ counter:
71
+ Monotonically increasing counter for anti-replay.
72
+ domain:
73
+ The routing domain (VCSEC or INFOTAINMENT).
74
+ vin:
75
+ The vehicle identification number (17 chars).
76
+ flags:
77
+ Optional flags (default 0).
78
+
79
+ Returns
80
+ -------
81
+ bytes
82
+ Concatenated TLV entries (TAG_END is NOT included — the signer
83
+ adds a bare 0xFF separator between metadata and payload).
84
+ """
85
+ parts = bytearray()
86
+ parts.extend(encode_tlv(TAG_SIGNATURE_TYPE, bytes([SIGNATURE_TYPE_HMAC_PERSONALIZED])))
87
+ # Domain is encoded as a single byte (numeric enum value), matching the Go SDK:
88
+ # meta.Add(TAG_DOMAIN, []byte{byte(x.Domain)})
89
+ parts.extend(encode_tlv(TAG_DOMAIN, bytes([int(domain)])))
90
+ parts.extend(encode_tlv(TAG_PERSONALIZATION, vin.encode()))
91
+ parts.extend(encode_tlv(TAG_EPOCH, epoch))
92
+ parts.extend(encode_tlv(TAG_EXPIRES_AT, struct.pack(">I", expires_at)))
93
+ parts.extend(encode_tlv(TAG_COUNTER, struct.pack(">I", counter)))
94
+ if flags:
95
+ parts.extend(encode_tlv(TAG_FLAGS, struct.pack(">I", flags)))
96
+ # NOTE: TAG_END is NOT included here. The Go SDK's Checksum() writes a
97
+ # bare 0xFF byte (no length byte) between metadata and payload when
98
+ # computing the HMAC. compute_hmac_tag() handles this separator.
99
+ return bytes(parts)
100
+
101
+
102
+ def decode_metadata(data: bytes) -> dict[int, bytes]:
103
+ """Decode a TLV metadata block into a {tag: value} dict."""
104
+ result: dict[int, bytes] = {}
105
+ pos = 0
106
+ while pos < len(data):
107
+ if pos + 2 > len(data):
108
+ break
109
+ tag = data[pos]
110
+ length = data[pos + 1]
111
+ pos += 2
112
+ if pos + length > len(data):
113
+ break
114
+ result[tag] = data[pos : pos + length]
115
+ pos += length
116
+ return result