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.
- tescmd/__init__.py +1 -1
- tescmd/api/client.py +49 -5
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +13 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/api/vehicle.py +19 -1
- tescmd/auth/oauth.py +5 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +11 -3
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +121 -11
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +70 -7
- 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 +244 -25
- 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 +156 -20
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/github_pages.py +8 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +24 -2
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- 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 +18 -8
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +28 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +427 -0
- tescmd/telemetry/flatbuf.py +162 -0
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/protos/__init__.py +4 -0
- tescmd/telemetry/protos/vehicle_alert.proto +31 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
- tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
- tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
- tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
- tescmd/telemetry/protos/vehicle_data.proto +768 -0
- tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
- tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
- tescmd/telemetry/protos/vehicle_error.proto +23 -0
- tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
- tescmd/telemetry/protos/vehicle_metric.proto +22 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
- tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
- tescmd/telemetry/server.py +300 -0
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +300 -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.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
- tescmd-0.3.1.dist-info/RECORD +128 -0
- tescmd-0.1.2.dist-info/RECORD +0 -81
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
- {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)
|