tescmd 0.1.2__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 (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
tescmd/api/command.py ADDED
@@ -0,0 +1,540 @@
1
+ """Vehicle command API — wraps POST /api/1/vehicles/{vin}/command/{name}."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from tescmd.models.command import CommandResponse
8
+
9
+ if TYPE_CHECKING:
10
+ from tescmd.api.client import TeslaFleetClient
11
+
12
+
13
+ class CommandAPI:
14
+ """Vehicle command operations (composition over :class:`TeslaFleetClient`)."""
15
+
16
+ def __init__(self, client: TeslaFleetClient) -> None:
17
+ self._client = client
18
+
19
+ async def _command(
20
+ self, vin: str, command: str, body: dict[str, Any] | None = None
21
+ ) -> CommandResponse:
22
+ path = f"/api/1/vehicles/{vin}/command/{command}"
23
+ kwargs: dict[str, Any] = {}
24
+ if body is not None:
25
+ kwargs["json"] = body
26
+ data = await self._client.post(path, **kwargs)
27
+ return CommandResponse.model_validate(data)
28
+
29
+ # ------------------------------------------------------------------
30
+ # Key enrollment
31
+ # ------------------------------------------------------------------
32
+ # Note: Initial key enrollment is NOT available via REST or signed_command.
33
+ # The Tesla Go SDK explicitly blocks add_key_request for Fleet API with
34
+ # ErrRequiresBLE. For Fleet API apps, enrollment happens through the
35
+ # Tesla app portal flow: https://tesla.com/_ak/<domain>
36
+ # See cli/key.py for the enrollment flow implementation.
37
+
38
+ # ------------------------------------------------------------------
39
+ # Security / convenience commands
40
+ # ------------------------------------------------------------------
41
+
42
+ async def auto_secure_vehicle(self, vin: str) -> CommandResponse:
43
+ """Close falcon-wing doors and lock (Model X only)."""
44
+ return await self._command(vin, "auto_secure_vehicle")
45
+
46
+ # ------------------------------------------------------------------
47
+ # Charging commands
48
+ # ------------------------------------------------------------------
49
+
50
+ async def charge_start(self, vin: str) -> CommandResponse:
51
+ return await self._command(vin, "charge_start")
52
+
53
+ async def charge_stop(self, vin: str) -> CommandResponse:
54
+ return await self._command(vin, "charge_stop")
55
+
56
+ async def charge_standard(self, vin: str) -> CommandResponse:
57
+ return await self._command(vin, "charge_standard")
58
+
59
+ async def charge_max_range(self, vin: str) -> CommandResponse:
60
+ return await self._command(vin, "charge_max_range")
61
+
62
+ async def charge_port_door_open(self, vin: str) -> CommandResponse:
63
+ return await self._command(vin, "charge_port_door_open")
64
+
65
+ async def charge_port_door_close(self, vin: str) -> CommandResponse:
66
+ return await self._command(vin, "charge_port_door_close")
67
+
68
+ async def set_charge_limit(self, vin: str, *, percent: int) -> CommandResponse:
69
+ return await self._command(vin, "set_charge_limit", {"percent": percent})
70
+
71
+ async def set_charging_amps(self, vin: str, *, charging_amps: int) -> CommandResponse:
72
+ return await self._command(vin, "set_charging_amps", {"charging_amps": charging_amps})
73
+
74
+ async def set_scheduled_charging(
75
+ self, vin: str, *, enable: bool, time: int
76
+ ) -> CommandResponse:
77
+ return await self._command(vin, "set_scheduled_charging", {"enable": enable, "time": time})
78
+
79
+ async def set_scheduled_departure(
80
+ self,
81
+ vin: str,
82
+ *,
83
+ enable: bool,
84
+ departure_time: int,
85
+ preconditioning_enabled: bool = False,
86
+ preconditioning_weekdays_only: bool = False,
87
+ off_peak_charging_enabled: bool = False,
88
+ off_peak_charging_weekdays_only: bool = False,
89
+ end_off_peak_time: int = 0,
90
+ ) -> CommandResponse:
91
+ return await self._command(
92
+ vin,
93
+ "set_scheduled_departure",
94
+ {
95
+ "enable": enable,
96
+ "departure_time": departure_time,
97
+ "preconditioning_enabled": preconditioning_enabled,
98
+ "preconditioning_weekdays_only": preconditioning_weekdays_only,
99
+ "off_peak_charging_enabled": off_peak_charging_enabled,
100
+ "off_peak_charging_weekdays_only": off_peak_charging_weekdays_only,
101
+ "end_off_peak_time": end_off_peak_time,
102
+ },
103
+ )
104
+
105
+ async def add_precondition_schedule(
106
+ self,
107
+ vin: str,
108
+ *,
109
+ lat: float | None = None,
110
+ lon: float | None = None,
111
+ precondition_time: int | None = None,
112
+ one_time: bool | None = None,
113
+ days_of_week: str | None = None,
114
+ id: int | None = None,
115
+ enabled: bool | None = None,
116
+ ) -> CommandResponse:
117
+ body: dict[str, Any] = {}
118
+ if lat is not None:
119
+ body["lat"] = lat
120
+ if lon is not None:
121
+ body["lon"] = lon
122
+ if precondition_time is not None:
123
+ body["precondition_time"] = precondition_time
124
+ if one_time is not None:
125
+ body["one_time"] = one_time
126
+ if days_of_week is not None:
127
+ body["days_of_week"] = days_of_week
128
+ if id is not None:
129
+ body["id"] = id
130
+ if enabled is not None:
131
+ body["enabled"] = enabled
132
+ return await self._command(vin, "add_precondition_schedule", body)
133
+
134
+ async def remove_precondition_schedule(self, vin: str, *, id: int) -> CommandResponse:
135
+ return await self._command(vin, "remove_precondition_schedule", {"id": id})
136
+
137
+ async def batch_remove_precondition_schedules(
138
+ self, vin: str, *, home: bool, work: bool, other: bool
139
+ ) -> CommandResponse:
140
+ """Remove precondition schedules by location type (home/work/other)."""
141
+ return await self._command(
142
+ vin,
143
+ "batch_remove_precondition_schedules",
144
+ {"home": home, "work": work, "other": other},
145
+ )
146
+
147
+ # ------------------------------------------------------------------
148
+ # Climate commands
149
+ # ------------------------------------------------------------------
150
+
151
+ async def auto_conditioning_start(self, vin: str) -> CommandResponse:
152
+ return await self._command(vin, "auto_conditioning_start")
153
+
154
+ async def auto_conditioning_stop(self, vin: str) -> CommandResponse:
155
+ return await self._command(vin, "auto_conditioning_stop")
156
+
157
+ async def set_temps(
158
+ self, vin: str, *, driver_temp: float, passenger_temp: float
159
+ ) -> CommandResponse:
160
+ return await self._command(
161
+ vin,
162
+ "set_temps",
163
+ {"driver_temp": driver_temp, "passenger_temp": passenger_temp},
164
+ )
165
+
166
+ async def set_preconditioning_max(
167
+ self, vin: str, *, on: bool, manual_override: bool = False
168
+ ) -> CommandResponse:
169
+ return await self._command(
170
+ vin, "set_preconditioning_max", {"on": on, "manual_override": manual_override}
171
+ )
172
+
173
+ async def remote_seat_heater_request(
174
+ self, vin: str, *, seat_position: int, level: int
175
+ ) -> CommandResponse:
176
+ return await self._command(
177
+ vin, "remote_seat_heater_request", {"seat_position": seat_position, "level": level}
178
+ )
179
+
180
+ async def remote_seat_cooler_request(
181
+ self, vin: str, *, seat_position: int, seat_cooler_level: int
182
+ ) -> CommandResponse:
183
+ return await self._command(
184
+ vin,
185
+ "remote_seat_cooler_request",
186
+ {"seat_position": seat_position, "seat_cooler_level": seat_cooler_level},
187
+ )
188
+
189
+ async def remote_steering_wheel_heater_request(self, vin: str, *, on: bool) -> CommandResponse:
190
+ return await self._command(vin, "remote_steering_wheel_heater_request", {"on": on})
191
+
192
+ async def set_cabin_overheat_protection(
193
+ self, vin: str, *, on: bool, fan_only: bool = False
194
+ ) -> CommandResponse:
195
+ return await self._command(
196
+ vin,
197
+ "set_cabin_overheat_protection",
198
+ {"on": on, "fan_only": fan_only},
199
+ )
200
+
201
+ async def set_climate_keeper_mode(
202
+ self, vin: str, *, climate_keeper_mode: int, manual_override: bool = False
203
+ ) -> CommandResponse:
204
+ return await self._command(
205
+ vin,
206
+ "set_climate_keeper_mode",
207
+ {"climate_keeper_mode": climate_keeper_mode, "manual_override": manual_override},
208
+ )
209
+
210
+ async def set_cop_temp(self, vin: str, *, cop_temp: int) -> CommandResponse:
211
+ """Set cabin overheat protection temperature (0=low, 1=medium, 2=high)."""
212
+ return await self._command(vin, "set_cop_temp", {"cop_temp": cop_temp})
213
+
214
+ async def remote_auto_seat_climate_request(
215
+ self, vin: str, *, auto_seat_position: int, auto_climate_on: bool
216
+ ) -> CommandResponse:
217
+ return await self._command(
218
+ vin,
219
+ "remote_auto_seat_climate_request",
220
+ {"auto_seat_position": auto_seat_position, "auto_climate_on": auto_climate_on},
221
+ )
222
+
223
+ async def remote_auto_steering_wheel_heat_climate_request(
224
+ self, vin: str, *, on: bool
225
+ ) -> CommandResponse:
226
+ return await self._command(
227
+ vin, "remote_auto_steering_wheel_heat_climate_request", {"on": on}
228
+ )
229
+
230
+ async def remote_steering_wheel_heat_level_request(
231
+ self, vin: str, *, level: int
232
+ ) -> CommandResponse:
233
+ return await self._command(
234
+ vin, "remote_steering_wheel_heat_level_request", {"level": level}
235
+ )
236
+
237
+ # ------------------------------------------------------------------
238
+ # Security commands
239
+ # ------------------------------------------------------------------
240
+
241
+ async def door_lock(self, vin: str) -> CommandResponse:
242
+ return await self._command(vin, "door_lock")
243
+
244
+ async def door_unlock(self, vin: str) -> CommandResponse:
245
+ return await self._command(vin, "door_unlock")
246
+
247
+ async def set_sentry_mode(self, vin: str, *, on: bool) -> CommandResponse:
248
+ return await self._command(vin, "set_sentry_mode", {"on": on})
249
+
250
+ async def set_valet_mode(
251
+ self, vin: str, *, on: bool, password: str | None = None
252
+ ) -> CommandResponse:
253
+ body: dict[str, Any] = {"on": on}
254
+ if password is not None:
255
+ body["password"] = password
256
+ return await self._command(vin, "set_valet_mode", body)
257
+
258
+ async def reset_valet_pin(self, vin: str) -> CommandResponse:
259
+ return await self._command(vin, "reset_valet_pin")
260
+
261
+ async def speed_limit_activate(self, vin: str, *, pin: str) -> CommandResponse:
262
+ return await self._command(vin, "speed_limit_activate", {"pin": pin})
263
+
264
+ async def speed_limit_deactivate(self, vin: str, *, pin: str) -> CommandResponse:
265
+ return await self._command(vin, "speed_limit_deactivate", {"pin": pin})
266
+
267
+ async def speed_limit_set_limit(self, vin: str, *, limit_mph: float) -> CommandResponse:
268
+ return await self._command(vin, "speed_limit_set_limit", {"limit_mph": limit_mph})
269
+
270
+ async def reset_pin_to_drive_pin(self, vin: str) -> CommandResponse:
271
+ return await self._command(vin, "reset_pin_to_drive_pin")
272
+
273
+ async def clear_pin_to_drive_admin(self, vin: str) -> CommandResponse:
274
+ return await self._command(vin, "clear_pin_to_drive_admin")
275
+
276
+ async def speed_limit_clear_pin(self, vin: str, *, pin: str) -> CommandResponse:
277
+ return await self._command(vin, "speed_limit_clear_pin", {"pin": pin})
278
+
279
+ async def speed_limit_clear_pin_admin(self, vin: str) -> CommandResponse:
280
+ return await self._command(vin, "speed_limit_clear_pin_admin")
281
+
282
+ async def remote_start_drive(self, vin: str) -> CommandResponse:
283
+ return await self._command(vin, "remote_start_drive")
284
+
285
+ async def flash_lights(self, vin: str) -> CommandResponse:
286
+ return await self._command(vin, "flash_lights")
287
+
288
+ async def honk_horn(self, vin: str) -> CommandResponse:
289
+ return await self._command(vin, "honk_horn")
290
+
291
+ # ------------------------------------------------------------------
292
+ # Media commands
293
+ # ------------------------------------------------------------------
294
+
295
+ async def media_toggle_playback(self, vin: str) -> CommandResponse:
296
+ return await self._command(vin, "media_toggle_playback")
297
+
298
+ async def media_next_track(self, vin: str) -> CommandResponse:
299
+ return await self._command(vin, "media_next_track")
300
+
301
+ async def media_prev_track(self, vin: str) -> CommandResponse:
302
+ return await self._command(vin, "media_prev_track")
303
+
304
+ async def media_next_fav(self, vin: str) -> CommandResponse:
305
+ return await self._command(vin, "media_next_fav")
306
+
307
+ async def media_prev_fav(self, vin: str) -> CommandResponse:
308
+ return await self._command(vin, "media_prev_fav")
309
+
310
+ async def media_volume_up(self, vin: str) -> CommandResponse:
311
+ return await self._command(vin, "media_volume_up")
312
+
313
+ async def media_volume_down(self, vin: str) -> CommandResponse:
314
+ return await self._command(vin, "media_volume_down")
315
+
316
+ async def adjust_volume(self, vin: str, *, volume: float) -> CommandResponse:
317
+ return await self._command(vin, "adjust_volume", {"volume": volume})
318
+
319
+ # ------------------------------------------------------------------
320
+ # Navigation commands
321
+ # ------------------------------------------------------------------
322
+
323
+ async def share(self, vin: str, *, address: str) -> CommandResponse:
324
+ import time as _time
325
+
326
+ body = {
327
+ "type": "share_ext_content_raw",
328
+ "value": {"android.intent.extra.TEXT": address},
329
+ "locale": "en-US",
330
+ "timestamp_ms": str(int(_time.time() * 1000)),
331
+ }
332
+ path = f"/api/1/vehicles/{vin}/command/share"
333
+ data = await self._client.post(path, json=body)
334
+ return CommandResponse.model_validate(data)
335
+
336
+ async def navigation_gps_request(
337
+ self, vin: str, *, lat: float, lon: float, order: int | None = None
338
+ ) -> CommandResponse:
339
+ body: dict[str, Any] = {"lat": lat, "lon": lon}
340
+ if order is not None:
341
+ body["order"] = order
342
+ return await self._command(vin, "navigation_gps_request", body)
343
+
344
+ async def navigation_sc_request(
345
+ self, vin: str, *, id: int = 0, order: int = 0
346
+ ) -> CommandResponse:
347
+ return await self._command(vin, "navigation_sc_request", {"id": id, "order": order})
348
+
349
+ async def trigger_homelink(self, vin: str, *, lat: float, lon: float) -> CommandResponse:
350
+ return await self._command(vin, "trigger_homelink", {"lat": lat, "lon": lon})
351
+
352
+ async def navigation_request(
353
+ self,
354
+ vin: str,
355
+ *,
356
+ type: str = "share_ext_content_raw",
357
+ locale: str = "en-US",
358
+ timestamp_ms: str = "",
359
+ value: dict[str, Any] | None = None,
360
+ ) -> CommandResponse:
361
+ """Legacy navigation request (REST-only, deprecated in favour of 'share')."""
362
+ import time as _time
363
+
364
+ body: dict[str, Any] = {
365
+ "type": type,
366
+ "locale": locale,
367
+ "timestamp_ms": timestamp_ms or str(int(_time.time() * 1000)),
368
+ }
369
+ if value is not None:
370
+ body["value"] = value
371
+ path = f"/api/1/vehicles/{vin}/command/navigation_request"
372
+ data = await self._client.post(path, json=body)
373
+ return CommandResponse.model_validate(data)
374
+
375
+ async def navigation_waypoints_request(self, vin: str, *, waypoints: str) -> CommandResponse:
376
+ return await self._command(vin, "navigation_waypoints_request", {"waypoints": waypoints})
377
+
378
+ # ------------------------------------------------------------------
379
+ # Software commands
380
+ # ------------------------------------------------------------------
381
+
382
+ async def schedule_software_update(self, vin: str, *, offset_sec: int) -> CommandResponse:
383
+ return await self._command(vin, "schedule_software_update", {"offset_sec": offset_sec})
384
+
385
+ async def cancel_software_update(self, vin: str) -> CommandResponse:
386
+ return await self._command(vin, "cancel_software_update")
387
+
388
+ # ------------------------------------------------------------------
389
+ # Bioweapon / sunroof / PIN-to-drive / guest-mode / erase / boombox
390
+ # ------------------------------------------------------------------
391
+
392
+ async def set_bioweapon_mode(
393
+ self, vin: str, *, on: bool, manual_override: bool = False
394
+ ) -> CommandResponse:
395
+ return await self._command(
396
+ vin, "set_bioweapon_mode", {"on": on, "manual_override": manual_override}
397
+ )
398
+
399
+ async def sun_roof_control(self, vin: str, *, state: str) -> CommandResponse:
400
+ return await self._command(vin, "sun_roof_control", {"state": state})
401
+
402
+ async def set_pin_to_drive(
403
+ self, vin: str, *, on: bool, password: str | None = None
404
+ ) -> CommandResponse:
405
+ body: dict[str, Any] = {"on": on}
406
+ if password is not None:
407
+ body["password"] = password
408
+ return await self._command(vin, "set_pin_to_drive", body)
409
+
410
+ async def guest_mode(self, vin: str, *, enable: bool) -> CommandResponse:
411
+ return await self._command(vin, "guest_mode", {"enable": enable})
412
+
413
+ async def erase_user_data(self, vin: str) -> CommandResponse:
414
+ return await self._command(vin, "erase_user_data")
415
+
416
+ async def remote_boombox(self, vin: str, *, sound: int = 2000) -> CommandResponse:
417
+ return await self._command(vin, "remote_boombox", {"sound": sound})
418
+
419
+ # ------------------------------------------------------------------
420
+ # Charge schedule commands (firmware 2024.26+)
421
+ # ------------------------------------------------------------------
422
+
423
+ async def add_charge_schedule(
424
+ self,
425
+ vin: str,
426
+ *,
427
+ lat: float | None = None,
428
+ lon: float | None = None,
429
+ start_time: int | None = None,
430
+ start_enabled: bool | None = None,
431
+ end_time: int | None = None,
432
+ end_enabled: bool | None = None,
433
+ days_of_week: str | None = None,
434
+ id: int | None = None,
435
+ enabled: bool | None = None,
436
+ one_time: bool | None = None,
437
+ ) -> CommandResponse:
438
+ body: dict[str, Any] = {}
439
+ if lat is not None:
440
+ body["lat"] = lat
441
+ if lon is not None:
442
+ body["lon"] = lon
443
+ if start_time is not None:
444
+ body["start_time"] = start_time
445
+ if start_enabled is not None:
446
+ body["start_enabled"] = start_enabled
447
+ if end_time is not None:
448
+ body["end_time"] = end_time
449
+ if end_enabled is not None:
450
+ body["end_enabled"] = end_enabled
451
+ if days_of_week is not None:
452
+ body["days_of_week"] = days_of_week
453
+ if id is not None:
454
+ body["id"] = id
455
+ if enabled is not None:
456
+ body["enabled"] = enabled
457
+ if one_time is not None:
458
+ body["one_time"] = one_time
459
+ return await self._command(vin, "add_charge_schedule", body)
460
+
461
+ async def remove_charge_schedule(self, vin: str, *, id: int) -> CommandResponse:
462
+ return await self._command(vin, "remove_charge_schedule", {"id": id})
463
+
464
+ async def batch_remove_charge_schedules(
465
+ self, vin: str, *, home: bool, work: bool, other: bool
466
+ ) -> CommandResponse:
467
+ """Remove charge schedules by location type (home/work/other)."""
468
+ return await self._command(
469
+ vin,
470
+ "batch_remove_charge_schedules",
471
+ {"home": home, "work": work, "other": other},
472
+ )
473
+
474
+ # ------------------------------------------------------------------
475
+ # Vehicle name / calendar
476
+ # ------------------------------------------------------------------
477
+
478
+ async def set_vehicle_name(self, vin: str, *, vehicle_name: str) -> CommandResponse:
479
+ return await self._command(vin, "set_vehicle_name", {"vehicle_name": vehicle_name})
480
+
481
+ async def upcoming_calendar_entries(self, vin: str, *, calendar_data: str) -> CommandResponse:
482
+ return await self._command(
483
+ vin, "upcoming_calendar_entries", {"calendar_data": calendar_data}
484
+ )
485
+
486
+ # ------------------------------------------------------------------
487
+ # Trunk / window commands
488
+ # ------------------------------------------------------------------
489
+
490
+ async def actuate_trunk(self, vin: str, *, which_trunk: str) -> CommandResponse:
491
+ return await self._command(vin, "actuate_trunk", {"which_trunk": which_trunk})
492
+
493
+ async def window_control(
494
+ self, vin: str, *, command: str, lat: float, lon: float
495
+ ) -> CommandResponse:
496
+ return await self._command(
497
+ vin, "window_control", {"command": command, "lat": lat, "lon": lon}
498
+ )
499
+
500
+ # ------------------------------------------------------------------
501
+ # Tonneau cover commands (Cybertruck)
502
+ # ------------------------------------------------------------------
503
+
504
+ async def open_tonneau(self, vin: str) -> CommandResponse:
505
+ return await self._command(vin, "open_tonneau")
506
+
507
+ async def close_tonneau(self, vin: str) -> CommandResponse:
508
+ return await self._command(vin, "close_tonneau")
509
+
510
+ async def stop_tonneau(self, vin: str) -> CommandResponse:
511
+ return await self._command(vin, "stop_tonneau")
512
+
513
+ # ------------------------------------------------------------------
514
+ # Power management commands
515
+ # ------------------------------------------------------------------
516
+
517
+ async def set_low_power_mode(self, vin: str, *, enable: bool) -> CommandResponse:
518
+ return await self._command(vin, "set_low_power_mode", {"enable": enable})
519
+
520
+ async def keep_accessory_power_mode(self, vin: str, *, enable: bool) -> CommandResponse:
521
+ return await self._command(vin, "keep_accessory_power_mode", {"enable": enable})
522
+
523
+ # ------------------------------------------------------------------
524
+ # Managed charging (fleet)
525
+ # ------------------------------------------------------------------
526
+
527
+ async def set_managed_charge_current_request(
528
+ self, vin: str, *, charging_amps: int
529
+ ) -> CommandResponse:
530
+ return await self._command(
531
+ vin, "set_managed_charge_current_request", {"charging_amps": charging_amps}
532
+ )
533
+
534
+ async def set_managed_charger_location(
535
+ self, vin: str, *, location: dict[str, Any]
536
+ ) -> CommandResponse:
537
+ return await self._command(vin, "set_managed_charger_location", location)
538
+
539
+ async def set_managed_scheduled_charging_time(self, vin: str, *, time: int) -> CommandResponse:
540
+ return await self._command(vin, "set_managed_scheduled_charging_time", {"time": time})
tescmd/api/energy.py ADDED
@@ -0,0 +1,146 @@
1
+ """Energy site API — wraps /api/1/energy_sites/{site_id} endpoints."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ from tescmd.models.energy import CalendarHistory, LiveStatus, SiteInfo
8
+
9
+ if TYPE_CHECKING:
10
+ from tescmd.api.client import TeslaFleetClient
11
+
12
+
13
+ class EnergyAPI:
14
+ """Energy site operations (Powerwall, Solar, etc.)."""
15
+
16
+ def __init__(self, client: TeslaFleetClient) -> None:
17
+ self._client = client
18
+
19
+ async def list_products(self) -> list[dict[str, Any]]:
20
+ """Return all energy products on the account."""
21
+ data = await self._client.get("/api/1/products")
22
+ result: list[dict[str, Any]] = data.get("response", [])
23
+ return result
24
+
25
+ async def live_status(self, site_id: int) -> LiveStatus:
26
+ """Fetch real-time power flow for a site."""
27
+ data = await self._client.get(f"/api/1/energy_sites/{site_id}/live_status")
28
+ return LiveStatus.model_validate(data.get("response", {}))
29
+
30
+ async def site_info(self, site_id: int) -> SiteInfo:
31
+ """Fetch site configuration and metadata."""
32
+ data = await self._client.get(f"/api/1/energy_sites/{site_id}/site_info")
33
+ return SiteInfo.model_validate(data.get("response", {}))
34
+
35
+ async def set_backup_reserve(self, site_id: int, *, percent: int) -> dict[str, Any]:
36
+ """Set the backup reserve percentage."""
37
+ data = await self._client.post(
38
+ f"/api/1/energy_sites/{site_id}/backup",
39
+ json={"backup_reserve_percent": percent},
40
+ )
41
+ result: dict[str, Any] = data.get("response", {})
42
+ return result
43
+
44
+ async def set_operation_mode(self, site_id: int, *, mode: str) -> dict[str, Any]:
45
+ """Set the site operation mode (self_consumption, backup, autonomous)."""
46
+ data = await self._client.post(
47
+ f"/api/1/energy_sites/{site_id}/operation",
48
+ json={"default_real_mode": mode},
49
+ )
50
+ result: dict[str, Any] = data.get("response", {})
51
+ return result
52
+
53
+ async def set_storm_mode(self, site_id: int, *, enabled: bool) -> dict[str, Any]:
54
+ """Enable or disable storm watch."""
55
+ data = await self._client.post(
56
+ f"/api/1/energy_sites/{site_id}/storm_mode",
57
+ json={"enabled": enabled},
58
+ )
59
+ result: dict[str, Any] = data.get("response", {})
60
+ return result
61
+
62
+ async def time_of_use_settings(
63
+ self, site_id: int, *, settings: dict[str, Any]
64
+ ) -> dict[str, Any]:
65
+ """Set time-of-use schedule."""
66
+ data = await self._client.post(
67
+ f"/api/1/energy_sites/{site_id}/time_of_use_settings",
68
+ json={"tou_settings": settings},
69
+ )
70
+ result: dict[str, Any] = data.get("response", {})
71
+ return result
72
+
73
+ async def charging_history(self, site_id: int) -> CalendarHistory:
74
+ """Fetch charging history for a site (wall connector telemetry)."""
75
+ data = await self._client.get(
76
+ f"/api/1/energy_sites/{site_id}/telemetry_history",
77
+ params={"kind": "charge"},
78
+ )
79
+ return CalendarHistory.model_validate(data.get("response", {}))
80
+
81
+ async def off_grid_vehicle_charging_reserve(
82
+ self, site_id: int, *, reserve: int
83
+ ) -> dict[str, Any]:
84
+ """Set off-grid EV charging reserve percentage."""
85
+ data = await self._client.post(
86
+ f"/api/1/energy_sites/{site_id}/off_grid_vehicle_charging_reserve",
87
+ json={"off_grid_vehicle_charging_reserve_percent": reserve},
88
+ )
89
+ result: dict[str, Any] = data.get("response", {})
90
+ return result
91
+
92
+ async def grid_import_export(self, site_id: int, *, config: dict[str, Any]) -> dict[str, Any]:
93
+ """Set grid import/export configuration."""
94
+ data = await self._client.post(
95
+ f"/api/1/energy_sites/{site_id}/grid_import_export",
96
+ json=config,
97
+ )
98
+ result: dict[str, Any] = data.get("response", {})
99
+ return result
100
+
101
+ async def telemetry_history(
102
+ self,
103
+ site_id: int,
104
+ *,
105
+ kind: str = "charge",
106
+ start_date: str | None = None,
107
+ end_date: str | None = None,
108
+ time_zone: str | None = None,
109
+ ) -> CalendarHistory:
110
+ """Fetch telemetry-based charge history for a site."""
111
+ params: dict[str, str] = {"kind": kind}
112
+ if start_date:
113
+ params["start_date"] = start_date
114
+ if end_date:
115
+ params["end_date"] = end_date
116
+ if time_zone:
117
+ params["time_zone"] = time_zone
118
+ data = await self._client.get(
119
+ f"/api/1/energy_sites/{site_id}/telemetry_history",
120
+ params=params,
121
+ )
122
+ return CalendarHistory.model_validate(data.get("response", {}))
123
+
124
+ async def calendar_history(
125
+ self,
126
+ site_id: int,
127
+ *,
128
+ kind: str = "energy",
129
+ period: str = "day",
130
+ start_date: str | None = None,
131
+ end_date: str | None = None,
132
+ time_zone: str | None = None,
133
+ ) -> CalendarHistory:
134
+ """Fetch calendar-based history for a site."""
135
+ params: dict[str, str] = {"kind": kind, "period": period}
136
+ if start_date:
137
+ params["start_date"] = start_date
138
+ if end_date:
139
+ params["end_date"] = end_date
140
+ if time_zone:
141
+ params["time_zone"] = time_zone
142
+ data = await self._client.get(
143
+ f"/api/1/energy_sites/{site_id}/calendar_history",
144
+ params=params,
145
+ )
146
+ return CalendarHistory.model_validate(data.get("response", {}))