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/cli/charge.py ADDED
@@ -0,0 +1,610 @@
1
+ """CLI commands for charging operations."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import TYPE_CHECKING
7
+
8
+ import click
9
+
10
+ from tescmd._internal.async_utils import run_async
11
+ from tescmd.cli._client import (
12
+ auto_wake,
13
+ cached_vehicle_data,
14
+ execute_command,
15
+ get_command_api,
16
+ get_vehicle_api,
17
+ invalidate_cache_for_vin,
18
+ require_vin,
19
+ )
20
+ from tescmd.cli._options import global_options
21
+
22
+ if TYPE_CHECKING:
23
+ from tescmd.cli.main import AppContext
24
+
25
+ charge_group = click.Group("charge", help="Charging commands")
26
+
27
+
28
+ # ---------------------------------------------------------------------------
29
+ # Status (read via VehicleAPI)
30
+ # ---------------------------------------------------------------------------
31
+
32
+
33
+ @charge_group.command("status")
34
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
35
+ @global_options
36
+ def status_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
37
+ """Show current charging status."""
38
+ run_async(_cmd_status(app_ctx, vin_positional))
39
+
40
+
41
+ async def _cmd_status(app_ctx: AppContext, vin_positional: str | None) -> None:
42
+ formatter = app_ctx.formatter
43
+ vin = require_vin(vin_positional, app_ctx.vin)
44
+ client, api = get_vehicle_api(app_ctx)
45
+ try:
46
+ vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=["charge_state"])
47
+ finally:
48
+ await client.close()
49
+
50
+ if formatter.format == "json":
51
+ cs = vdata.charge_state
52
+ formatter.output(
53
+ cs.model_dump(exclude_none=True) if cs else {},
54
+ command="charge.status",
55
+ )
56
+ else:
57
+ if vdata.charge_state:
58
+ formatter.rich.charge_status(vdata.charge_state)
59
+ else:
60
+ formatter.rich.info("No charge state data available.")
61
+
62
+
63
+ # ---------------------------------------------------------------------------
64
+ # Simple commands (write via CommandAPI)
65
+ # ---------------------------------------------------------------------------
66
+
67
+
68
+ @charge_group.command("start")
69
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
70
+ @global_options
71
+ def start_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
72
+ """Start charging."""
73
+ run_async(
74
+ execute_command(
75
+ app_ctx,
76
+ vin_positional,
77
+ "charge_start",
78
+ "charge.start",
79
+ success_message="Charging started.",
80
+ )
81
+ )
82
+
83
+
84
+ @charge_group.command("stop")
85
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
86
+ @global_options
87
+ def stop_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
88
+ """Stop charging."""
89
+ run_async(
90
+ execute_command(
91
+ app_ctx,
92
+ vin_positional,
93
+ "charge_stop",
94
+ "charge.stop",
95
+ success_message="Charging stopped.",
96
+ )
97
+ )
98
+
99
+
100
+ @charge_group.command("limit-max")
101
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
102
+ @global_options
103
+ def limit_max_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
104
+ """Set charge limit to maximum range."""
105
+ run_async(
106
+ execute_command(
107
+ app_ctx,
108
+ vin_positional,
109
+ "charge_max_range",
110
+ "charge.limit-max",
111
+ success_message="Charge limit set to maximum range.",
112
+ )
113
+ )
114
+
115
+
116
+ @charge_group.command("limit-std")
117
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
118
+ @global_options
119
+ def limit_std_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
120
+ """Set charge limit to standard range."""
121
+ run_async(
122
+ execute_command(
123
+ app_ctx,
124
+ vin_positional,
125
+ "charge_standard",
126
+ "charge.limit-std",
127
+ success_message="Charge limit set to standard range.",
128
+ )
129
+ )
130
+
131
+
132
+ @charge_group.command("port-open")
133
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
134
+ @global_options
135
+ def port_open_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
136
+ """Open the charge port door."""
137
+ run_async(
138
+ execute_command(
139
+ app_ctx,
140
+ vin_positional,
141
+ "charge_port_door_open",
142
+ "charge.port-open",
143
+ success_message="Charge port opened.",
144
+ )
145
+ )
146
+
147
+
148
+ @charge_group.command("port-close")
149
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
150
+ @global_options
151
+ def port_close_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
152
+ """Close the charge port door."""
153
+ run_async(
154
+ execute_command(
155
+ app_ctx,
156
+ vin_positional,
157
+ "charge_port_door_close",
158
+ "charge.port-close",
159
+ success_message="Charge port closed.",
160
+ )
161
+ )
162
+
163
+
164
+ # ---------------------------------------------------------------------------
165
+ # Parameterised commands
166
+ # ---------------------------------------------------------------------------
167
+
168
+
169
+ @charge_group.command("limit")
170
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
171
+ @click.argument("percent", type=click.IntRange(50, 100))
172
+ @global_options
173
+ def limit_cmd(app_ctx: AppContext, vin_positional: str | None, percent: int) -> None:
174
+ """Set charge limit to PERCENT (50-100)."""
175
+ run_async(_cmd_limit(app_ctx, vin_positional, percent))
176
+
177
+
178
+ async def _cmd_limit(app_ctx: AppContext, vin_positional: str | None, percent: int) -> None:
179
+ formatter = app_ctx.formatter
180
+ vin = require_vin(vin_positional, app_ctx.vin)
181
+ client, vehicle_api, cmd_api = get_command_api(app_ctx)
182
+ try:
183
+ result = await auto_wake(
184
+ formatter,
185
+ vehicle_api,
186
+ vin,
187
+ lambda: cmd_api.set_charge_limit(vin, percent=percent),
188
+ auto=app_ctx.auto_wake,
189
+ )
190
+ finally:
191
+ await client.close()
192
+
193
+ invalidate_cache_for_vin(app_ctx, vin)
194
+
195
+ if formatter.format == "json":
196
+ formatter.output(result, command="charge.limit")
197
+ else:
198
+ msg = result.response.reason or f"Charge limit set to {percent}%."
199
+ formatter.rich.command_result(result.response.result, msg)
200
+
201
+
202
+ @charge_group.command("amps")
203
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
204
+ @click.argument("amps", type=click.IntRange(0, 48))
205
+ @global_options
206
+ def amps_cmd(app_ctx: AppContext, vin_positional: str | None, amps: int) -> None:
207
+ """Set charging current to AMPS (0-48)."""
208
+ run_async(_cmd_amps(app_ctx, vin_positional, amps))
209
+
210
+
211
+ async def _cmd_amps(app_ctx: AppContext, vin_positional: str | None, amps: int) -> None:
212
+ formatter = app_ctx.formatter
213
+ vin = require_vin(vin_positional, app_ctx.vin)
214
+ client, vehicle_api, cmd_api = get_command_api(app_ctx)
215
+ try:
216
+ result = await auto_wake(
217
+ formatter,
218
+ vehicle_api,
219
+ vin,
220
+ lambda: cmd_api.set_charging_amps(vin, charging_amps=amps),
221
+ auto=app_ctx.auto_wake,
222
+ )
223
+ finally:
224
+ await client.close()
225
+
226
+ invalidate_cache_for_vin(app_ctx, vin)
227
+
228
+ if formatter.format == "json":
229
+ formatter.output(result, command="charge.amps")
230
+ else:
231
+ msg = result.response.reason or f"Charging current set to {amps}A."
232
+ formatter.rich.command_result(result.response.result, msg)
233
+
234
+
235
+ @charge_group.command("schedule")
236
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
237
+ @click.option("--enable/--disable", default=True, help="Enable or disable scheduled charging")
238
+ @click.option(
239
+ "--time",
240
+ "time_minutes",
241
+ type=int,
242
+ default=0,
243
+ help="Scheduled start time in minutes past midnight",
244
+ )
245
+ @global_options
246
+ def schedule_cmd(
247
+ app_ctx: AppContext,
248
+ vin_positional: str | None,
249
+ enable: bool,
250
+ time_minutes: int,
251
+ ) -> None:
252
+ """Configure scheduled charging."""
253
+ run_async(_cmd_schedule(app_ctx, vin_positional, enable, time_minutes))
254
+
255
+
256
+ async def _cmd_schedule(
257
+ app_ctx: AppContext,
258
+ vin_positional: str | None,
259
+ enable: bool,
260
+ time_minutes: int,
261
+ ) -> None:
262
+ formatter = app_ctx.formatter
263
+ vin = require_vin(vin_positional, app_ctx.vin)
264
+ client, vehicle_api, cmd_api = get_command_api(app_ctx)
265
+ try:
266
+ result = await auto_wake(
267
+ formatter,
268
+ vehicle_api,
269
+ vin,
270
+ lambda: cmd_api.set_scheduled_charging(vin, enable=enable, time=time_minutes),
271
+ auto=app_ctx.auto_wake,
272
+ )
273
+ finally:
274
+ await client.close()
275
+
276
+ invalidate_cache_for_vin(app_ctx, vin)
277
+
278
+ if formatter.format == "json":
279
+ formatter.output(result, command="charge.schedule")
280
+ else:
281
+ state = "enabled" if enable else "disabled"
282
+ msg = result.response.reason or f"Scheduled charging {state}."
283
+ formatter.rich.command_result(result.response.result, msg)
284
+
285
+
286
+ # ---------------------------------------------------------------------------
287
+ # Scheduled departure / precondition schedules
288
+ # ---------------------------------------------------------------------------
289
+
290
+
291
+ @charge_group.command("departure")
292
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
293
+ @click.option(
294
+ "--time", "departure_time", type=int, required=True, help="Departure time (mins past midnight)"
295
+ )
296
+ @click.option("--on/--off", "enable", default=True, help="Enable or disable scheduled departure")
297
+ @click.option("--precondition", is_flag=True, default=False, help="Enable preconditioning")
298
+ @click.option(
299
+ "--precondition-weekdays", is_flag=True, default=False, help="Preconditioning weekdays only"
300
+ )
301
+ @click.option("--off-peak", is_flag=True, default=False, help="Enable off-peak charging")
302
+ @click.option("--off-peak-weekdays", is_flag=True, default=False, help="Off-peak weekdays only")
303
+ @click.option("--off-peak-end", type=int, default=0, help="End off-peak time (mins past midnight)")
304
+ @global_options
305
+ def departure_cmd(
306
+ app_ctx: AppContext,
307
+ vin_positional: str | None,
308
+ departure_time: int,
309
+ enable: bool,
310
+ precondition: bool,
311
+ precondition_weekdays: bool,
312
+ off_peak: bool,
313
+ off_peak_weekdays: bool,
314
+ off_peak_end: int,
315
+ ) -> None:
316
+ """Configure scheduled departure."""
317
+ run_async(
318
+ _cmd_departure(
319
+ app_ctx,
320
+ vin_positional,
321
+ enable,
322
+ departure_time,
323
+ precondition,
324
+ precondition_weekdays,
325
+ off_peak,
326
+ off_peak_weekdays,
327
+ off_peak_end,
328
+ )
329
+ )
330
+
331
+
332
+ async def _cmd_departure(
333
+ app_ctx: AppContext,
334
+ vin_positional: str | None,
335
+ enable: bool,
336
+ departure_time: int,
337
+ precondition: bool,
338
+ precondition_weekdays: bool,
339
+ off_peak: bool,
340
+ off_peak_weekdays: bool,
341
+ off_peak_end: int,
342
+ ) -> None:
343
+ formatter = app_ctx.formatter
344
+ vin = require_vin(vin_positional, app_ctx.vin)
345
+ client, vehicle_api, cmd_api = get_command_api(app_ctx)
346
+ try:
347
+ result = await auto_wake(
348
+ formatter,
349
+ vehicle_api,
350
+ vin,
351
+ lambda: cmd_api.set_scheduled_departure(
352
+ vin,
353
+ enable=enable,
354
+ departure_time=departure_time,
355
+ preconditioning_enabled=precondition,
356
+ preconditioning_weekdays_only=precondition_weekdays,
357
+ off_peak_charging_enabled=off_peak,
358
+ off_peak_charging_weekdays_only=off_peak_weekdays,
359
+ end_off_peak_time=off_peak_end,
360
+ ),
361
+ auto=app_ctx.auto_wake,
362
+ )
363
+ finally:
364
+ await client.close()
365
+
366
+ invalidate_cache_for_vin(app_ctx, vin)
367
+
368
+ if formatter.format == "json":
369
+ formatter.output(result, command="charge.departure")
370
+ else:
371
+ state = "enabled" if enable else "disabled"
372
+ msg = result.response.reason or f"Scheduled departure {state}."
373
+ formatter.rich.command_result(result.response.result, msg)
374
+
375
+
376
+ @charge_group.command("precondition-add")
377
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
378
+ @click.argument("schedule_json")
379
+ @global_options
380
+ def precondition_add_cmd(
381
+ app_ctx: AppContext, vin_positional: str | None, schedule_json: str
382
+ ) -> None:
383
+ """Add a precondition schedule (SCHEDULE_JSON is a JSON string)."""
384
+ from tescmd.api.errors import ConfigError
385
+
386
+ try:
387
+ schedule = json.loads(schedule_json)
388
+ except json.JSONDecodeError as e:
389
+ raise ConfigError(f"Invalid JSON in SCHEDULE_JSON: {e}") from e
390
+ run_async(
391
+ execute_command(
392
+ app_ctx,
393
+ vin_positional,
394
+ "add_precondition_schedule",
395
+ "charge.precondition-add",
396
+ body=schedule,
397
+ success_message="Precondition schedule added.",
398
+ )
399
+ )
400
+
401
+
402
+ @charge_group.command("precondition-remove")
403
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
404
+ @click.argument("schedule_id", type=int)
405
+ @global_options
406
+ def precondition_remove_cmd(
407
+ app_ctx: AppContext, vin_positional: str | None, schedule_id: int
408
+ ) -> None:
409
+ """Remove a precondition schedule by ID."""
410
+ run_async(
411
+ execute_command(
412
+ app_ctx,
413
+ vin_positional,
414
+ "remove_precondition_schedule",
415
+ "charge.precondition-remove",
416
+ body={"id": schedule_id},
417
+ success_message="Precondition schedule removed.",
418
+ )
419
+ )
420
+
421
+
422
+ # ---------------------------------------------------------------------------
423
+ # Charge schedule management (firmware 2024.26+)
424
+ # ---------------------------------------------------------------------------
425
+
426
+
427
+ @charge_group.command("add-schedule")
428
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
429
+ @click.option("--id", "schedule_id", type=int, required=True, help="Schedule ID")
430
+ @click.option("--name", default=None, help="Schedule name")
431
+ @click.option(
432
+ "--days-of-week",
433
+ default=None,
434
+ help="Days of week bitmask (e.g. 127 for all days)",
435
+ )
436
+ @click.option("--start-time", type=int, default=None, help="Start time (minutes past midnight)")
437
+ @click.option("--end-time", type=int, default=None, help="End time (minutes past midnight)")
438
+ @click.option("--enabled/--disabled", default=True, help="Enable or disable the schedule")
439
+ @click.option("--one-time", is_flag=True, default=False, help="One-time schedule")
440
+ @global_options
441
+ def add_schedule_cmd(
442
+ app_ctx: AppContext,
443
+ vin_positional: str | None,
444
+ schedule_id: int,
445
+ name: str | None,
446
+ days_of_week: str | None,
447
+ start_time: int | None,
448
+ end_time: int | None,
449
+ enabled: bool,
450
+ one_time: bool,
451
+ ) -> None:
452
+ """Add or update a charge schedule (firmware 2024.26+)."""
453
+ schedule: dict[str, object] = {"id": schedule_id, "enabled": enabled}
454
+ if name is not None:
455
+ schedule["name"] = name
456
+ if days_of_week is not None:
457
+ schedule["days_of_week"] = int(days_of_week)
458
+ if start_time is not None:
459
+ schedule["start_time"] = start_time
460
+ if end_time is not None:
461
+ schedule["end_time"] = end_time
462
+ if one_time:
463
+ schedule["one_time"] = True
464
+ run_async(
465
+ execute_command(
466
+ app_ctx,
467
+ vin_positional,
468
+ "add_charge_schedule",
469
+ "charge.add-schedule",
470
+ body=schedule,
471
+ success_message="Charge schedule updated.",
472
+ )
473
+ )
474
+
475
+
476
+ @charge_group.command("remove-schedule")
477
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
478
+ @click.option("--id", "schedule_id", type=int, required=True, help="Schedule ID to remove")
479
+ @global_options
480
+ def remove_schedule_cmd(app_ctx: AppContext, vin_positional: str | None, schedule_id: int) -> None:
481
+ """Remove a charge schedule (firmware 2024.26+)."""
482
+ run_async(
483
+ execute_command(
484
+ app_ctx,
485
+ vin_positional,
486
+ "remove_charge_schedule",
487
+ "charge.remove-schedule",
488
+ body={"id": schedule_id},
489
+ success_message="Charge schedule removed.",
490
+ )
491
+ )
492
+
493
+
494
+ @charge_group.command("clear-schedules")
495
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
496
+ @click.option("--home/--no-home", default=True, help="Remove home location schedules")
497
+ @click.option("--work/--no-work", default=True, help="Remove work location schedules")
498
+ @click.option("--other/--no-other", default=True, help="Remove other location schedules")
499
+ @global_options
500
+ def clear_schedules_cmd(
501
+ app_ctx: AppContext,
502
+ vin_positional: str | None,
503
+ home: bool,
504
+ work: bool,
505
+ other: bool,
506
+ ) -> None:
507
+ """Batch remove charge schedules by location type."""
508
+ run_async(
509
+ execute_command(
510
+ app_ctx,
511
+ vin_positional,
512
+ "batch_remove_charge_schedules",
513
+ "charge.clear-schedules",
514
+ body={"home": home, "work": work, "other": other},
515
+ success_message="Charge schedules removed.",
516
+ )
517
+ )
518
+
519
+
520
+ @charge_group.command("clear-preconditions")
521
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
522
+ @click.option("--home/--no-home", default=True, help="Remove home location schedules")
523
+ @click.option("--work/--no-work", default=True, help="Remove work location schedules")
524
+ @click.option("--other/--no-other", default=True, help="Remove other location schedules")
525
+ @global_options
526
+ def clear_preconditions_cmd(
527
+ app_ctx: AppContext,
528
+ vin_positional: str | None,
529
+ home: bool,
530
+ work: bool,
531
+ other: bool,
532
+ ) -> None:
533
+ """Batch remove precondition schedules by location type."""
534
+ run_async(
535
+ execute_command(
536
+ app_ctx,
537
+ vin_positional,
538
+ "batch_remove_precondition_schedules",
539
+ "charge.clear-preconditions",
540
+ body={"home": home, "work": work, "other": other},
541
+ success_message="Precondition schedules removed.",
542
+ )
543
+ )
544
+
545
+
546
+ # ---------------------------------------------------------------------------
547
+ # Managed charging (fleet)
548
+ # ---------------------------------------------------------------------------
549
+
550
+
551
+ @charge_group.command("managed-amps")
552
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
553
+ @click.argument("amps", type=int)
554
+ @global_options
555
+ def managed_amps_cmd(app_ctx: AppContext, vin_positional: str | None, amps: int) -> None:
556
+ """Set managed charging current in amps (fleet management)."""
557
+ run_async(
558
+ execute_command(
559
+ app_ctx,
560
+ vin_positional,
561
+ "set_managed_charge_current_request",
562
+ "charge.managed-amps",
563
+ body={"charging_amps": amps},
564
+ success_message=f"Managed charging current set to {amps}A.",
565
+ )
566
+ )
567
+
568
+
569
+ @charge_group.command("managed-location")
570
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
571
+ @click.option("--lat", type=float, required=True, help="Latitude")
572
+ @click.option("--lon", type=float, required=True, help="Longitude")
573
+ @global_options
574
+ def managed_location_cmd(
575
+ app_ctx: AppContext, vin_positional: str | None, lat: float, lon: float
576
+ ) -> None:
577
+ """Set managed charger location (fleet management)."""
578
+ run_async(
579
+ execute_command(
580
+ app_ctx,
581
+ vin_positional,
582
+ "set_managed_charger_location",
583
+ "charge.managed-location",
584
+ body={"location": {"lat": lat, "lon": lon}},
585
+ success_message="Managed charger location set.",
586
+ )
587
+ )
588
+
589
+
590
+ @charge_group.command("managed-schedule")
591
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
592
+ @click.argument("time_minutes", type=click.IntRange(0, 1440))
593
+ @global_options
594
+ def managed_schedule_cmd(
595
+ app_ctx: AppContext, vin_positional: str | None, time_minutes: int
596
+ ) -> None:
597
+ """Set managed scheduled charging time (fleet management).
598
+
599
+ TIME_MINUTES is minutes past midnight (0-1440).
600
+ """
601
+ run_async(
602
+ execute_command(
603
+ app_ctx,
604
+ vin_positional,
605
+ "set_managed_scheduled_charging_time",
606
+ "charge.managed-schedule",
607
+ body={"time": time_minutes},
608
+ success_message=f"Managed scheduled charging time set to {time_minutes} min.",
609
+ )
610
+ )