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.
Files changed (47) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +8 -1
  3. tescmd/api/errors.py +8 -0
  4. tescmd/api/vehicle.py +19 -1
  5. tescmd/cache/response_cache.py +3 -2
  6. tescmd/cli/auth.py +30 -2
  7. tescmd/cli/key.py +149 -14
  8. tescmd/cli/main.py +44 -0
  9. tescmd/cli/setup.py +230 -22
  10. tescmd/cli/vehicle.py +464 -1
  11. tescmd/crypto/__init__.py +3 -1
  12. tescmd/crypto/ecdh.py +9 -0
  13. tescmd/crypto/schnorr.py +191 -0
  14. tescmd/deploy/tailscale_serve.py +154 -0
  15. tescmd/models/__init__.py +0 -2
  16. tescmd/models/auth.py +19 -0
  17. tescmd/models/config.py +1 -0
  18. tescmd/models/energy.py +0 -9
  19. tescmd/protocol/session.py +10 -3
  20. tescmd/telemetry/__init__.py +19 -0
  21. tescmd/telemetry/dashboard.py +227 -0
  22. tescmd/telemetry/decoder.py +284 -0
  23. tescmd/telemetry/fields.py +248 -0
  24. tescmd/telemetry/flatbuf.py +162 -0
  25. tescmd/telemetry/protos/__init__.py +4 -0
  26. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  27. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  28. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  29. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  30. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  31. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  32. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  33. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  34. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  35. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  36. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  37. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  38. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  39. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  40. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  41. tescmd/telemetry/server.py +293 -0
  42. tescmd/telemetry/tailscale.py +300 -0
  43. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
  44. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
  45. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
  46. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
  47. {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)
@@ -0,0 +1,4 @@
1
+ """Generated protobuf bindings for Tesla Fleet Telemetry messages.
2
+
3
+ Proto source: https://github.com/teslamotors/fleet-telemetry/tree/main/protos
4
+ """