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,427 @@
1
+ """Fleet Telemetry field name registry and preset configurations.
2
+
3
+ Field IDs and names sourced from Tesla's ``vehicle_data.proto`` Field enum
4
+ (https://github.com/teslamotors/fleet-telemetry/blob/main/protos/vehicle_data.proto).
5
+
6
+ Presets define commonly-used field groups with appropriate polling
7
+ intervals for different use cases.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from tescmd.api.errors import ConfigError
13
+
14
+ # ---------------------------------------------------------------------------
15
+ # Field enum → human-readable name (from vehicle_data.proto)
16
+ #
17
+ # IDs and names match the proto exactly. Excluded:
18
+ # - Unknown (0)
19
+ # - Deprecated_1 (162), Deprecated_2 (100), Deprecated_3 (257)
20
+ # - Experimental_1-15 (119-122, 168-178)
21
+ # ---------------------------------------------------------------------------
22
+
23
+ FIELD_NAMES: dict[int, str] = {
24
+ # --- Drive / Motion ---
25
+ 1: "DriveRail",
26
+ 4: "VehicleSpeed",
27
+ 5: "Odometer",
28
+ 10: "Gear",
29
+ 12: "PedalPosition",
30
+ 13: "BrakePedal",
31
+ 21: "Location",
32
+ 22: "GpsState",
33
+ 23: "GpsHeading",
34
+ 98: "LateralAcceleration",
35
+ 99: "LongitudinalAcceleration",
36
+ 101: "CruiseSetSpeed",
37
+ 106: "BrakePedalPos",
38
+ 126: "CruiseFollowDistance",
39
+ 129: "SpeedLimitWarning",
40
+ # --- Battery / Energy ---
41
+ 6: "PackVoltage",
42
+ 7: "PackCurrent",
43
+ 8: "Soc",
44
+ 9: "DCDCEnable",
45
+ 11: "IsolationResistance",
46
+ 24: "NumBrickVoltageMax",
47
+ 25: "BrickVoltageMax",
48
+ 26: "NumBrickVoltageMin",
49
+ 27: "BrickVoltageMin",
50
+ 28: "NumModuleTempMax",
51
+ 29: "ModuleTempMax",
52
+ 30: "NumModuleTempMin",
53
+ 31: "ModuleTempMin",
54
+ 32: "RatedRange",
55
+ 33: "Hvil",
56
+ 40: "EstBatteryRange",
57
+ 41: "IdealBatteryRange",
58
+ 42: "BatteryLevel",
59
+ 55: "BatteryHeaterOn",
60
+ 56: "NotEnoughPowerToHeat",
61
+ 102: "LifetimeEnergyUsed",
62
+ 103: "LifetimeEnergyUsedDrive",
63
+ 134: "LifetimeEnergyGainedRegen",
64
+ 158: "EnergyRemaining",
65
+ 160: "BMSState",
66
+ # --- Charging ---
67
+ 2: "ChargeState",
68
+ 3: "BmsFullchargecomplete",
69
+ 34: "DCChargingEnergyIn",
70
+ 35: "DCChargingPower",
71
+ 36: "ACChargingEnergyIn",
72
+ 37: "ACChargingPower",
73
+ 38: "ChargeLimitSoc",
74
+ 39: "FastChargerPresent",
75
+ 43: "TimeToFullCharge",
76
+ 44: "ScheduledChargingStartTime",
77
+ 45: "ScheduledChargingPending",
78
+ 46: "ScheduledDepartureTime",
79
+ 47: "PreconditioningEnabled",
80
+ 48: "ScheduledChargingMode",
81
+ 49: "ChargeAmps",
82
+ 50: "ChargeEnableRequest",
83
+ 51: "ChargerPhases",
84
+ 52: "ChargePortColdWeatherMode",
85
+ 53: "ChargeCurrentRequest",
86
+ 54: "ChargeCurrentRequestMax",
87
+ 57: "SuperchargerSessionTripPlanner",
88
+ 117: "ChargePort",
89
+ 118: "ChargePortLatch",
90
+ 179: "DetailedChargeState",
91
+ 183: "ChargePortDoorOpen",
92
+ 184: "ChargerVoltage",
93
+ 185: "ChargingCableType",
94
+ 190: "EstimatedHoursToChargeTermination",
95
+ 193: "FastChargerType",
96
+ 256: "ChargeRateMilePerHour",
97
+ # --- Climate / HVAC ---
98
+ 85: "InsideTemp",
99
+ 86: "OutsideTemp",
100
+ 87: "SeatHeaterLeft",
101
+ 88: "SeatHeaterRight",
102
+ 89: "SeatHeaterRearLeft",
103
+ 90: "SeatHeaterRearRight",
104
+ 91: "SeatHeaterRearCenter",
105
+ 92: "AutoSeatClimateLeft",
106
+ 93: "AutoSeatClimateRight",
107
+ 186: "ClimateKeeperMode",
108
+ 187: "DefrostForPreconditioning",
109
+ 188: "DefrostMode",
110
+ 196: "HvacACEnabled",
111
+ 197: "HvacAutoMode",
112
+ 198: "HvacFanSpeed",
113
+ 199: "HvacFanStatus",
114
+ 200: "HvacLeftTemperatureRequest",
115
+ 201: "HvacPower",
116
+ 202: "HvacRightTemperatureRequest",
117
+ 203: "HvacSteeringWheelHeatAuto",
118
+ 204: "HvacSteeringWheelHeatLevel",
119
+ 211: "RearDisplayHvacEnabled",
120
+ 237: "ClimateSeatCoolingFrontLeft",
121
+ 238: "ClimateSeatCoolingFrontRight",
122
+ 254: "SeatVentEnabled",
123
+ 255: "RearDefrostEnabled",
124
+ 180: "CabinOverheatProtectionMode",
125
+ 181: "CabinOverheatProtectionTemperatureLimit",
126
+ # --- Security / Doors / Windows ---
127
+ 58: "DoorState",
128
+ 59: "Locked",
129
+ 60: "FdWindow",
130
+ 61: "FpWindow",
131
+ 62: "RdWindow",
132
+ 63: "RpWindow",
133
+ 64: "VehicleName",
134
+ 65: "SentryMode",
135
+ 66: "SpeedLimitMode",
136
+ 67: "CurrentLimitMph",
137
+ 68: "Version",
138
+ 94: "DriverSeatBelt",
139
+ 95: "PassengerSeatBelt",
140
+ 96: "DriverSeatOccupied",
141
+ 123: "GuestModeEnabled",
142
+ 124: "PinToDriveEnabled",
143
+ 125: "PairedPhoneKeyAndKeyFobQty",
144
+ 159: "ServiceMode",
145
+ 161: "GuestModeMobileAccessState",
146
+ 182: "CenterDisplay",
147
+ 213: "RemoteStartEnabled",
148
+ 226: "ValetModeEnabled",
149
+ # --- Tires ---
150
+ 69: "TpmsPressureFl",
151
+ 70: "TpmsPressureFr",
152
+ 71: "TpmsPressureRl",
153
+ 72: "TpmsPressureRr",
154
+ 81: "TpmsLastSeenPressureTimeFl",
155
+ 82: "TpmsLastSeenPressureTimeFr",
156
+ 83: "TpmsLastSeenPressureTimeRl",
157
+ 84: "TpmsLastSeenPressureTimeRr",
158
+ 224: "TpmsHardWarnings",
159
+ 225: "TpmsSoftWarnings",
160
+ # --- Drive Inverter (per-motor diagnostics) ---
161
+ 14: "DiStateR",
162
+ 15: "DiHeatsinkTR",
163
+ 16: "DiAxleSpeedR",
164
+ 17: "DiTorquemotor",
165
+ 18: "DiStatorTempR",
166
+ 19: "DiVBatR",
167
+ 20: "DiMotorCurrentR",
168
+ 135: "DiStateF",
169
+ 136: "DiStateREL",
170
+ 137: "DiStateRER",
171
+ 138: "DiHeatsinkTF",
172
+ 139: "DiHeatsinkTREL",
173
+ 140: "DiHeatsinkTRER",
174
+ 141: "DiAxleSpeedF",
175
+ 142: "DiAxleSpeedREL",
176
+ 143: "DiAxleSpeedRER",
177
+ 144: "DiSlaveTorqueCmd",
178
+ 145: "DiTorqueActualR",
179
+ 146: "DiTorqueActualF",
180
+ 147: "DiTorqueActualREL",
181
+ 148: "DiTorqueActualRER",
182
+ 149: "DiStatorTempF",
183
+ 150: "DiStatorTempREL",
184
+ 151: "DiStatorTempRER",
185
+ 152: "DiVBatF",
186
+ 153: "DiVBatREL",
187
+ 154: "DiVBatRER",
188
+ 155: "DiMotorCurrentF",
189
+ 156: "DiMotorCurrentREL",
190
+ 157: "DiMotorCurrentRER",
191
+ 164: "DiInverterTR",
192
+ 165: "DiInverterTF",
193
+ 166: "DiInverterTREL",
194
+ 167: "DiInverterTRER",
195
+ # --- Navigation / Route ---
196
+ 107: "RouteLastUpdated",
197
+ 108: "RouteLine",
198
+ 109: "MilesToArrival",
199
+ 110: "MinutesToArrival",
200
+ 111: "OriginLocation",
201
+ 112: "DestinationLocation",
202
+ 163: "DestinationName",
203
+ 215: "RouteTrafficMinutesDelay",
204
+ 192: "ExpectedEnergyPercentAtTripArrival",
205
+ # --- Vehicle Info / Config ---
206
+ 113: "CarType",
207
+ 114: "Trim",
208
+ 115: "ExteriorColor",
209
+ 116: "RoofColor",
210
+ 189: "EfficiencyPackage",
211
+ 191: "EuropeVehicle",
212
+ 214: "RightHandDrive",
213
+ 227: "WheelType",
214
+ 228: "WiperHeatEnabled",
215
+ # --- Safety / ADAS ---
216
+ 127: "AutomaticBlindSpotCamera",
217
+ 128: "BlindSpotCollisionWarningChime",
218
+ 130: "ForwardCollisionWarning",
219
+ 131: "LaneDepartureAvoidance",
220
+ 132: "EmergencyLaneDepartureAvoidance",
221
+ 133: "AutomaticEmergencyBrakingOff",
222
+ # --- Powershare ---
223
+ 206: "PowershareHoursLeft",
224
+ 207: "PowershareInstantaneousPowerKW",
225
+ 208: "PowershareStatus",
226
+ 209: "PowershareStopReason",
227
+ 210: "PowershareType",
228
+ # --- Homelink ---
229
+ 194: "HomelinkDeviceCount",
230
+ 195: "HomelinkNearby",
231
+ # --- Software Updates ---
232
+ 216: "SoftwareUpdateDownloadPercentComplete",
233
+ 217: "SoftwareUpdateExpectedDurationMinutes",
234
+ 218: "SoftwareUpdateInstallationPercentComplete",
235
+ 219: "SoftwareUpdateScheduledStartTime",
236
+ 220: "SoftwareUpdateVersion",
237
+ # --- Tonneau ---
238
+ 221: "TonneauOpenPercent",
239
+ 222: "TonneauPosition",
240
+ 223: "TonneauTentMode",
241
+ # --- Location Context ---
242
+ 229: "LocatedAtHome",
243
+ 230: "LocatedAtWork",
244
+ 231: "LocatedAtFavorite",
245
+ # --- Settings ---
246
+ 232: "SettingDistanceUnit",
247
+ 233: "SettingTemperatureUnit",
248
+ 234: "Setting24HourTime",
249
+ 235: "SettingTirePressureUnit",
250
+ 236: "SettingChargeUnit",
251
+ # --- Lights ---
252
+ 239: "LightsHazardsActive",
253
+ 240: "LightsTurnSignal",
254
+ 241: "LightsHighBeams",
255
+ # --- Media ---
256
+ 242: "MediaPlaybackStatus",
257
+ 243: "MediaPlaybackSource",
258
+ 244: "MediaAudioVolume",
259
+ 245: "MediaNowPlayingDuration",
260
+ 246: "MediaNowPlayingElapsed",
261
+ 247: "MediaNowPlayingArtist",
262
+ 248: "MediaNowPlayingTitle",
263
+ 249: "MediaNowPlayingAlbum",
264
+ 250: "MediaNowPlayingStation",
265
+ 251: "MediaAudioVolumeIncrement",
266
+ 252: "MediaAudioVolumeMax",
267
+ # --- Misc ---
268
+ 205: "OffroadLightbarPresent",
269
+ 212: "RearSeatHeaters",
270
+ 253: "SunroofInstalled",
271
+ 258: "MilesSinceReset",
272
+ 259: "SelfDrivingMilesSinceReset",
273
+ # --- Semi-truck (included for completeness, excluded from presets) ---
274
+ 73: "SemitruckTpmsPressureRe1L0",
275
+ 74: "SemitruckTpmsPressureRe1L1",
276
+ 75: "SemitruckTpmsPressureRe1R0",
277
+ 76: "SemitruckTpmsPressureRe1R1",
278
+ 77: "SemitruckTpmsPressureRe2L0",
279
+ 78: "SemitruckTpmsPressureRe2L1",
280
+ 79: "SemitruckTpmsPressureRe2R0",
281
+ 80: "SemitruckTpmsPressureRe2R1",
282
+ 97: "SemitruckPassengerSeatFoldPosition",
283
+ 104: "SemitruckTractorParkBrakeStatus",
284
+ 105: "SemitruckTrailerParkBrakeStatus",
285
+ }
286
+
287
+ # ---------------------------------------------------------------------------
288
+ # Fields that exist in vehicle_data.proto but should be excluded from the
289
+ # "all" preset. Semi-truck fields won't work on consumer vehicles.
290
+ # LifetimeEnergyGainedRegen returns "unsupported_field" on many vehicles.
291
+ # ---------------------------------------------------------------------------
292
+
293
+ _NON_STREAMABLE_FIELDS: frozenset[str] = frozenset(
294
+ {name for name in FIELD_NAMES.values() if name.startswith("Semitruck")}
295
+ | {
296
+ "LifetimeEnergyGainedRegen", # returns "unsupported_field" on many vehicles
297
+ }
298
+ )
299
+
300
+ # Fields that require minimum_delta instead of interval_seconds.
301
+ # Tesla's API rejects these if minimum_delta is not explicitly set >= 1.
302
+ _DELTA_FIELDS: frozenset[str] = frozenset(
303
+ {
304
+ "MilesSinceReset",
305
+ "SelfDrivingMilesSinceReset",
306
+ }
307
+ )
308
+
309
+ # ---------------------------------------------------------------------------
310
+ # Preset field configurations
311
+ # ---------------------------------------------------------------------------
312
+
313
+ DEFAULT_FIELDS: dict[str, dict[str, int]] = {
314
+ "Soc": {"interval_seconds": 10},
315
+ "VehicleSpeed": {"interval_seconds": 1},
316
+ "Location": {"interval_seconds": 5},
317
+ "ChargeState": {"interval_seconds": 10},
318
+ "InsideTemp": {"interval_seconds": 30},
319
+ "OutsideTemp": {"interval_seconds": 60},
320
+ "Odometer": {"interval_seconds": 60},
321
+ "BatteryLevel": {"interval_seconds": 10},
322
+ "Gear": {"interval_seconds": 1},
323
+ "PackVoltage": {"interval_seconds": 10},
324
+ "PackCurrent": {"interval_seconds": 10},
325
+ }
326
+
327
+ PRESETS: dict[str, dict[str, dict[str, int]]] = {
328
+ "default": DEFAULT_FIELDS,
329
+ "driving": {
330
+ "VehicleSpeed": {"interval_seconds": 1},
331
+ "Location": {"interval_seconds": 1},
332
+ "Gear": {"interval_seconds": 1},
333
+ "GpsHeading": {"interval_seconds": 1},
334
+ "Odometer": {"interval_seconds": 10},
335
+ "BatteryLevel": {"interval_seconds": 10},
336
+ "Soc": {"interval_seconds": 10},
337
+ "PackCurrent": {"interval_seconds": 5},
338
+ "PackVoltage": {"interval_seconds": 5},
339
+ "CruiseSetSpeed": {"interval_seconds": 5},
340
+ "LateralAcceleration": {"interval_seconds": 5},
341
+ "LongitudinalAcceleration": {"interval_seconds": 5},
342
+ "BrakePedalPos": {"interval_seconds": 5},
343
+ "PedalPosition": {"interval_seconds": 5},
344
+ },
345
+ "charging": {
346
+ "Soc": {"interval_seconds": 5},
347
+ "BatteryLevel": {"interval_seconds": 5},
348
+ "PackVoltage": {"interval_seconds": 5},
349
+ "PackCurrent": {"interval_seconds": 5},
350
+ "ChargeState": {"interval_seconds": 5},
351
+ "ChargeAmps": {"interval_seconds": 5},
352
+ "ChargerVoltage": {"interval_seconds": 5},
353
+ "ChargerPhases": {"interval_seconds": 30},
354
+ "ACChargingPower": {"interval_seconds": 5},
355
+ "DCChargingPower": {"interval_seconds": 5},
356
+ "TimeToFullCharge": {"interval_seconds": 30},
357
+ "ChargeLimitSoc": {"interval_seconds": 60},
358
+ "ChargePortDoorOpen": {"interval_seconds": 60},
359
+ "BatteryHeaterOn": {"interval_seconds": 30},
360
+ "InsideTemp": {"interval_seconds": 60},
361
+ },
362
+ "climate": {
363
+ "InsideTemp": {"interval_seconds": 10},
364
+ "OutsideTemp": {"interval_seconds": 30},
365
+ "HvacLeftTemperatureRequest": {"interval_seconds": 30},
366
+ "HvacRightTemperatureRequest": {"interval_seconds": 30},
367
+ "HvacPower": {"interval_seconds": 10},
368
+ "HvacFanStatus": {"interval_seconds": 10},
369
+ "SeatHeaterLeft": {"interval_seconds": 30},
370
+ "SeatHeaterRight": {"interval_seconds": 30},
371
+ "HvacSteeringWheelHeatLevel": {"interval_seconds": 30},
372
+ "CabinOverheatProtectionMode": {"interval_seconds": 60},
373
+ "DefrostMode": {"interval_seconds": 30},
374
+ "PreconditioningEnabled": {"interval_seconds": 30},
375
+ },
376
+ "all": {
377
+ name: (
378
+ {"interval_seconds": 30, "minimum_delta": 1}
379
+ if name in _DELTA_FIELDS
380
+ else {"interval_seconds": 30}
381
+ )
382
+ for name in FIELD_NAMES.values()
383
+ if name not in _NON_STREAMABLE_FIELDS
384
+ },
385
+ }
386
+
387
+ # Reverse lookup: name → ID
388
+ _NAME_TO_ID: dict[str, int] = {v: k for k, v in FIELD_NAMES.items()}
389
+
390
+
391
+ def resolve_fields(
392
+ spec: str,
393
+ interval_override: int | None = None,
394
+ ) -> dict[str, dict[str, int]]:
395
+ """Resolve a ``--fields`` argument to a field configuration dict.
396
+
397
+ Args:
398
+ spec: A preset name (e.g. ``"default"``, ``"charging"``) or a
399
+ comma-separated list of field names (e.g. ``"Soc,VehicleSpeed"``).
400
+ interval_override: If set, overrides ``interval_seconds`` for all fields.
401
+
402
+ Returns:
403
+ A dict mapping field names to ``{"interval_seconds": N}``.
404
+
405
+ Raises:
406
+ ConfigError: If a field name or preset is unrecognized.
407
+ """
408
+ if spec in PRESETS:
409
+ fields = dict(PRESETS[spec])
410
+ else:
411
+ # Comma-separated field names
412
+ fields = {}
413
+ for name in spec.split(","):
414
+ name = name.strip()
415
+ if not name:
416
+ continue
417
+ if name not in _NAME_TO_ID:
418
+ raise ConfigError(
419
+ f"Unknown telemetry field: '{name}'. "
420
+ f"Available presets: {', '.join(sorted(PRESETS.keys()))}"
421
+ )
422
+ fields[name] = {"interval_seconds": 10} # reasonable default
423
+
424
+ if interval_override is not None:
425
+ fields = {name: {"interval_seconds": interval_override} for name in fields}
426
+
427
+ 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)