tescmd 0.2.0__py3-none-any.whl → 0.3.1__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 +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +96 -14
- tescmd/cli/energy.py +2 -0
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +18 -7
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +8 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +522 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +687 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""In-memory store for the latest telemetry values per field.
|
|
2
|
+
|
|
3
|
+
Used by :class:`CommandDispatcher` to serve read requests from cached
|
|
4
|
+
telemetry data before falling back to the Fleet API. Updated on every
|
|
5
|
+
decoded frame by :class:`TelemetryBridge`.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import time
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import TYPE_CHECKING, Any
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(slots=True)
|
|
19
|
+
class FieldSnapshot:
|
|
20
|
+
"""A single telemetry field's most recent value."""
|
|
21
|
+
|
|
22
|
+
value: Any
|
|
23
|
+
timestamp: datetime
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class TelemetryStore:
|
|
27
|
+
"""Thread-safe (single-event-loop) store of latest telemetry values.
|
|
28
|
+
|
|
29
|
+
Keyed by the Tesla Fleet Telemetry field name (e.g. ``"Soc"``,
|
|
30
|
+
``"Location"``, ``"Locked"``).
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
def __init__(self) -> None:
|
|
34
|
+
self._data: dict[str, FieldSnapshot] = {}
|
|
35
|
+
|
|
36
|
+
def update(self, field_name: str, value: Any, timestamp: datetime) -> None:
|
|
37
|
+
"""Record or overwrite the latest value for *field_name*."""
|
|
38
|
+
self._data[field_name] = FieldSnapshot(value=value, timestamp=timestamp)
|
|
39
|
+
|
|
40
|
+
def get(self, field_name: str) -> FieldSnapshot | None:
|
|
41
|
+
"""Return the latest snapshot for *field_name*, or ``None``."""
|
|
42
|
+
return self._data.get(field_name)
|
|
43
|
+
|
|
44
|
+
def get_all(self) -> dict[str, FieldSnapshot]:
|
|
45
|
+
"""Return a shallow copy of all current snapshots."""
|
|
46
|
+
return dict(self._data)
|
|
47
|
+
|
|
48
|
+
def age_seconds(self, field_name: str) -> float | None:
|
|
49
|
+
"""Return seconds since *field_name* was last updated, or ``None``."""
|
|
50
|
+
snap = self._data.get(field_name)
|
|
51
|
+
if snap is None:
|
|
52
|
+
return None
|
|
53
|
+
return time.time() - snap.timestamp.timestamp()
|
tescmd/output/rich_output.py
CHANGED
|
@@ -214,11 +214,29 @@ class RichOutput:
|
|
|
214
214
|
|
|
215
215
|
def vehicle_release_notes(self, data: dict[str, Any]) -> None:
|
|
216
216
|
"""Display firmware release notes."""
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
217
|
+
version = data.get("deployed_version") or data.get("release_notes_version", "")
|
|
218
|
+
notes = data.get("release_notes", [])
|
|
219
|
+
|
|
220
|
+
if not notes:
|
|
221
|
+
self._con.print("[dim]No release notes available.[/dim]")
|
|
222
|
+
return
|
|
223
|
+
|
|
224
|
+
if version:
|
|
225
|
+
self._con.print(f"[bold]Release Notes — {version}[/bold]")
|
|
226
|
+
self._con.print()
|
|
227
|
+
|
|
228
|
+
for note in notes:
|
|
229
|
+
if not isinstance(note, dict):
|
|
230
|
+
self._con.print(f" {note}")
|
|
231
|
+
continue
|
|
232
|
+
title = note.get("title", "")
|
|
233
|
+
desc = note.get("description", "")
|
|
234
|
+
if title:
|
|
235
|
+
self._con.print(f"[bold cyan]{title}[/bold cyan]")
|
|
236
|
+
if desc:
|
|
237
|
+
for line in desc.strip().splitlines():
|
|
238
|
+
self._con.print(f" {line.strip()}")
|
|
239
|
+
self._con.print()
|
|
222
240
|
|
|
223
241
|
# ------------------------------------------------------------------
|
|
224
242
|
# Vehicle list
|
|
@@ -339,10 +357,14 @@ class RichOutput:
|
|
|
339
357
|
):
|
|
340
358
|
rows.append(("Time to full", f"{cs.time_to_full_charge:.1f}h"))
|
|
341
359
|
if cs.scheduled_charging_start_time is not None:
|
|
360
|
+
from datetime import UTC
|
|
361
|
+
|
|
342
362
|
rows.append(
|
|
343
363
|
(
|
|
344
364
|
"Scheduled start",
|
|
345
|
-
datetime.fromtimestamp(cs.scheduled_charging_start_time
|
|
365
|
+
datetime.fromtimestamp(cs.scheduled_charging_start_time, tz=UTC)
|
|
366
|
+
.astimezone()
|
|
367
|
+
.strftime("%I:%M %p"),
|
|
346
368
|
)
|
|
347
369
|
)
|
|
348
370
|
if cs.scheduled_departure_time_minutes is not None:
|
|
@@ -410,27 +432,37 @@ class RichOutput:
|
|
|
410
432
|
# ------------------------------------------------------------------
|
|
411
433
|
|
|
412
434
|
def location(self, ds: DriveState) -> None:
|
|
413
|
-
"""Print a table of
|
|
435
|
+
"""Print a table of GPS location fields."""
|
|
414
436
|
table = Table(title="Location")
|
|
415
437
|
table.add_column("Field", style="bold")
|
|
416
438
|
table.add_column("Value")
|
|
417
439
|
|
|
418
440
|
if ds.latitude is not None and ds.longitude is not None:
|
|
419
|
-
table.add_row("
|
|
441
|
+
table.add_row("Latitude", str(ds.latitude))
|
|
442
|
+
table.add_row("Longitude", str(ds.longitude))
|
|
420
443
|
if ds.heading is not None:
|
|
421
444
|
table.add_row("Heading", f"{ds.heading}\u00b0")
|
|
422
|
-
if ds.shift_state:
|
|
423
|
-
table.add_row("Gear", ds.shift_state)
|
|
424
|
-
if ds.speed is not None:
|
|
425
|
-
table.add_row("Speed", self._fmt_speed(ds.speed))
|
|
426
|
-
if ds.power is not None:
|
|
427
|
-
table.add_row("Power", f"{ds.power} kW")
|
|
428
445
|
if ds.timestamp is not None:
|
|
429
446
|
table.add_row(
|
|
430
447
|
"Updated",
|
|
431
448
|
datetime.fromtimestamp(ds.timestamp / 1000).strftime("%Y-%m-%d %H:%M:%S"),
|
|
432
449
|
)
|
|
433
450
|
|
|
451
|
+
# Show any additional location fields from the API (extra="allow")
|
|
452
|
+
_known = {
|
|
453
|
+
"latitude",
|
|
454
|
+
"longitude",
|
|
455
|
+
"heading",
|
|
456
|
+
"timestamp",
|
|
457
|
+
"shift_state",
|
|
458
|
+
"speed",
|
|
459
|
+
"power",
|
|
460
|
+
}
|
|
461
|
+
for key, value in sorted((ds.model_extra or {}).items()):
|
|
462
|
+
if key not in _known and value is not None:
|
|
463
|
+
label = key.replace("_", " ").title()
|
|
464
|
+
table.add_row(label, str(value))
|
|
465
|
+
|
|
434
466
|
self._con.print(table)
|
|
435
467
|
|
|
436
468
|
# ------------------------------------------------------------------
|
tescmd/protocol/commands.py
CHANGED
|
@@ -100,7 +100,7 @@ _INFOTAINMENT_COMMANDS: dict[str, CommandSpec] = {
|
|
|
100
100
|
"set_pin_to_drive": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
101
101
|
"guest_mode": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
102
102
|
"erase_user_data": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
103
|
-
"remote_boombox": CommandSpec(Domain.DOMAIN_INFOTAINMENT
|
|
103
|
+
"remote_boombox": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
104
104
|
# Media
|
|
105
105
|
"media_toggle_playback": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
106
106
|
"media_next_track": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
@@ -121,7 +121,7 @@ _INFOTAINMENT_COMMANDS: dict[str, CommandSpec] = {
|
|
|
121
121
|
"cancel_software_update": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
122
122
|
# Vehicle name / calendar
|
|
123
123
|
"set_vehicle_name": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
124
|
-
"upcoming_calendar_entries": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
124
|
+
"upcoming_calendar_entries": CommandSpec(Domain.DOMAIN_INFOTAINMENT, requires_signing=False),
|
|
125
125
|
# Windows / sunroof
|
|
126
126
|
"window_control": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
|
127
127
|
"sun_roof_control": CommandSpec(Domain.DOMAIN_INFOTAINMENT),
|
tescmd/protocol/encoder.py
CHANGED
|
@@ -38,7 +38,7 @@ def build_session_info_request(
|
|
|
38
38
|
"""
|
|
39
39
|
return RoutableMessage(
|
|
40
40
|
to_destination=Destination(domain=domain),
|
|
41
|
-
from_destination=Destination(routing_address=
|
|
41
|
+
from_destination=Destination(routing_address=os.urandom(16)),
|
|
42
42
|
session_info_request=SessionInfoRequest(public_key=client_public_key),
|
|
43
43
|
uuid=os.urandom(16),
|
|
44
44
|
)
|
|
@@ -80,7 +80,7 @@ def build_signed_command(
|
|
|
80
80
|
"""
|
|
81
81
|
return RoutableMessage(
|
|
82
82
|
to_destination=Destination(domain=domain),
|
|
83
|
-
from_destination=Destination(routing_address=
|
|
83
|
+
from_destination=Destination(routing_address=os.urandom(16)),
|
|
84
84
|
protobuf_message_as_bytes=payload,
|
|
85
85
|
signature_data=SignatureData(
|
|
86
86
|
signer_identity=KeyIdentity(public_key=client_public_key),
|
|
@@ -100,23 +100,26 @@ def encode_routable_message(msg: RoutableMessage) -> str:
|
|
|
100
100
|
return base64.b64encode(msg.serialize()).decode("ascii")
|
|
101
101
|
|
|
102
102
|
|
|
103
|
-
def default_expiry(
|
|
103
|
+
def default_expiry(time_zero: float = 0.0, ttl_seconds: int = 15) -> int:
|
|
104
104
|
"""Return a command expiry timestamp in the vehicle's epoch-relative time.
|
|
105
105
|
|
|
106
|
-
The Go SDK computes
|
|
107
|
-
``clockTime`` comes from the vehicle's SessionInfo. Our Session stores
|
|
108
|
-
``clock_offset = clockTime - local_time_at_handshake``, so::
|
|
106
|
+
The Go SDK computes::
|
|
109
107
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
108
|
+
ExpiresAt = uint32(time.Now().Add(expiresIn).Sub(s.timeZero) / time.Second)
|
|
109
|
+
|
|
110
|
+
Where ``timeZero`` is the wall-clock instant when the vehicle's epoch
|
|
111
|
+
counter was zero (``now - clockTime`` at handshake time). So::
|
|
112
|
+
|
|
113
|
+
expires_at = (now + TTL - time_zero)
|
|
114
|
+
= (now + TTL - (handshake_time - clockTime))
|
|
115
|
+
≈ clockTime + TTL (vehicle-relative seconds)
|
|
113
116
|
|
|
114
117
|
Parameters
|
|
115
118
|
----------
|
|
116
|
-
|
|
117
|
-
``session.
|
|
118
|
-
|
|
119
|
+
time_zero:
|
|
120
|
+
``session.time_zero`` — the wall-clock time corresponding to epoch
|
|
121
|
+
counter 0 on the vehicle.
|
|
119
122
|
ttl_seconds:
|
|
120
123
|
Time-to-live in seconds (default 15).
|
|
121
124
|
"""
|
|
122
|
-
return int(time.time()
|
|
125
|
+
return int(time.time() + ttl_seconds - time_zero)
|
tescmd/protocol/payloads.py
CHANGED
|
@@ -64,6 +64,7 @@ _CLOSURE_CLOSE = 4
|
|
|
64
64
|
_CLOSURE_REAR_TRUNK = 5
|
|
65
65
|
_CLOSURE_FRONT_TRUNK = 6
|
|
66
66
|
_CLOSURE_CHARGE_PORT = 7
|
|
67
|
+
_CLOSURE_TONNEAU = 8
|
|
67
68
|
|
|
68
69
|
|
|
69
70
|
def _vcsec_rke(action: int) -> bytes:
|
|
@@ -141,8 +142,18 @@ _VA_ADD_CHARGE_SCHEDULE = 97
|
|
|
141
142
|
_VA_REMOVE_CHARGE_SCHEDULE = 98
|
|
142
143
|
_VA_ADD_PRECONDITION_SCHEDULE = 99
|
|
143
144
|
_VA_REMOVE_PRECONDITION_SCHEDULE = 100
|
|
145
|
+
_VA_BOOMBOX = 64 # VehicleControlRemoteBoomboxAction
|
|
144
146
|
_VA_BATCH_REMOVE_PRECONDITION = 107
|
|
145
147
|
_VA_BATCH_REMOVE_CHARGE = 108
|
|
148
|
+
_VA_SET_LOW_POWER_MODE = 130
|
|
149
|
+
_VA_KEEP_ACCESSORY_POWER = 138
|
|
150
|
+
|
|
151
|
+
# Navigation (from Teslemetry's extended proto — not in Tesla's published proto)
|
|
152
|
+
# https://github.com/Teslemetry/python-tesla-fleet-api/blob/main/proto/car_server.proto
|
|
153
|
+
_VA_NAVIGATION_REQUEST = 21
|
|
154
|
+
_VA_NAVIGATION_SC_REQUEST = 22
|
|
155
|
+
_VA_NAVIGATION_GPS_REQUEST = 53
|
|
156
|
+
_VA_NAVIGATION_WAYPOINTS_REQUEST = 90
|
|
146
157
|
|
|
147
158
|
|
|
148
159
|
# ---------------------------------------------------------------------------
|
|
@@ -425,17 +436,14 @@ def _bioweapon_mode(body: dict[str, Any]) -> bytes:
|
|
|
425
436
|
|
|
426
437
|
|
|
427
438
|
def _window_control(body: dict[str, Any]) -> bytes:
|
|
428
|
-
"""VehicleControlWindowAction — vent or close.
|
|
429
|
-
command = body.get("command", "vent")
|
|
430
|
-
field = 2 if command == "close" else 1
|
|
431
|
-
inner = _encode_varint_field(field, 1)
|
|
432
|
-
# lat/lon may be required for window control
|
|
433
|
-
if "lat" in body and "lon" in body:
|
|
434
|
-
import struct
|
|
439
|
+
"""VehicleControlWindowAction — vent (field 3) or close (field 4).
|
|
435
440
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
441
|
+
Per Tesla's proto, field 1 is reserved (location removed) and the
|
|
442
|
+
action is a oneof of Void fields: vent=3, close=4.
|
|
443
|
+
"""
|
|
444
|
+
command = body.get("command", "vent")
|
|
445
|
+
field = 4 if command == "close" else 3
|
|
446
|
+
inner = _encode_length_delimited(field, _VOID)
|
|
439
447
|
return _wrap_vehicle_action(_VA_WINDOW_CONTROL, inner)
|
|
440
448
|
|
|
441
449
|
|
|
@@ -458,6 +466,14 @@ def _erase_user_data(_body: dict[str, Any]) -> bytes:
|
|
|
458
466
|
return _void_vehicle_action(_VA_ERASE_USER_DATA)
|
|
459
467
|
|
|
460
468
|
|
|
469
|
+
def _remote_boombox(body: dict[str, Any]) -> bytes:
|
|
470
|
+
"""VehicleControlRemoteBoomboxAction: action (field 1, uint32)."""
|
|
471
|
+
# CLI sends {"sound": id}, protobuf field is "action"
|
|
472
|
+
action = body.get("sound", body.get("action", 0))
|
|
473
|
+
inner = _encode_varint_field(1, int(action))
|
|
474
|
+
return _wrap_vehicle_action(_VA_BOOMBOX, inner)
|
|
475
|
+
|
|
476
|
+
|
|
461
477
|
def _scheduled_charging(body: dict[str, Any]) -> bytes:
|
|
462
478
|
"""ScheduledChargingAction."""
|
|
463
479
|
inner = b""
|
|
@@ -518,6 +534,98 @@ def _steering_wheel_heat_level(body: dict[str, Any]) -> bytes:
|
|
|
518
534
|
return _wrap_vehicle_action(_VA_HVAC_STEERING_WHEEL_HEATER, inner)
|
|
519
535
|
|
|
520
536
|
|
|
537
|
+
def _set_low_power_mode(body: dict[str, Any]) -> bytes:
|
|
538
|
+
"""SetLowPowerModeAction: low_power_mode (field 1, bool)."""
|
|
539
|
+
on = body.get("enable", True)
|
|
540
|
+
inner = _encode_varint_field(1, 1 if on else 0)
|
|
541
|
+
return _wrap_vehicle_action(_VA_SET_LOW_POWER_MODE, inner)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def _keep_accessory_power_mode(body: dict[str, Any]) -> bytes:
|
|
545
|
+
"""SetKeepAccessoryPowerModeAction: keep_accessory_power_mode (field 1, bool)."""
|
|
546
|
+
on = body.get("enable", True)
|
|
547
|
+
inner = _encode_varint_field(1, 1 if on else 0)
|
|
548
|
+
return _wrap_vehicle_action(_VA_KEEP_ACCESSORY_POWER, inner)
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def _batch_remove_precondition_schedules(body: dict[str, Any]) -> bytes:
|
|
552
|
+
"""BatchRemovePreconditionSchedulesAction: home(1), work(2), other(3)."""
|
|
553
|
+
inner = b""
|
|
554
|
+
if body.get("home"):
|
|
555
|
+
inner += _encode_varint_field(1, 1)
|
|
556
|
+
if body.get("work"):
|
|
557
|
+
inner += _encode_varint_field(2, 1)
|
|
558
|
+
if body.get("other"):
|
|
559
|
+
inner += _encode_varint_field(3, 1)
|
|
560
|
+
return _wrap_vehicle_action(_VA_BATCH_REMOVE_PRECONDITION, inner)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _batch_remove_charge_schedules(body: dict[str, Any]) -> bytes:
|
|
564
|
+
"""BatchRemoveChargeSchedulesAction: home(1), work(2), other(3)."""
|
|
565
|
+
inner = b""
|
|
566
|
+
if body.get("home"):
|
|
567
|
+
inner += _encode_varint_field(1, 1)
|
|
568
|
+
if body.get("work"):
|
|
569
|
+
inner += _encode_varint_field(2, 1)
|
|
570
|
+
if body.get("other"):
|
|
571
|
+
inner += _encode_varint_field(3, 1)
|
|
572
|
+
return _wrap_vehicle_action(_VA_BATCH_REMOVE_CHARGE, inner)
|
|
573
|
+
|
|
574
|
+
|
|
575
|
+
# ---------------------------------------------------------------------------
|
|
576
|
+
# Navigation payload builders
|
|
577
|
+
# ---------------------------------------------------------------------------
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
def _navigation_request(body: dict[str, Any]) -> bytes:
|
|
581
|
+
"""NavigationRequest: destination (field 1, string), order (field 2, int32).
|
|
582
|
+
|
|
583
|
+
The CLI ``nav send`` command passes ``{"address": "..."}``. The REST
|
|
584
|
+
``share`` endpoint wraps this in an Android intent JSON, but the VCP
|
|
585
|
+
proto only needs the destination string.
|
|
586
|
+
"""
|
|
587
|
+
inner = b""
|
|
588
|
+
destination = body.get("address", body.get("destination", ""))
|
|
589
|
+
if destination:
|
|
590
|
+
inner += _encode_length_delimited(1, str(destination).encode())
|
|
591
|
+
order = body.get("order")
|
|
592
|
+
if order is not None:
|
|
593
|
+
inner += _encode_varint_field(2, int(order))
|
|
594
|
+
return _wrap_vehicle_action(_VA_NAVIGATION_REQUEST, inner)
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _navigation_sc_request(body: dict[str, Any]) -> bytes:
|
|
598
|
+
"""NavigationSuperchargerRequest: order (field 1, int32)."""
|
|
599
|
+
order = body.get("order", 1)
|
|
600
|
+
inner = _encode_varint_field(1, int(order))
|
|
601
|
+
return _wrap_vehicle_action(_VA_NAVIGATION_SC_REQUEST, inner)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
def _navigation_gps_request(body: dict[str, Any]) -> bytes:
|
|
605
|
+
"""NavigationGpsRequest: lat (1, double), lon (2, double), order (3, enum)."""
|
|
606
|
+
import struct
|
|
607
|
+
|
|
608
|
+
inner = b""
|
|
609
|
+
lat = body.get("lat", 0.0)
|
|
610
|
+
lon = body.get("lon", 0.0)
|
|
611
|
+
# Protobuf double = wire type 1 (fixed 64-bit)
|
|
612
|
+
inner += _encode_tag_raw(1, 1) + struct.pack("<d", float(lat))
|
|
613
|
+
inner += _encode_tag_raw(2, 1) + struct.pack("<d", float(lon))
|
|
614
|
+
order = body.get("order")
|
|
615
|
+
if order is not None:
|
|
616
|
+
inner += _encode_varint_field(3, int(order))
|
|
617
|
+
return _wrap_vehicle_action(_VA_NAVIGATION_GPS_REQUEST, inner)
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
def _navigation_waypoints_request(body: dict[str, Any]) -> bytes:
|
|
621
|
+
"""NavigationWaypointsRequest: waypoints (field 1, string)."""
|
|
622
|
+
inner = b""
|
|
623
|
+
waypoints = body.get("waypoints", "")
|
|
624
|
+
if waypoints:
|
|
625
|
+
inner += _encode_length_delimited(1, str(waypoints).encode())
|
|
626
|
+
return _wrap_vehicle_action(_VA_NAVIGATION_WAYPOINTS_REQUEST, inner)
|
|
627
|
+
|
|
628
|
+
|
|
521
629
|
# ---------------------------------------------------------------------------
|
|
522
630
|
# Builder registry — maps REST command names to payload builder functions
|
|
523
631
|
# ---------------------------------------------------------------------------
|
|
@@ -529,7 +637,11 @@ _BUILDERS: dict[str, _PayloadBuilder] = {
|
|
|
529
637
|
"door_lock": lambda _: _vcsec_rke(_RKE_LOCK),
|
|
530
638
|
"door_unlock": lambda _: _vcsec_rke(_RKE_UNLOCK),
|
|
531
639
|
"remote_start_drive": lambda _: _vcsec_rke(_RKE_REMOTE_DRIVE),
|
|
640
|
+
"auto_secure_vehicle": lambda _: _vcsec_rke(_RKE_AUTO_SECURE),
|
|
532
641
|
"actuate_trunk": lambda body: _build_trunk_payload(body),
|
|
642
|
+
"open_tonneau": lambda _: _vcsec_closure_move(**{str(_CLOSURE_TONNEAU): _CLOSURE_OPEN}),
|
|
643
|
+
"close_tonneau": lambda _: _vcsec_closure_move(**{str(_CLOSURE_TONNEAU): _CLOSURE_CLOSE}),
|
|
644
|
+
"stop_tonneau": lambda _: _vcsec_closure_move(**{str(_CLOSURE_TONNEAU): _CLOSURE_STOP}),
|
|
533
645
|
# Infotainment commands → Action { VehicleAction } payloads
|
|
534
646
|
"charge_start": _charging_start,
|
|
535
647
|
"charge_stop": _charging_stop,
|
|
@@ -545,6 +657,8 @@ _BUILDERS: dict[str, _PayloadBuilder] = {
|
|
|
545
657
|
"remove_charge_schedule": _remove_charge_schedule,
|
|
546
658
|
"add_precondition_schedule": _add_precondition_schedule,
|
|
547
659
|
"remove_precondition_schedule": _remove_precondition_schedule,
|
|
660
|
+
"batch_remove_precondition_schedules": _batch_remove_precondition_schedules,
|
|
661
|
+
"batch_remove_charge_schedules": _batch_remove_charge_schedules,
|
|
548
662
|
# Climate
|
|
549
663
|
"auto_conditioning_start": _hvac_auto_start,
|
|
550
664
|
"auto_conditioning_stop": _hvac_auto_stop,
|
|
@@ -576,7 +690,7 @@ _BUILDERS: dict[str, _PayloadBuilder] = {
|
|
|
576
690
|
"set_pin_to_drive": _set_pin_to_drive,
|
|
577
691
|
"guest_mode": _guest_mode,
|
|
578
692
|
"erase_user_data": _erase_user_data,
|
|
579
|
-
"remote_boombox":
|
|
693
|
+
"remote_boombox": _remote_boombox,
|
|
580
694
|
# Media
|
|
581
695
|
"media_toggle_playback": lambda _: _void_vehicle_action(_VA_MEDIA_PLAY),
|
|
582
696
|
"media_next_track": lambda _: _void_vehicle_action(_VA_MEDIA_NEXT_TRACK),
|
|
@@ -591,6 +705,10 @@ _BUILDERS: dict[str, _PayloadBuilder] = {
|
|
|
591
705
|
),
|
|
592
706
|
"adjust_volume": _media_volume,
|
|
593
707
|
# Navigation
|
|
708
|
+
"share": _navigation_request,
|
|
709
|
+
"navigation_gps_request": _navigation_gps_request,
|
|
710
|
+
"navigation_sc_request": _navigation_sc_request,
|
|
711
|
+
"navigation_waypoints_request": _navigation_waypoints_request,
|
|
594
712
|
"trigger_homelink": _trigger_homelink,
|
|
595
713
|
# Software
|
|
596
714
|
"schedule_software_update": _schedule_software_update,
|
|
@@ -601,6 +719,9 @@ _BUILDERS: dict[str, _PayloadBuilder] = {
|
|
|
601
719
|
"sun_roof_control": _sunroof,
|
|
602
720
|
# Window (infotainment path)
|
|
603
721
|
"window_control": _window_control,
|
|
722
|
+
# Power management
|
|
723
|
+
"set_low_power_mode": _set_low_power_mode,
|
|
724
|
+
"keep_accessory_power_mode": _keep_accessory_power_mode,
|
|
604
725
|
}
|
|
605
726
|
|
|
606
727
|
|
tescmd/protocol/session.py
CHANGED
|
@@ -63,7 +63,7 @@ class Session:
|
|
|
63
63
|
session_info_key: bytes
|
|
64
64
|
epoch: bytes
|
|
65
65
|
counter: int
|
|
66
|
-
|
|
66
|
+
time_zero: float # wall-clock time when the vehicle's epoch counter was 0
|
|
67
67
|
created_at: float
|
|
68
68
|
ttl: float = _SESSION_TTL
|
|
69
69
|
|
|
@@ -308,9 +308,12 @@ class SessionManager:
|
|
|
308
308
|
"The vehicle response may have been tampered with."
|
|
309
309
|
)
|
|
310
310
|
|
|
311
|
-
#
|
|
312
|
-
|
|
313
|
-
|
|
311
|
+
# Reconstruct the wall-clock instant when the vehicle's epoch counter
|
|
312
|
+
# was zero. The Go SDK does: timeZero = now - Duration(clockTime).
|
|
313
|
+
# clock_time is a monotonic uptime counter (seconds since the
|
|
314
|
+
# vehicle's security module last booted), NOT a Unix timestamp.
|
|
315
|
+
now = time.time()
|
|
316
|
+
time_zero = now - session_info.clock_time if session_info.clock_time else now
|
|
314
317
|
|
|
315
318
|
return Session(
|
|
316
319
|
vin=vin,
|
|
@@ -320,6 +323,6 @@ class SessionManager:
|
|
|
320
323
|
session_info_key=session_info_key,
|
|
321
324
|
epoch=session_info.epoch,
|
|
322
325
|
counter=session_info.counter,
|
|
323
|
-
|
|
326
|
+
time_zero=time_zero,
|
|
324
327
|
created_at=time.monotonic(),
|
|
325
328
|
)
|
tescmd/protocol/signer.py
CHANGED
|
@@ -5,8 +5,7 @@ Signing flow (for the REST/Fleet API path):
|
|
|
5
5
|
1. Serialize metadata as TLV: ``encode_metadata(epoch, expires_at, counter)``
|
|
6
6
|
2. Derive signing key: ``K' = HMAC-SHA256(K, b"authenticated command")``
|
|
7
7
|
3. Compute tag: ``HMAC-SHA256(K', metadata_bytes || 0xFF || payload_bytes)``
|
|
8
|
-
4.
|
|
9
|
-
5. Attach to ``RoutableMessage.signature_data.HMAC_PersonalizedData.tag``
|
|
8
|
+
4. Attach to ``RoutableMessage.signature_data.HMAC_PersonalizedData.tag``
|
|
10
9
|
"""
|
|
11
10
|
|
|
12
11
|
from __future__ import annotations
|
|
@@ -14,15 +13,10 @@ from __future__ import annotations
|
|
|
14
13
|
import hashlib
|
|
15
14
|
import hmac
|
|
16
15
|
|
|
17
|
-
from tescmd.protocol.protobuf.messages import Domain
|
|
18
|
-
|
|
19
16
|
# Derivation labels (from Tesla vehicle-command specification)
|
|
20
17
|
_LABEL_AUTHENTICATED_COMMAND = b"authenticated command"
|
|
21
18
|
_LABEL_SESSION_INFO = b"session info"
|
|
22
19
|
|
|
23
|
-
# VCSEC domain uses a truncated 17-byte tag
|
|
24
|
-
_VCSEC_TAG_LENGTH = 17
|
|
25
|
-
|
|
26
20
|
|
|
27
21
|
def derive_signing_key(session_key: bytes) -> bytes:
|
|
28
22
|
"""Derive the command signing key: ``HMAC-SHA256(K, "authenticated command")``."""
|
|
@@ -38,8 +32,6 @@ def compute_hmac_tag(
|
|
|
38
32
|
signing_key: bytes,
|
|
39
33
|
metadata_bytes: bytes,
|
|
40
34
|
payload_bytes: bytes,
|
|
41
|
-
*,
|
|
42
|
-
domain: Domain = Domain.DOMAIN_INFOTAINMENT,
|
|
43
35
|
) -> bytes:
|
|
44
36
|
"""Compute the HMAC-SHA256 authentication tag.
|
|
45
37
|
|
|
@@ -51,24 +43,18 @@ def compute_hmac_tag(
|
|
|
51
43
|
TLV-encoded metadata (epoch, expires_at, counter, flags).
|
|
52
44
|
payload_bytes:
|
|
53
45
|
The serialized protobuf command payload.
|
|
54
|
-
domain:
|
|
55
|
-
The routing domain — VCSEC tags are truncated to 17 bytes.
|
|
56
46
|
|
|
57
47
|
Returns
|
|
58
48
|
-------
|
|
59
49
|
bytes
|
|
60
|
-
The
|
|
50
|
+
The full 32-byte HMAC-SHA256 tag.
|
|
61
51
|
"""
|
|
62
52
|
# The Go SDK streams metadata entries into the hash, then Checksum()
|
|
63
53
|
# writes a bare TAG_END byte (0xFF) before the payload — no length byte.
|
|
64
54
|
# m.Context.Write([]byte{byte(signatures.Tag_TAG_END)}) // just 0xFF
|
|
65
55
|
# m.Context.Write(message)
|
|
66
56
|
msg = metadata_bytes + b"\xff" + payload_bytes
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
if domain == Domain.DOMAIN_VEHICLE_SECURITY:
|
|
70
|
-
return tag[:_VCSEC_TAG_LENGTH]
|
|
71
|
-
return tag
|
|
57
|
+
return hmac.new(signing_key, msg, hashlib.sha256).digest()
|
|
72
58
|
|
|
73
59
|
|
|
74
60
|
def verify_session_info_tag(
|
tescmd/telemetry/__init__.py
CHANGED
|
@@ -2,18 +2,27 @@
|
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
|
+
from tescmd.telemetry.cache_sink import CacheSink
|
|
5
6
|
from tescmd.telemetry.decoder import TelemetryDatum, TelemetryDecoder, TelemetryFrame
|
|
7
|
+
from tescmd.telemetry.fanout import FrameFanout
|
|
6
8
|
from tescmd.telemetry.fields import FIELD_NAMES, PRESETS, resolve_fields
|
|
9
|
+
from tescmd.telemetry.mapper import TelemetryMapper
|
|
7
10
|
from tescmd.telemetry.server import TelemetryServer
|
|
11
|
+
from tescmd.telemetry.setup import TelemetrySession, telemetry_session
|
|
8
12
|
from tescmd.telemetry.tailscale import TailscaleManager
|
|
9
13
|
|
|
10
14
|
__all__ = [
|
|
11
15
|
"FIELD_NAMES",
|
|
12
16
|
"PRESETS",
|
|
17
|
+
"CacheSink",
|
|
18
|
+
"FrameFanout",
|
|
13
19
|
"TailscaleManager",
|
|
14
20
|
"TelemetryDatum",
|
|
15
21
|
"TelemetryDecoder",
|
|
16
22
|
"TelemetryFrame",
|
|
23
|
+
"TelemetryMapper",
|
|
17
24
|
"TelemetryServer",
|
|
25
|
+
"TelemetrySession",
|
|
18
26
|
"resolve_fields",
|
|
27
|
+
"telemetry_session",
|
|
19
28
|
]
|