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.
Files changed (61) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +41 -4
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +5 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/auth/oauth.py +5 -1
  7. tescmd/auth/server.py +6 -1
  8. tescmd/auth/token_store.py +8 -1
  9. tescmd/cache/response_cache.py +8 -1
  10. tescmd/cli/_client.py +142 -20
  11. tescmd/cli/_options.py +2 -4
  12. tescmd/cli/auth.py +96 -14
  13. tescmd/cli/energy.py +2 -0
  14. tescmd/cli/main.py +27 -8
  15. tescmd/cli/mcp_cmd.py +153 -0
  16. tescmd/cli/nav.py +3 -1
  17. tescmd/cli/openclaw.py +169 -0
  18. tescmd/cli/security.py +7 -1
  19. tescmd/cli/serve.py +923 -0
  20. tescmd/cli/setup.py +18 -7
  21. tescmd/cli/sharing.py +2 -0
  22. tescmd/cli/status.py +1 -1
  23. tescmd/cli/trunk.py +8 -17
  24. tescmd/cli/user.py +16 -1
  25. tescmd/cli/vehicle.py +135 -462
  26. tescmd/deploy/github_pages.py +8 -0
  27. tescmd/mcp/__init__.py +7 -0
  28. tescmd/mcp/server.py +648 -0
  29. tescmd/models/auth.py +5 -2
  30. tescmd/openclaw/__init__.py +23 -0
  31. tescmd/openclaw/bridge.py +330 -0
  32. tescmd/openclaw/config.py +167 -0
  33. tescmd/openclaw/dispatcher.py +522 -0
  34. tescmd/openclaw/emitter.py +175 -0
  35. tescmd/openclaw/filters.py +123 -0
  36. tescmd/openclaw/gateway.py +687 -0
  37. tescmd/openclaw/telemetry_store.py +53 -0
  38. tescmd/output/rich_output.py +46 -14
  39. tescmd/protocol/commands.py +2 -2
  40. tescmd/protocol/encoder.py +16 -13
  41. tescmd/protocol/payloads.py +132 -11
  42. tescmd/protocol/session.py +8 -5
  43. tescmd/protocol/signer.py +3 -17
  44. tescmd/telemetry/__init__.py +9 -0
  45. tescmd/telemetry/cache_sink.py +154 -0
  46. tescmd/telemetry/csv_sink.py +180 -0
  47. tescmd/telemetry/dashboard.py +4 -4
  48. tescmd/telemetry/fanout.py +49 -0
  49. tescmd/telemetry/fields.py +308 -129
  50. tescmd/telemetry/mapper.py +239 -0
  51. tescmd/telemetry/server.py +26 -19
  52. tescmd/telemetry/setup.py +468 -0
  53. tescmd/telemetry/tui.py +1716 -0
  54. tescmd/triggers/__init__.py +18 -0
  55. tescmd/triggers/manager.py +264 -0
  56. tescmd/triggers/models.py +93 -0
  57. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/METADATA +80 -32
  58. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/RECORD +61 -39
  59. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  60. {tescmd-0.2.0.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  61. {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()
@@ -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
- self._dict_table(
218
- "Release Notes",
219
- data,
220
- empty_msg="[dim]No release notes available.[/dim]",
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).strftime("%I:%M %p"),
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 drive-state / location fields."""
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("Coordinates", f"{ds.latitude}, {ds.longitude}")
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
  # ------------------------------------------------------------------
@@ -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, requires_signing=False),
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),
@@ -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=client_public_key),
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=client_public_key),
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(clock_offset: int = 0, ttl_seconds: int = 15) -> int:
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 ``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::
106
+ The Go SDK computes::
109
107
 
110
- expires_at = now + clock_offset + TTL
111
- = now + (clockTime - handshake_time) + TTL
112
- clockTime + TTL (when sent shortly after handshake)
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
- clock_offset:
117
- ``session.clock_offset`` — the difference between the vehicle's
118
- epoch clock and the local wall clock at handshake time.
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()) + clock_offset + ttl_seconds
125
+ return int(time.time() + ttl_seconds - time_zero)
@@ -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
- location = _encode_tag_raw(3, 5) + struct.pack("<f", float(body["lat"]))
437
- location += _encode_tag_raw(4, 5) + struct.pack("<f", float(body["lon"]))
438
- inner += location
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": lambda _: _void_vehicle_action(_VA_HONK_HORN), # Maps to honk
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
 
@@ -63,7 +63,7 @@ class Session:
63
63
  session_info_key: bytes
64
64
  epoch: bytes
65
65
  counter: int
66
- clock_offset: int # vehicle_clock - local_clock (seconds)
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
- # Calculate clock offset
312
- local_time = int(time.time())
313
- clock_offset = session_info.clock_time - local_time if session_info.clock_time else 0
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
- clock_offset=clock_offset,
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. For VCSEC domain: truncate tag to 17 bytes.
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 HMAC tag (32 bytes for Infotainment, 17 bytes for VCSEC).
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
- tag = hmac.new(signing_key, msg, hashlib.sha256).digest()
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(
@@ -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
  ]