tescmd 0.1.2__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 (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,284 @@
1
+ """Decode Fleet Telemetry messages from Tesla vehicles.
2
+
3
+ Tesla vehicles send telemetry data as **Flatbuffers envelopes** wrapping
4
+ protobuf payloads. The :class:`TelemetryDecoder` handles both layers:
5
+
6
+ 1. Unwrap the Flatbuffers ``FlatbuffersEnvelope`` → ``FlatbuffersStream``
7
+ to extract the topic, VIN, timestamp, and raw protobuf bytes.
8
+ 2. Decode the protobuf ``Payload`` message using generated bindings from
9
+ Tesla's ``vehicle_data.proto``.
10
+
11
+ Proto source: https://github.com/teslamotors/fleet-telemetry/tree/main/protos
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ from dataclasses import dataclass, field
18
+ from datetime import UTC, datetime
19
+ from typing import Any
20
+
21
+ logger = logging.getLogger(__name__)
22
+
23
+ # Value oneof variants that are enum types (wire varint) — map to enum name.
24
+ _ENUM_VARIANTS: frozenset[str] = frozenset(
25
+ {
26
+ "charging_value",
27
+ "shift_state_value",
28
+ "lane_assist_level_value",
29
+ "scheduled_charging_mode_value",
30
+ "sentry_mode_state_value",
31
+ "speed_assist_level_value",
32
+ "bms_state_value",
33
+ "buckle_status_value",
34
+ "car_type_value",
35
+ "charge_port_value",
36
+ "charge_port_latch_value",
37
+ "drive_inverter_state_value",
38
+ "hvil_status_value",
39
+ "window_state_value",
40
+ "seat_fold_position_value",
41
+ "tractor_air_status_value",
42
+ "follow_distance_value",
43
+ "forward_collision_sensitivity_value",
44
+ "guest_mode_mobile_access_value",
45
+ "trailer_air_status_value",
46
+ "detailed_charge_state_value",
47
+ "hvac_auto_mode_value",
48
+ "cabin_overheat_protection_mode_value",
49
+ "cabin_overheat_protection_temperature_limit_value",
50
+ "defrost_mode_value",
51
+ "climate_keeper_mode_value",
52
+ "hvac_power_value",
53
+ "fast_charger_value",
54
+ "cable_type_value",
55
+ "tonneau_tent_mode_value",
56
+ "tonneau_position_value",
57
+ "powershare_type_value",
58
+ "powershare_state_value",
59
+ "powershare_stop_reason_value",
60
+ "display_state_value",
61
+ "distance_unit_value",
62
+ "temperature_unit_value",
63
+ "pressure_unit_value",
64
+ "charge_unit_preference_value",
65
+ "turn_signal_state_value",
66
+ "media_status_value",
67
+ "sunroof_installed_state_value",
68
+ }
69
+ )
70
+
71
+
72
+ @dataclass
73
+ class TelemetryDatum:
74
+ """A single decoded telemetry field."""
75
+
76
+ field_name: str
77
+ field_id: int
78
+ value: Any
79
+ value_type: str # "string", "int", "float", "bool", "location", "enum"
80
+
81
+
82
+ @dataclass
83
+ class TelemetryFrame:
84
+ """A decoded telemetry payload from one vehicle push."""
85
+
86
+ vin: str
87
+ created_at: datetime
88
+ data: list[TelemetryDatum] = field(default_factory=list)
89
+ is_resend: bool = False
90
+ topic: str = "V"
91
+
92
+
93
+ class TelemetryDecoder:
94
+ """Decodes binary Fleet Telemetry messages into :class:`TelemetryFrame`.
95
+
96
+ Handles the full Flatbuffers → protobuf pipeline.
97
+ """
98
+
99
+ def decode(self, raw: bytes) -> TelemetryFrame:
100
+ """Decode a raw WebSocket binary message.
101
+
102
+ The message is expected to be a Flatbuffers ``FlatbuffersEnvelope``
103
+ containing a ``FlatbuffersStream`` with a protobuf payload.
104
+
105
+ Args:
106
+ raw: The raw binary WebSocket message.
107
+
108
+ Returns:
109
+ A :class:`TelemetryFrame` with decoded telemetry data.
110
+
111
+ Raises:
112
+ ValueError: If the message is fundamentally malformed.
113
+ """
114
+ from tescmd.telemetry.flatbuf import parse_envelope
115
+
116
+ envelope = parse_envelope(raw)
117
+ topic = envelope.topic.decode("utf-8", errors="replace")
118
+ vin = envelope.device_id.decode("utf-8", errors="replace")
119
+ created_at = datetime.fromtimestamp(envelope.created_at, tz=UTC)
120
+
121
+ if topic != "V":
122
+ # Non-telemetry topics (alerts, errors, connectivity) — return
123
+ # a frame with no data items for now.
124
+ logger.debug("Received non-telemetry topic: %s", topic)
125
+ return TelemetryFrame(
126
+ vin=vin,
127
+ created_at=created_at,
128
+ topic=topic,
129
+ )
130
+
131
+ return self._decode_payload(envelope.payload, vin, created_at, topic)
132
+
133
+ def decode_protobuf(self, raw: bytes) -> TelemetryFrame:
134
+ """Decode a raw protobuf ``Payload`` message directly.
135
+
136
+ Bypasses the Flatbuffers envelope — use this when the envelope has
137
+ already been unwrapped, or for testing with hand-crafted protobuf.
138
+
139
+ Args:
140
+ raw: Raw protobuf bytes of a ``Payload`` message.
141
+
142
+ Returns:
143
+ A :class:`TelemetryFrame` with decoded telemetry data.
144
+ """
145
+ return self._decode_payload(raw, vin="", created_at=datetime.now(tz=UTC), topic="V")
146
+
147
+ def _decode_payload(
148
+ self,
149
+ payload_bytes: bytes,
150
+ vin: str,
151
+ created_at: datetime,
152
+ topic: str,
153
+ ) -> TelemetryFrame:
154
+ """Decode the protobuf Payload message using generated bindings."""
155
+ from tescmd.telemetry.protos import vehicle_data_pb2
156
+
157
+ payload = vehicle_data_pb2.Payload()
158
+ payload.ParseFromString(payload_bytes)
159
+
160
+ # Use VIN and timestamp from protobuf if present (more accurate
161
+ # than the Flatbuffers envelope which has second granularity).
162
+ if payload.vin:
163
+ vin = payload.vin
164
+ if payload.HasField("created_at"):
165
+ ts = payload.created_at
166
+ created_at = datetime.fromtimestamp(ts.seconds + ts.nanos / 1_000_000_000, tz=UTC)
167
+
168
+ data_items: list[TelemetryDatum] = []
169
+ for datum in payload.data:
170
+ item = self._decode_datum(datum)
171
+ if item is not None:
172
+ data_items.append(item)
173
+
174
+ return TelemetryFrame(
175
+ vin=vin,
176
+ created_at=created_at,
177
+ data=data_items,
178
+ is_resend=payload.is_resend,
179
+ topic=topic,
180
+ )
181
+
182
+ @staticmethod
183
+ def _decode_datum(
184
+ datum: Any,
185
+ ) -> TelemetryDatum | None:
186
+ """Extract a single Datum into a TelemetryDatum."""
187
+ from tescmd.telemetry.protos import vehicle_data_pb2
188
+
189
+ field_id: int = datum.key
190
+ if field_id == 0:
191
+ return None
192
+
193
+ # Get field name from proto enum, fall back to our registry
194
+ try:
195
+ field_name = vehicle_data_pb2.Field.Name(field_id)
196
+ except ValueError:
197
+ from tescmd.telemetry.fields import FIELD_NAMES
198
+
199
+ field_name = FIELD_NAMES.get(field_id, f"Unknown({field_id})")
200
+
201
+ value_msg = datum.value
202
+ which = value_msg.WhichOneof("value")
203
+ if which is None:
204
+ return None
205
+
206
+ val: Any
207
+ value_type: str
208
+
209
+ # --- Primitive types ---
210
+ if which == "string_value":
211
+ val, value_type = value_msg.string_value, "string"
212
+ elif which == "int_value":
213
+ val, value_type = value_msg.int_value, "int"
214
+ elif which == "long_value":
215
+ val, value_type = value_msg.long_value, "int"
216
+ elif which == "float_value":
217
+ val, value_type = value_msg.float_value, "float"
218
+ elif which == "double_value":
219
+ val, value_type = value_msg.double_value, "float"
220
+ elif which == "boolean_value":
221
+ val, value_type = value_msg.boolean_value, "bool"
222
+ elif which == "invalid":
223
+ val, value_type = None, "invalid"
224
+
225
+ # --- Structured types ---
226
+ elif which == "location_value":
227
+ loc = value_msg.location_value
228
+ val = {"latitude": loc.latitude, "longitude": loc.longitude}
229
+ value_type = "location"
230
+ elif which == "door_value":
231
+ doors = value_msg.door_value
232
+ val = {
233
+ "DriverFront": doors.DriverFront,
234
+ "DriverRear": doors.DriverRear,
235
+ "PassengerFront": doors.PassengerFront,
236
+ "PassengerRear": doors.PassengerRear,
237
+ "TrunkFront": doors.TrunkFront,
238
+ "TrunkRear": doors.TrunkRear,
239
+ }
240
+ value_type = "doors"
241
+ elif which == "tire_location_value":
242
+ tire = value_msg.tire_location_value
243
+ val = {
244
+ "front_left": tire.front_left,
245
+ "front_right": tire.front_right,
246
+ "rear_left": tire.rear_left,
247
+ "rear_right": tire.rear_right,
248
+ }
249
+ value_type = "tires"
250
+ elif which == "time_value":
251
+ t = value_msg.time_value
252
+ val = f"{t.hour:02d}:{t.minute:02d}:{t.second:02d}"
253
+ value_type = "time"
254
+
255
+ # --- Enum types → resolve to human-readable name ---
256
+ elif which in _ENUM_VARIANTS:
257
+ raw_val = getattr(value_msg, which)
258
+ # Enum fields are ints; get the name from the descriptor
259
+ enum_descriptor = value_msg.DESCRIPTOR.fields_by_name[which].enum_type
260
+ if enum_descriptor is not None:
261
+ try:
262
+ val = enum_descriptor.values_by_number[raw_val].name
263
+ except (KeyError, IndexError):
264
+ val = raw_val
265
+ else:
266
+ val = raw_val
267
+ value_type = "enum"
268
+
269
+ else:
270
+ # Unknown variant — store the raw value
271
+ val = getattr(value_msg, which, None)
272
+ value_type = which
273
+
274
+ return TelemetryDatum(
275
+ field_name=field_name,
276
+ field_id=field_id,
277
+ value=val,
278
+ value_type=value_type,
279
+ )
280
+
281
+
282
+ def _zigzag_decode(n: int) -> int:
283
+ """Decode a ZigZag-encoded signed integer."""
284
+ return (n >> 1) ^ -(n & 1)
@@ -0,0 +1,49 @@
1
+ """Fan-out dispatcher for telemetry frames.
2
+
3
+ Multiplexes a single ``on_frame`` callback to N sinks, each error-isolated.
4
+ One sink failing does not affect others.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ if TYPE_CHECKING:
13
+ from collections.abc import Awaitable, Callable
14
+
15
+ from tescmd.telemetry.decoder import TelemetryFrame
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ class FrameFanout:
21
+ """Fan-out dispatcher: delivers each telemetry frame to all registered sinks."""
22
+
23
+ def __init__(self) -> None:
24
+ self._sinks: list[Callable[[TelemetryFrame], Awaitable[None]]] = []
25
+
26
+ def add_sink(self, callback: Callable[[TelemetryFrame], Awaitable[None]]) -> None:
27
+ """Register a sink to receive telemetry frames."""
28
+ self._sinks.append(callback)
29
+
30
+ @property
31
+ def sink_count(self) -> int:
32
+ """Number of registered sinks."""
33
+ return len(self._sinks)
34
+
35
+ def has_sinks(self) -> bool:
36
+ """Return ``True`` if at least one sink is registered."""
37
+ return len(self._sinks) > 0
38
+
39
+ async def on_frame(self, frame: TelemetryFrame) -> None:
40
+ """Dispatch *frame* to all registered sinks.
41
+
42
+ Each sink is called independently. If a sink raises, the exception
43
+ is logged and the remaining sinks still receive the frame.
44
+ """
45
+ for sink in self._sinks:
46
+ try:
47
+ await sink(frame)
48
+ except Exception:
49
+ logger.warning("Sink %s failed for frame", sink, exc_info=True)