tescmd 0.1.2__py3-none-any.whl → 0.2.0__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 +8 -1
- tescmd/api/errors.py +8 -0
- tescmd/api/vehicle.py +19 -1
- tescmd/cache/response_cache.py +3 -2
- tescmd/cli/auth.py +30 -2
- tescmd/cli/key.py +149 -14
- tescmd/cli/main.py +44 -0
- tescmd/cli/setup.py +230 -22
- tescmd/cli/vehicle.py +464 -1
- tescmd/crypto/__init__.py +3 -1
- tescmd/crypto/ecdh.py +9 -0
- tescmd/crypto/schnorr.py +191 -0
- tescmd/deploy/tailscale_serve.py +154 -0
- tescmd/models/__init__.py +0 -2
- tescmd/models/auth.py +19 -0
- tescmd/models/config.py +1 -0
- tescmd/models/energy.py +0 -9
- tescmd/protocol/session.py +10 -3
- tescmd/telemetry/__init__.py +19 -0
- tescmd/telemetry/dashboard.py +227 -0
- tescmd/telemetry/decoder.py +284 -0
- tescmd/telemetry/fields.py +248 -0
- tescmd/telemetry/flatbuf.py +162 -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 +293 -0
- tescmd/telemetry/tailscale.py +300 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.1.2.dist-info → tescmd-0.2.0.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,248 @@
|
|
|
1
|
+
"""Fleet Telemetry field name registry and preset configurations.
|
|
2
|
+
|
|
3
|
+
Field IDs sourced from Tesla's ``vehicle_data.proto`` Field enum.
|
|
4
|
+
Presets define commonly-used field groups with appropriate polling
|
|
5
|
+
intervals for different use cases.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from tescmd.api.errors import ConfigError
|
|
11
|
+
|
|
12
|
+
# ---------------------------------------------------------------------------
|
|
13
|
+
# Field enum → human-readable name (from vehicle_data.proto)
|
|
14
|
+
# ---------------------------------------------------------------------------
|
|
15
|
+
|
|
16
|
+
FIELD_NAMES: dict[int, str] = {
|
|
17
|
+
1: "DriveState",
|
|
18
|
+
2: "ChargeState",
|
|
19
|
+
3: "Soc",
|
|
20
|
+
4: "VehicleSpeed",
|
|
21
|
+
5: "Odometer",
|
|
22
|
+
6: "PackVoltage",
|
|
23
|
+
7: "PackCurrent",
|
|
24
|
+
8: "BatteryLevel",
|
|
25
|
+
9: "Location",
|
|
26
|
+
10: "Gear",
|
|
27
|
+
11: "EstBatteryRange",
|
|
28
|
+
12: "IdealBatteryRange",
|
|
29
|
+
13: "RatedBatteryRange",
|
|
30
|
+
14: "ACChargingEnergyIn",
|
|
31
|
+
15: "ACChargingPower",
|
|
32
|
+
16: "DCChargingEnergyIn",
|
|
33
|
+
17: "DCChargingPower",
|
|
34
|
+
18: "ChargeCurrentRequest",
|
|
35
|
+
19: "ChargeCurrentRequestMax",
|
|
36
|
+
20: "ChargeLimitSoc",
|
|
37
|
+
21: "ChargePortDoorOpen",
|
|
38
|
+
22: "ChargePortLatch",
|
|
39
|
+
23: "ChargerActualCurrent",
|
|
40
|
+
24: "ChargerPhases",
|
|
41
|
+
25: "ChargerPilotCurrent",
|
|
42
|
+
26: "ChargerVoltage",
|
|
43
|
+
27: "FastChargerPresent",
|
|
44
|
+
28: "ScheduledChargingMode",
|
|
45
|
+
29: "ScheduledChargingPending",
|
|
46
|
+
30: "ScheduledChargingStartTime",
|
|
47
|
+
31: "ScheduledDepartureTime",
|
|
48
|
+
32: "TimeToFullCharge",
|
|
49
|
+
33: "InsideTemp",
|
|
50
|
+
34: "OutsideTemp",
|
|
51
|
+
35: "DriverTempSetting",
|
|
52
|
+
36: "PassengerTempSetting",
|
|
53
|
+
37: "IsClimateOn",
|
|
54
|
+
38: "FanStatus",
|
|
55
|
+
39: "LeftTempDirection",
|
|
56
|
+
40: "RightTempDirection",
|
|
57
|
+
41: "SeatHeaterLeft",
|
|
58
|
+
42: "SeatHeaterRight",
|
|
59
|
+
43: "SeatHeaterRearLeft",
|
|
60
|
+
44: "SeatHeaterRearCenter",
|
|
61
|
+
45: "SeatHeaterRearRight",
|
|
62
|
+
46: "SteeringWheelHeater",
|
|
63
|
+
47: "AutoSeatClimateLeft",
|
|
64
|
+
48: "AutoSeatClimateRight",
|
|
65
|
+
49: "CabinOverheatProtection",
|
|
66
|
+
50: "DefrostMode",
|
|
67
|
+
51: "Locked",
|
|
68
|
+
52: "SentryMode",
|
|
69
|
+
53: "CenterDisplay",
|
|
70
|
+
54: "ValetMode",
|
|
71
|
+
55: "RemoteStart",
|
|
72
|
+
56: "DoorState",
|
|
73
|
+
57: "WindowState",
|
|
74
|
+
58: "TrunkOpen",
|
|
75
|
+
59: "FrunkOpen",
|
|
76
|
+
60: "SoftwareVersion",
|
|
77
|
+
61: "TpmsPressureFl",
|
|
78
|
+
62: "TpmsPressureFr",
|
|
79
|
+
63: "TpmsPressureRl",
|
|
80
|
+
64: "TpmsPressureRr",
|
|
81
|
+
65: "DetailedChargeState",
|
|
82
|
+
66: "HomeLink",
|
|
83
|
+
67: "UserPresent",
|
|
84
|
+
68: "DashcamState",
|
|
85
|
+
69: "RearSeatHeaters",
|
|
86
|
+
70: "BatteryHeaterOn",
|
|
87
|
+
71: "NotEnoughPowerToHeat",
|
|
88
|
+
72: "BmsFullchargecomplete",
|
|
89
|
+
73: "ChargeEnablerequest",
|
|
90
|
+
74: "ChargerPhases2",
|
|
91
|
+
75: "EnergyRemaining",
|
|
92
|
+
76: "LifetimeEnergyUsed",
|
|
93
|
+
77: "LifetimeEnergyUsedDrive",
|
|
94
|
+
78: "BMSState",
|
|
95
|
+
79: "GuestModeEnabled",
|
|
96
|
+
80: "PreconditioningEnabled",
|
|
97
|
+
81: "ScheduledChargingStartTimeApp",
|
|
98
|
+
82: "TripCharging",
|
|
99
|
+
83: "ChargingCableType",
|
|
100
|
+
84: "RouteLastUpdated",
|
|
101
|
+
85: "RouteLine",
|
|
102
|
+
86: "MilesToArrival",
|
|
103
|
+
87: "MinutesToArrival",
|
|
104
|
+
88: "TrafficMinutesDelay",
|
|
105
|
+
89: "Elevation",
|
|
106
|
+
90: "Heading",
|
|
107
|
+
91: "PowerState",
|
|
108
|
+
92: "RatedRange",
|
|
109
|
+
93: "CruiseState",
|
|
110
|
+
94: "CruiseSetSpeed",
|
|
111
|
+
95: "LaneAssistLevel",
|
|
112
|
+
96: "AutopilotState",
|
|
113
|
+
97: "AutopilotHandsOnState",
|
|
114
|
+
98: "SpeedLimitMode",
|
|
115
|
+
99: "SpeedLimitWarning",
|
|
116
|
+
100: "MaxSpeedLimit",
|
|
117
|
+
101: "SunRoofPercentOpen",
|
|
118
|
+
102: "SunRoofState",
|
|
119
|
+
103: "TonneauPosition",
|
|
120
|
+
104: "TonneauState",
|
|
121
|
+
105: "WiperHeatEnabled",
|
|
122
|
+
106: "BrakePedalPos",
|
|
123
|
+
107: "PedalPosition",
|
|
124
|
+
108: "DriveRailAmps12v",
|
|
125
|
+
109: "BrickVoltageMax",
|
|
126
|
+
110: "BrickVoltageMin",
|
|
127
|
+
111: "ModuleTempMax",
|
|
128
|
+
112: "ModuleTempMin",
|
|
129
|
+
113: "NumBrickVoltageMax",
|
|
130
|
+
114: "NumBrickVoltageMin",
|
|
131
|
+
115: "NumModuleTempMax",
|
|
132
|
+
116: "NumModuleTempMin",
|
|
133
|
+
117: "ChargeAmps",
|
|
134
|
+
118: "FastChargerType",
|
|
135
|
+
119: "ConnChargeCable",
|
|
136
|
+
120: "Supercharger",
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Preset field configurations
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
DEFAULT_FIELDS: dict[str, dict[str, int]] = {
|
|
144
|
+
"Soc": {"interval_seconds": 10},
|
|
145
|
+
"VehicleSpeed": {"interval_seconds": 1},
|
|
146
|
+
"Location": {"interval_seconds": 5},
|
|
147
|
+
"ChargeState": {"interval_seconds": 10},
|
|
148
|
+
"InsideTemp": {"interval_seconds": 30},
|
|
149
|
+
"OutsideTemp": {"interval_seconds": 60},
|
|
150
|
+
"Odometer": {"interval_seconds": 60},
|
|
151
|
+
"BatteryLevel": {"interval_seconds": 10},
|
|
152
|
+
"Gear": {"interval_seconds": 1},
|
|
153
|
+
"PackVoltage": {"interval_seconds": 10},
|
|
154
|
+
"PackCurrent": {"interval_seconds": 10},
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
PRESETS: dict[str, dict[str, dict[str, int]]] = {
|
|
158
|
+
"default": DEFAULT_FIELDS,
|
|
159
|
+
"driving": {
|
|
160
|
+
"VehicleSpeed": {"interval_seconds": 1},
|
|
161
|
+
"Location": {"interval_seconds": 1},
|
|
162
|
+
"Gear": {"interval_seconds": 1},
|
|
163
|
+
"Heading": {"interval_seconds": 1},
|
|
164
|
+
"Odometer": {"interval_seconds": 10},
|
|
165
|
+
"Elevation": {"interval_seconds": 5},
|
|
166
|
+
"BatteryLevel": {"interval_seconds": 10},
|
|
167
|
+
"Soc": {"interval_seconds": 10},
|
|
168
|
+
"PackCurrent": {"interval_seconds": 5},
|
|
169
|
+
"PackVoltage": {"interval_seconds": 5},
|
|
170
|
+
"CruiseState": {"interval_seconds": 5},
|
|
171
|
+
"CruiseSetSpeed": {"interval_seconds": 5},
|
|
172
|
+
"PowerState": {"interval_seconds": 10},
|
|
173
|
+
},
|
|
174
|
+
"charging": {
|
|
175
|
+
"Soc": {"interval_seconds": 5},
|
|
176
|
+
"BatteryLevel": {"interval_seconds": 5},
|
|
177
|
+
"PackVoltage": {"interval_seconds": 5},
|
|
178
|
+
"PackCurrent": {"interval_seconds": 5},
|
|
179
|
+
"ChargeState": {"interval_seconds": 5},
|
|
180
|
+
"ChargerActualCurrent": {"interval_seconds": 5},
|
|
181
|
+
"ChargerVoltage": {"interval_seconds": 5},
|
|
182
|
+
"ChargerPhases": {"interval_seconds": 30},
|
|
183
|
+
"ACChargingPower": {"interval_seconds": 5},
|
|
184
|
+
"DCChargingPower": {"interval_seconds": 5},
|
|
185
|
+
"TimeToFullCharge": {"interval_seconds": 30},
|
|
186
|
+
"ChargeLimitSoc": {"interval_seconds": 60},
|
|
187
|
+
"ChargePortDoorOpen": {"interval_seconds": 60},
|
|
188
|
+
"BatteryHeaterOn": {"interval_seconds": 30},
|
|
189
|
+
"InsideTemp": {"interval_seconds": 60},
|
|
190
|
+
},
|
|
191
|
+
"climate": {
|
|
192
|
+
"InsideTemp": {"interval_seconds": 10},
|
|
193
|
+
"OutsideTemp": {"interval_seconds": 30},
|
|
194
|
+
"DriverTempSetting": {"interval_seconds": 30},
|
|
195
|
+
"PassengerTempSetting": {"interval_seconds": 30},
|
|
196
|
+
"IsClimateOn": {"interval_seconds": 10},
|
|
197
|
+
"FanStatus": {"interval_seconds": 10},
|
|
198
|
+
"SeatHeaterLeft": {"interval_seconds": 30},
|
|
199
|
+
"SeatHeaterRight": {"interval_seconds": 30},
|
|
200
|
+
"SteeringWheelHeater": {"interval_seconds": 30},
|
|
201
|
+
"CabinOverheatProtection": {"interval_seconds": 60},
|
|
202
|
+
"DefrostMode": {"interval_seconds": 30},
|
|
203
|
+
"PreconditioningEnabled": {"interval_seconds": 30},
|
|
204
|
+
},
|
|
205
|
+
"all": {name: {"interval_seconds": 30} for name in FIELD_NAMES.values()},
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
# Reverse lookup: name → ID
|
|
209
|
+
_NAME_TO_ID: dict[str, int] = {v: k for k, v in FIELD_NAMES.items()}
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def resolve_fields(
|
|
213
|
+
spec: str,
|
|
214
|
+
interval_override: int | None = None,
|
|
215
|
+
) -> dict[str, dict[str, int]]:
|
|
216
|
+
"""Resolve a ``--fields`` argument to a field configuration dict.
|
|
217
|
+
|
|
218
|
+
Args:
|
|
219
|
+
spec: A preset name (e.g. ``"default"``, ``"charging"``) or a
|
|
220
|
+
comma-separated list of field names (e.g. ``"Soc,VehicleSpeed"``).
|
|
221
|
+
interval_override: If set, overrides ``interval_seconds`` for all fields.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
A dict mapping field names to ``{"interval_seconds": N}``.
|
|
225
|
+
|
|
226
|
+
Raises:
|
|
227
|
+
ConfigError: If a field name or preset is unrecognized.
|
|
228
|
+
"""
|
|
229
|
+
if spec in PRESETS:
|
|
230
|
+
fields = dict(PRESETS[spec])
|
|
231
|
+
else:
|
|
232
|
+
# Comma-separated field names
|
|
233
|
+
fields = {}
|
|
234
|
+
for name in spec.split(","):
|
|
235
|
+
name = name.strip()
|
|
236
|
+
if not name:
|
|
237
|
+
continue
|
|
238
|
+
if name not in _NAME_TO_ID:
|
|
239
|
+
raise ConfigError(
|
|
240
|
+
f"Unknown telemetry field: '{name}'. "
|
|
241
|
+
f"Available presets: {', '.join(sorted(PRESETS.keys()))}"
|
|
242
|
+
)
|
|
243
|
+
fields[name] = {"interval_seconds": 10} # reasonable default
|
|
244
|
+
|
|
245
|
+
if interval_override is not None:
|
|
246
|
+
fields = {name: {"interval_seconds": interval_override} for name in fields}
|
|
247
|
+
|
|
248
|
+
return fields
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""Minimal Flatbuffers reader for Tesla Fleet Telemetry envelopes.
|
|
2
|
+
|
|
3
|
+
Tesla vehicles send telemetry data wrapped in a Flatbuffers envelope:
|
|
4
|
+
|
|
5
|
+
WebSocket binary frame
|
|
6
|
+
└─ FlatbuffersEnvelope
|
|
7
|
+
├── txid (bytes)
|
|
8
|
+
├── topic (bytes) — "V", "alerts", "errors", "connectivity"
|
|
9
|
+
├── messageType (uint8)
|
|
10
|
+
├── messageId (bytes)
|
|
11
|
+
└── FlatbuffersStream (union)
|
|
12
|
+
├── createdAt (uint32) — epoch seconds
|
|
13
|
+
├── payload (bytes) — protobuf content
|
|
14
|
+
├── deviceId (bytes) — VIN
|
|
15
|
+
└── ...
|
|
16
|
+
|
|
17
|
+
This module provides a zero-dependency reader for these two tables.
|
|
18
|
+
The protobuf payload inside ``FlatbuffersStream.payload`` is decoded
|
|
19
|
+
separately by :mod:`tescmd.telemetry.decoder`.
|
|
20
|
+
|
|
21
|
+
Schema source:
|
|
22
|
+
https://github.com/teslamotors/fleet-telemetry/tree/main/messages/tesla
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import struct
|
|
28
|
+
from dataclasses import dataclass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
@dataclass(slots=True)
|
|
32
|
+
class StreamMessage:
|
|
33
|
+
"""Unwrapped Fleet Telemetry envelope."""
|
|
34
|
+
|
|
35
|
+
topic: bytes
|
|
36
|
+
device_id: bytes # VIN
|
|
37
|
+
created_at: int # epoch seconds
|
|
38
|
+
payload: bytes # protobuf content
|
|
39
|
+
txid: bytes = b""
|
|
40
|
+
message_id: bytes = b""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def parse_envelope(buf: bytes) -> StreamMessage:
|
|
44
|
+
"""Parse a FlatbuffersEnvelope from raw WebSocket bytes.
|
|
45
|
+
|
|
46
|
+
Raises:
|
|
47
|
+
ValueError: If the buffer is too small or structurally invalid.
|
|
48
|
+
"""
|
|
49
|
+
if len(buf) < 8:
|
|
50
|
+
raise ValueError(f"Buffer too small for Flatbuffers envelope ({len(buf)} bytes)")
|
|
51
|
+
|
|
52
|
+
# Root table offset (uint32 LE at byte 0)
|
|
53
|
+
root_pos = _read_u32(buf, 0)
|
|
54
|
+
if root_pos >= len(buf):
|
|
55
|
+
raise ValueError(f"Root offset {root_pos} exceeds buffer size {len(buf)}")
|
|
56
|
+
|
|
57
|
+
# Envelope vtable
|
|
58
|
+
vtable_pos = _vtable_pos(buf, root_pos)
|
|
59
|
+
|
|
60
|
+
# Envelope fields (vtable offsets: txid=4, topic=6, messageType=8, message=10, messageId=12)
|
|
61
|
+
txid = _read_vector(buf, root_pos, vtable_pos, vtable_offset=4)
|
|
62
|
+
topic = _read_vector(buf, root_pos, vtable_pos, vtable_offset=6)
|
|
63
|
+
message_id = _read_vector(buf, root_pos, vtable_pos, vtable_offset=12)
|
|
64
|
+
|
|
65
|
+
# Navigate to FlatbuffersStream (union at vtable_offset=10)
|
|
66
|
+
stream_pos = _read_indirect(buf, root_pos, vtable_pos, vtable_offset=10)
|
|
67
|
+
if stream_pos is None:
|
|
68
|
+
raise ValueError("FlatbuffersEnvelope has no message field")
|
|
69
|
+
|
|
70
|
+
# Stream vtable
|
|
71
|
+
stream_vtable = _vtable_pos(buf, stream_pos)
|
|
72
|
+
|
|
73
|
+
# Stream fields (vtable offsets: createdAt=4, senderId=6, payload=8,
|
|
74
|
+
# deviceType=10, deviceId=12, deliveredAtEpochMs=14)
|
|
75
|
+
created_at = _read_u32_field(buf, stream_pos, stream_vtable, vtable_offset=4)
|
|
76
|
+
payload = _read_vector(buf, stream_pos, stream_vtable, vtable_offset=8)
|
|
77
|
+
device_id = _read_vector(buf, stream_pos, stream_vtable, vtable_offset=12)
|
|
78
|
+
|
|
79
|
+
return StreamMessage(
|
|
80
|
+
topic=topic,
|
|
81
|
+
device_id=device_id,
|
|
82
|
+
created_at=created_at,
|
|
83
|
+
payload=payload,
|
|
84
|
+
txid=txid,
|
|
85
|
+
message_id=message_id,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
# Low-level Flatbuffers primitives
|
|
91
|
+
# ---------------------------------------------------------------------------
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _read_u16(buf: bytes, pos: int) -> int:
|
|
95
|
+
result: int = struct.unpack_from("<H", buf, pos)[0]
|
|
96
|
+
return result
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _read_u32(buf: bytes, pos: int) -> int:
|
|
100
|
+
result: int = struct.unpack_from("<I", buf, pos)[0]
|
|
101
|
+
return result
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _read_i32(buf: bytes, pos: int) -> int:
|
|
105
|
+
result: int = struct.unpack_from("<i", buf, pos)[0]
|
|
106
|
+
return result
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _vtable_pos(buf: bytes, table_pos: int) -> int:
|
|
110
|
+
"""Compute vtable position from a table position.
|
|
111
|
+
|
|
112
|
+
The first 4 bytes of a Flatbuffers table are a signed offset (soffset32)
|
|
113
|
+
pointing back to its vtable.
|
|
114
|
+
"""
|
|
115
|
+
soffset = _read_i32(buf, table_pos)
|
|
116
|
+
return table_pos - soffset
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _field_offset(buf: bytes, vtable_pos: int, vtable_offset: int) -> int:
|
|
120
|
+
"""Read a field's offset from the vtable.
|
|
121
|
+
|
|
122
|
+
Returns 0 if the field is absent (vtable_offset beyond vtable size
|
|
123
|
+
or stored offset is 0).
|
|
124
|
+
"""
|
|
125
|
+
vtable_size = _read_u16(buf, vtable_pos)
|
|
126
|
+
if vtable_offset >= vtable_size:
|
|
127
|
+
return 0
|
|
128
|
+
return _read_u16(buf, vtable_pos + vtable_offset)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _read_vector(buf: bytes, table_pos: int, vtable_pos: int, *, vtable_offset: int) -> bytes:
|
|
132
|
+
"""Read a byte-vector field from a Flatbuffers table."""
|
|
133
|
+
voff = _field_offset(buf, vtable_pos, vtable_offset)
|
|
134
|
+
if voff == 0:
|
|
135
|
+
return b""
|
|
136
|
+
# Follow indirect offset to vector
|
|
137
|
+
field_pos = table_pos + voff
|
|
138
|
+
vec_pos = field_pos + _read_u32(buf, field_pos)
|
|
139
|
+
length = _read_u32(buf, vec_pos)
|
|
140
|
+
return bytes(buf[vec_pos + 4 : vec_pos + 4 + length])
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _read_u32_field(buf: bytes, table_pos: int, vtable_pos: int, *, vtable_offset: int) -> int:
|
|
144
|
+
"""Read a uint32 scalar field from a Flatbuffers table."""
|
|
145
|
+
voff = _field_offset(buf, vtable_pos, vtable_offset)
|
|
146
|
+
if voff == 0:
|
|
147
|
+
return 0
|
|
148
|
+
return _read_u32(buf, table_pos + voff)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _read_indirect(
|
|
152
|
+
buf: bytes, table_pos: int, vtable_pos: int, *, vtable_offset: int
|
|
153
|
+
) -> int | None:
|
|
154
|
+
"""Follow an offset field to a child table (used for unions).
|
|
155
|
+
|
|
156
|
+
Returns the child table position, or ``None`` if absent.
|
|
157
|
+
"""
|
|
158
|
+
voff = _field_offset(buf, vtable_pos, vtable_offset)
|
|
159
|
+
if voff == 0:
|
|
160
|
+
return None
|
|
161
|
+
field_pos = table_pos + voff
|
|
162
|
+
return field_pos + _read_u32(buf, field_pos)
|