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,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
|