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/vehicle.py ADDED
@@ -0,0 +1,837 @@
1
+ """CLI commands for vehicle operations (list, info, data, location, wake)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from typing import TYPE_CHECKING
8
+
9
+ import click
10
+
11
+ from tescmd._internal.async_utils import run_async
12
+ from tescmd.api.errors import VehicleAsleepError
13
+ from tescmd.cli._client import (
14
+ TTL_DEFAULT,
15
+ TTL_FAST,
16
+ TTL_SLOW,
17
+ TTL_STATIC,
18
+ cached_api_call,
19
+ cached_vehicle_data,
20
+ execute_command,
21
+ get_vehicle_api,
22
+ require_vin,
23
+ )
24
+ from tescmd.cli._options import global_options
25
+
26
+ if TYPE_CHECKING:
27
+ from tescmd.cli.main import AppContext
28
+
29
+
30
+ # ---------------------------------------------------------------------------
31
+ # Command group
32
+ # ---------------------------------------------------------------------------
33
+
34
+ vehicle_group = click.Group("vehicle", help="Vehicle commands")
35
+
36
+ telemetry_group = click.Group("telemetry", help="Fleet telemetry configuration and errors")
37
+ vehicle_group.add_command(telemetry_group)
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Commands
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ @vehicle_group.command("list")
46
+ @global_options
47
+ def list_cmd(app_ctx: AppContext) -> None:
48
+ """List all vehicles on the account."""
49
+ run_async(_cmd_list(app_ctx))
50
+
51
+
52
+ async def _cmd_list(app_ctx: AppContext) -> None:
53
+ formatter = app_ctx.formatter
54
+ client, api = get_vehicle_api(app_ctx)
55
+ try:
56
+ vehicles = await cached_api_call(
57
+ app_ctx,
58
+ scope="account",
59
+ identifier="global",
60
+ endpoint="vehicle.list",
61
+ fetch=lambda: api.list_vehicles(),
62
+ ttl=TTL_SLOW,
63
+ )
64
+ finally:
65
+ await client.close()
66
+
67
+ if formatter.format == "json":
68
+ formatter.output(vehicles, command="vehicle.list")
69
+ else:
70
+ formatter.rich.vehicle_list(vehicles)
71
+
72
+
73
+ @vehicle_group.command("get")
74
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
75
+ @global_options
76
+ def get_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
77
+ """Fetch basic vehicle info (lightweight, no wake required)."""
78
+ run_async(_cmd_get(app_ctx, vin_positional))
79
+
80
+
81
+ async def _cmd_get(app_ctx: AppContext, vin_positional: str | None) -> None:
82
+ formatter = app_ctx.formatter
83
+ vin = require_vin(vin_positional, app_ctx.vin)
84
+ client, api = get_vehicle_api(app_ctx)
85
+ try:
86
+ vehicle = await cached_api_call(
87
+ app_ctx,
88
+ scope="vin",
89
+ identifier=vin,
90
+ endpoint="vehicle.get",
91
+ fetch=lambda: api.get_vehicle(vin),
92
+ ttl=TTL_DEFAULT,
93
+ )
94
+ finally:
95
+ await client.close()
96
+
97
+ if formatter.format == "json":
98
+ formatter.output(vehicle, command="vehicle.get")
99
+ else:
100
+ state = vehicle.get("state") if isinstance(vehicle, dict) else vehicle.state
101
+ style = "green" if state == "online" else "yellow"
102
+ if isinstance(vehicle, dict):
103
+ name = vehicle.get("display_name") or vehicle.get("vin") or "Unknown"
104
+ v_vin = vehicle.get("vin", "")
105
+ else:
106
+ name = vehicle.display_name or vehicle.vin or "Unknown"
107
+ v_vin = vehicle.vin
108
+ formatter.rich.info(f"{name} [{style}]{state}[/{style}] VIN: {v_vin}")
109
+
110
+
111
+ @vehicle_group.command("info")
112
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
113
+ @global_options
114
+ def info_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
115
+ """Show all vehicle data."""
116
+ run_async(_cmd_info(app_ctx, vin_positional))
117
+
118
+
119
+ async def _cmd_info(app_ctx: AppContext, vin_positional: str | None) -> None:
120
+ formatter = app_ctx.formatter
121
+ vin = require_vin(vin_positional, app_ctx.vin)
122
+ client, api = get_vehicle_api(app_ctx)
123
+ try:
124
+ vdata = await cached_vehicle_data(app_ctx, api, vin)
125
+ finally:
126
+ await client.close()
127
+
128
+ if formatter.format == "json":
129
+ formatter.output(vdata, command="vehicle.info")
130
+ else:
131
+ formatter.rich.vehicle_data(vdata)
132
+
133
+
134
+ @vehicle_group.command("data")
135
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
136
+ @click.option("--endpoints", default=None, help="Comma-separated endpoint filter")
137
+ @global_options
138
+ def data_cmd(app_ctx: AppContext, vin_positional: str | None, endpoints: str | None) -> None:
139
+ """Fetch vehicle data filtered by endpoint."""
140
+ run_async(_cmd_data(app_ctx, vin_positional, endpoints))
141
+
142
+
143
+ async def _cmd_data(
144
+ app_ctx: AppContext, vin_positional: str | None, endpoints: str | None
145
+ ) -> None:
146
+ formatter = app_ctx.formatter
147
+ vin = require_vin(vin_positional, app_ctx.vin)
148
+ endpoint_list: list[str] | None = None
149
+ if endpoints:
150
+ endpoint_list = [e.strip() for e in endpoints.split(",")]
151
+
152
+ client, api = get_vehicle_api(app_ctx)
153
+ try:
154
+ vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=endpoint_list)
155
+ finally:
156
+ await client.close()
157
+
158
+ if formatter.format == "json":
159
+ formatter.output(vdata, command="vehicle.data")
160
+ else:
161
+ formatter.rich.vehicle_data(vdata)
162
+
163
+
164
+ @vehicle_group.command("location")
165
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
166
+ @global_options
167
+ def location_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
168
+ """Show the vehicle's current location."""
169
+ run_async(_cmd_location(app_ctx, vin_positional))
170
+
171
+
172
+ async def _cmd_location(app_ctx: AppContext, vin_positional: str | None) -> None:
173
+ formatter = app_ctx.formatter
174
+ vin = require_vin(vin_positional, app_ctx.vin)
175
+ client, api = get_vehicle_api(app_ctx)
176
+ try:
177
+ vdata = await cached_vehicle_data(app_ctx, api, vin, endpoints=["drive_state"])
178
+ finally:
179
+ await client.close()
180
+
181
+ if formatter.format == "json":
182
+ ds = vdata.drive_state
183
+ formatter.output(
184
+ ds.model_dump(exclude_none=True) if ds else {},
185
+ command="vehicle.location",
186
+ )
187
+ else:
188
+ if vdata.drive_state:
189
+ formatter.rich.location(vdata.drive_state)
190
+ else:
191
+ formatter.rich.info("No drive state data available.")
192
+ if vdata.state == "online":
193
+ formatter.rich.info(
194
+ "[dim]Location requires a vehicle command key."
195
+ " Run [cyan]tescmd setup[/cyan] and choose"
196
+ " full control to enable location access.[/dim]"
197
+ )
198
+ else:
199
+ formatter.rich.info(
200
+ "[dim]The vehicle may be asleep."
201
+ " Try [cyan]tescmd vehicle wake[/cyan] first.[/dim]"
202
+ )
203
+
204
+
205
+ @vehicle_group.command("wake")
206
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
207
+ @click.option("--wait", is_flag=True, help="Wait for vehicle to come online")
208
+ @click.option("--timeout", type=int, default=30, help="Timeout in seconds when using --wait")
209
+ @global_options
210
+ def wake_cmd(
211
+ app_ctx: AppContext,
212
+ vin_positional: str | None,
213
+ wait: bool,
214
+ timeout: int,
215
+ ) -> None:
216
+ """Wake up the vehicle."""
217
+ run_async(_cmd_wake(app_ctx, vin_positional, wait, timeout))
218
+
219
+
220
+ async def _cmd_wake(
221
+ app_ctx: AppContext,
222
+ vin_positional: str | None,
223
+ wait: bool,
224
+ timeout: int,
225
+ ) -> None:
226
+ formatter = app_ctx.formatter
227
+ vin = require_vin(vin_positional, app_ctx.vin)
228
+ client, api = get_vehicle_api(app_ctx)
229
+ try:
230
+ vehicle = await api.wake(vin)
231
+
232
+ if wait and vehicle.state != "online":
233
+ elapsed = 0
234
+ while elapsed < timeout and vehicle.state != "online":
235
+ await asyncio.sleep(2)
236
+ elapsed += 2
237
+ with contextlib.suppress(VehicleAsleepError):
238
+ vehicle = await api.wake(vin)
239
+ finally:
240
+ await client.close()
241
+
242
+ if formatter.format == "json":
243
+ formatter.output(vehicle, command="vehicle.wake")
244
+ else:
245
+ state = vehicle.state
246
+ style = "green" if state == "online" else "yellow"
247
+ formatter.rich.info(f"Vehicle state: [{style}]{state}[/{style}]")
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Vehicle extras
252
+ # ---------------------------------------------------------------------------
253
+
254
+
255
+ @vehicle_group.command("rename")
256
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
257
+ @click.argument("name")
258
+ @global_options
259
+ def rename_cmd(app_ctx: AppContext, vin_positional: str | None, name: str) -> None:
260
+ """Rename the vehicle."""
261
+ run_async(
262
+ execute_command(
263
+ app_ctx,
264
+ vin_positional,
265
+ "set_vehicle_name",
266
+ "vehicle.rename",
267
+ body={"vehicle_name": name},
268
+ )
269
+ )
270
+
271
+
272
+ @vehicle_group.command("mobile-access")
273
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
274
+ @global_options
275
+ def mobile_access_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
276
+ """Check if mobile access is enabled."""
277
+ run_async(_cmd_mobile_access(app_ctx, vin_positional))
278
+
279
+
280
+ async def _cmd_mobile_access(app_ctx: AppContext, vin_positional: str | None) -> None:
281
+ formatter = app_ctx.formatter
282
+ vin = require_vin(vin_positional, app_ctx.vin)
283
+ client, api = get_vehicle_api(app_ctx)
284
+ try:
285
+ result = await cached_api_call(
286
+ app_ctx,
287
+ scope="vin",
288
+ identifier=vin,
289
+ endpoint="vehicle.mobile-access",
290
+ fetch=lambda: api.mobile_enabled(vin),
291
+ ttl=TTL_DEFAULT,
292
+ )
293
+ finally:
294
+ await client.close()
295
+
296
+ # Result is bool on miss, {"_value": bool} on hit
297
+ enabled = result.get("_value") if isinstance(result, dict) else result
298
+ if formatter.format == "json":
299
+ formatter.output({"mobile_enabled": enabled}, command="vehicle.mobile-access")
300
+ else:
301
+ label = "[green]enabled[/green]" if enabled else "[red]disabled[/red]"
302
+ formatter.rich.info(f"Mobile access: {label}")
303
+
304
+
305
+ @vehicle_group.command("nearby-chargers")
306
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
307
+ @global_options
308
+ def nearby_chargers_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
309
+ """Show nearby Superchargers and destination chargers."""
310
+ run_async(_cmd_nearby_chargers(app_ctx, vin_positional))
311
+
312
+
313
+ async def _cmd_nearby_chargers(app_ctx: AppContext, vin_positional: str | None) -> None:
314
+ formatter = app_ctx.formatter
315
+ vin = require_vin(vin_positional, app_ctx.vin)
316
+ client, api = get_vehicle_api(app_ctx)
317
+ try:
318
+ data = await cached_api_call(
319
+ app_ctx,
320
+ scope="vin",
321
+ identifier=vin,
322
+ endpoint="vehicle.nearby-chargers",
323
+ fetch=lambda: api.nearby_charging_sites(vin),
324
+ ttl=TTL_FAST,
325
+ )
326
+ finally:
327
+ await client.close()
328
+
329
+ if formatter.format == "json":
330
+ formatter.output(data, command="vehicle.nearby-chargers")
331
+ else:
332
+ formatter.rich.nearby_chargers(data)
333
+
334
+
335
+ @vehicle_group.command("alerts")
336
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
337
+ @global_options
338
+ def alerts_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
339
+ """Show recent vehicle alerts."""
340
+ run_async(_cmd_alerts(app_ctx, vin_positional))
341
+
342
+
343
+ async def _cmd_alerts(app_ctx: AppContext, vin_positional: str | None) -> None:
344
+ formatter = app_ctx.formatter
345
+ vin = require_vin(vin_positional, app_ctx.vin)
346
+ client, api = get_vehicle_api(app_ctx)
347
+ try:
348
+ alerts = await cached_api_call(
349
+ app_ctx,
350
+ scope="vin",
351
+ identifier=vin,
352
+ endpoint="vehicle.alerts",
353
+ fetch=lambda: api.recent_alerts(vin),
354
+ ttl=TTL_DEFAULT,
355
+ )
356
+ finally:
357
+ await client.close()
358
+
359
+ if formatter.format == "json":
360
+ formatter.output(alerts, command="vehicle.alerts")
361
+ else:
362
+ if alerts:
363
+ for alert in alerts:
364
+ name = alert.get("name", "Unknown")
365
+ ts = alert.get("time", "")
366
+ formatter.rich.info(f" {name} [dim]{ts}[/dim]")
367
+ else:
368
+ formatter.rich.info("[dim]No recent alerts.[/dim]")
369
+
370
+
371
+ @vehicle_group.command("release-notes")
372
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
373
+ @global_options
374
+ def release_notes_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
375
+ """Show firmware release notes."""
376
+ run_async(_cmd_release_notes(app_ctx, vin_positional))
377
+
378
+
379
+ async def _cmd_release_notes(app_ctx: AppContext, vin_positional: str | None) -> None:
380
+ formatter = app_ctx.formatter
381
+ vin = require_vin(vin_positional, app_ctx.vin)
382
+ client, api = get_vehicle_api(app_ctx)
383
+ try:
384
+ data = await cached_api_call(
385
+ app_ctx,
386
+ scope="vin",
387
+ identifier=vin,
388
+ endpoint="vehicle.release-notes",
389
+ fetch=lambda: api.release_notes(vin),
390
+ ttl=TTL_SLOW,
391
+ )
392
+ finally:
393
+ await client.close()
394
+
395
+ if formatter.format == "json":
396
+ formatter.output(data, command="vehicle.release-notes")
397
+ else:
398
+ formatter.rich.vehicle_release_notes(data)
399
+
400
+
401
+ @vehicle_group.command("service")
402
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
403
+ @global_options
404
+ def service_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
405
+ """Show vehicle service data."""
406
+ run_async(_cmd_service(app_ctx, vin_positional))
407
+
408
+
409
+ async def _cmd_service(app_ctx: AppContext, vin_positional: str | None) -> None:
410
+ formatter = app_ctx.formatter
411
+ vin = require_vin(vin_positional, app_ctx.vin)
412
+ client, api = get_vehicle_api(app_ctx)
413
+ try:
414
+ data = await cached_api_call(
415
+ app_ctx,
416
+ scope="vin",
417
+ identifier=vin,
418
+ endpoint="vehicle.service",
419
+ fetch=lambda: api.service_data(vin),
420
+ ttl=TTL_SLOW,
421
+ )
422
+ finally:
423
+ await client.close()
424
+
425
+ if formatter.format == "json":
426
+ formatter.output(data, command="vehicle.service")
427
+ else:
428
+ formatter.rich.vehicle_service(data)
429
+
430
+
431
+ @vehicle_group.command("drivers")
432
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
433
+ @global_options
434
+ def drivers_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
435
+ """List drivers associated with the vehicle."""
436
+ run_async(_cmd_drivers(app_ctx, vin_positional))
437
+
438
+
439
+ async def _cmd_drivers(app_ctx: AppContext, vin_positional: str | None) -> None:
440
+ formatter = app_ctx.formatter
441
+ vin = require_vin(vin_positional, app_ctx.vin)
442
+ client, api = get_vehicle_api(app_ctx)
443
+ try:
444
+ drivers = await cached_api_call(
445
+ app_ctx,
446
+ scope="vin",
447
+ identifier=vin,
448
+ endpoint="vehicle.drivers",
449
+ fetch=lambda: api.list_drivers(vin),
450
+ ttl=TTL_SLOW,
451
+ )
452
+ finally:
453
+ await client.close()
454
+
455
+ if formatter.format == "json":
456
+ formatter.output(drivers, command="vehicle.drivers")
457
+ else:
458
+ if drivers:
459
+ for d in drivers:
460
+ email = (d.get("email") if isinstance(d, dict) else d.email) or "unknown"
461
+ status = (d.get("status") if isinstance(d, dict) else d.status) or ""
462
+ formatter.rich.info(f" {email} [dim]{status}[/dim]")
463
+ else:
464
+ formatter.rich.info("[dim]No drivers found.[/dim]")
465
+
466
+
467
+ @vehicle_group.command("calendar")
468
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
469
+ @click.argument("calendar_data")
470
+ @global_options
471
+ def calendar_cmd(app_ctx: AppContext, vin_positional: str | None, calendar_data: str) -> None:
472
+ """Send calendar entries to the vehicle.
473
+
474
+ CALENDAR_DATA should be a JSON string of calendar entries.
475
+ """
476
+ run_async(
477
+ execute_command(
478
+ app_ctx,
479
+ vin_positional,
480
+ "upcoming_calendar_entries",
481
+ "vehicle.calendar",
482
+ body={"calendar_data": calendar_data},
483
+ )
484
+ )
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Power management commands
489
+ # ---------------------------------------------------------------------------
490
+
491
+
492
+ # ---------------------------------------------------------------------------
493
+ # Extended vehicle data endpoints
494
+ # ---------------------------------------------------------------------------
495
+
496
+
497
+ @vehicle_group.command("subscriptions")
498
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
499
+ @global_options
500
+ def subscriptions_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
501
+ """Check subscription eligibility for the vehicle."""
502
+ run_async(_cmd_subscriptions(app_ctx, vin_positional))
503
+
504
+
505
+ async def _cmd_subscriptions(app_ctx: AppContext, vin_positional: str | None) -> None:
506
+ formatter = app_ctx.formatter
507
+ vin = require_vin(vin_positional, app_ctx.vin)
508
+ client, api = get_vehicle_api(app_ctx)
509
+ try:
510
+ data = await cached_api_call(
511
+ app_ctx,
512
+ scope="vin",
513
+ identifier=vin,
514
+ endpoint="vehicle.subscriptions",
515
+ fetch=lambda: api.eligible_subscriptions(vin),
516
+ ttl=TTL_SLOW,
517
+ )
518
+ finally:
519
+ await client.close()
520
+
521
+ if formatter.format == "json":
522
+ formatter.output(data, command="vehicle.subscriptions")
523
+ else:
524
+ formatter.rich.vehicle_subscriptions(data)
525
+
526
+
527
+ @vehicle_group.command("upgrades")
528
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
529
+ @global_options
530
+ def upgrades_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
531
+ """Check upgrade eligibility for the vehicle."""
532
+ run_async(_cmd_upgrades(app_ctx, vin_positional))
533
+
534
+
535
+ async def _cmd_upgrades(app_ctx: AppContext, vin_positional: str | None) -> None:
536
+ formatter = app_ctx.formatter
537
+ vin = require_vin(vin_positional, app_ctx.vin)
538
+ client, api = get_vehicle_api(app_ctx)
539
+ try:
540
+ data = await cached_api_call(
541
+ app_ctx,
542
+ scope="vin",
543
+ identifier=vin,
544
+ endpoint="vehicle.upgrades",
545
+ fetch=lambda: api.eligible_upgrades(vin),
546
+ ttl=TTL_SLOW,
547
+ )
548
+ finally:
549
+ await client.close()
550
+
551
+ if formatter.format == "json":
552
+ formatter.output(data, command="vehicle.upgrades")
553
+ else:
554
+ formatter.rich.vehicle_upgrades(data)
555
+
556
+
557
+ @vehicle_group.command("options")
558
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
559
+ @global_options
560
+ def options_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
561
+ """Fetch vehicle option codes."""
562
+ run_async(_cmd_options(app_ctx, vin_positional))
563
+
564
+
565
+ async def _cmd_options(app_ctx: AppContext, vin_positional: str | None) -> None:
566
+ formatter = app_ctx.formatter
567
+ vin = require_vin(vin_positional, app_ctx.vin)
568
+ client, api = get_vehicle_api(app_ctx)
569
+ try:
570
+ data = await cached_api_call(
571
+ app_ctx,
572
+ scope="vin",
573
+ identifier=vin,
574
+ endpoint="vehicle.options",
575
+ fetch=lambda: api.options(vin),
576
+ ttl=TTL_STATIC,
577
+ )
578
+ finally:
579
+ await client.close()
580
+
581
+ if formatter.format == "json":
582
+ formatter.output(data, command="vehicle.options")
583
+ else:
584
+ formatter.rich.vehicle_options(data)
585
+
586
+
587
+ @vehicle_group.command("specs")
588
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
589
+ @global_options
590
+ def specs_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
591
+ """Fetch vehicle specifications (partner tokens, $0.10/call)."""
592
+ run_async(_cmd_specs(app_ctx, vin_positional))
593
+
594
+
595
+ async def _cmd_specs(app_ctx: AppContext, vin_positional: str | None) -> None:
596
+ formatter = app_ctx.formatter
597
+ vin = require_vin(vin_positional, app_ctx.vin)
598
+ client, api = get_vehicle_api(app_ctx)
599
+ try:
600
+ data = await cached_api_call(
601
+ app_ctx,
602
+ scope="vin",
603
+ identifier=vin,
604
+ endpoint="vehicle.specs",
605
+ fetch=lambda: api.specs(vin),
606
+ ttl=TTL_STATIC,
607
+ )
608
+ finally:
609
+ await client.close()
610
+
611
+ if formatter.format == "json":
612
+ formatter.output(data, command="vehicle.specs")
613
+ else:
614
+ formatter.rich.vehicle_specs(data)
615
+
616
+
617
+ @vehicle_group.command("warranty")
618
+ @global_options
619
+ def warranty_cmd(app_ctx: AppContext) -> None:
620
+ """Fetch warranty details for the account."""
621
+ run_async(_cmd_warranty(app_ctx))
622
+
623
+
624
+ async def _cmd_warranty(app_ctx: AppContext) -> None:
625
+ formatter = app_ctx.formatter
626
+ client, api = get_vehicle_api(app_ctx)
627
+ try:
628
+ data = await cached_api_call(
629
+ app_ctx,
630
+ scope="account",
631
+ identifier="global",
632
+ endpoint="vehicle.warranty",
633
+ fetch=lambda: api.warranty_details(),
634
+ ttl=TTL_STATIC,
635
+ )
636
+ finally:
637
+ await client.close()
638
+
639
+ if formatter.format == "json":
640
+ formatter.output(data, command="vehicle.warranty")
641
+ else:
642
+ formatter.rich.vehicle_warranty(data)
643
+
644
+
645
+ @vehicle_group.command("fleet-status")
646
+ @global_options
647
+ def fleet_status_cmd(app_ctx: AppContext) -> None:
648
+ """Fetch fleet status for all vehicles."""
649
+ run_async(_cmd_fleet_status(app_ctx))
650
+
651
+
652
+ async def _cmd_fleet_status(app_ctx: AppContext) -> None:
653
+ formatter = app_ctx.formatter
654
+ client, api = get_vehicle_api(app_ctx)
655
+ try:
656
+ data = await cached_api_call(
657
+ app_ctx,
658
+ scope="account",
659
+ identifier="global",
660
+ endpoint="vehicle.fleet-status",
661
+ fetch=lambda: api.fleet_status(),
662
+ ttl=TTL_SLOW,
663
+ )
664
+ finally:
665
+ await client.close()
666
+
667
+ if formatter.format == "json":
668
+ formatter.output(data, command="vehicle.fleet-status")
669
+ else:
670
+ formatter.rich.fleet_status(data)
671
+
672
+
673
+ @telemetry_group.command("config")
674
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
675
+ @global_options
676
+ def telemetry_config_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
677
+ """Fetch fleet telemetry configuration for a vehicle."""
678
+ run_async(_cmd_telemetry_config(app_ctx, vin_positional))
679
+
680
+
681
+ async def _cmd_telemetry_config(app_ctx: AppContext, vin_positional: str | None) -> None:
682
+ formatter = app_ctx.formatter
683
+ vin = require_vin(vin_positional, app_ctx.vin)
684
+ client, api = get_vehicle_api(app_ctx)
685
+ try:
686
+ data = await cached_api_call(
687
+ app_ctx,
688
+ scope="vin",
689
+ identifier=vin,
690
+ endpoint="vehicle.telemetry.config",
691
+ fetch=lambda: api.fleet_telemetry_config(vin),
692
+ ttl=TTL_SLOW,
693
+ )
694
+ finally:
695
+ await client.close()
696
+
697
+ if formatter.format == "json":
698
+ formatter.output(data, command="vehicle.telemetry.config")
699
+ else:
700
+ formatter.rich.telemetry_config(data)
701
+
702
+
703
+ @telemetry_group.command("create")
704
+ @click.argument("config_json")
705
+ @global_options
706
+ def telemetry_config_create_cmd(app_ctx: AppContext, config_json: str) -> None:
707
+ """Create or update fleet telemetry server configuration (CONFIG_JSON is a JSON string)."""
708
+ import json
709
+
710
+ from tescmd.api.errors import ConfigError
711
+
712
+ try:
713
+ config = json.loads(config_json)
714
+ except json.JSONDecodeError as e:
715
+ raise ConfigError(f"Invalid JSON in CONFIG_JSON: {e}") from e
716
+ run_async(_cmd_telemetry_config_create(app_ctx, config))
717
+
718
+
719
+ async def _cmd_telemetry_config_create(app_ctx: AppContext, config: dict[str, object]) -> None:
720
+ formatter = app_ctx.formatter
721
+ client, api = get_vehicle_api(app_ctx)
722
+ try:
723
+ data = await api.fleet_telemetry_config_create(config=config)
724
+ finally:
725
+ await client.close()
726
+
727
+ if formatter.format == "json":
728
+ formatter.output(data, command="vehicle.telemetry.create")
729
+ else:
730
+ formatter.rich.info("Fleet telemetry config created/updated.")
731
+
732
+
733
+ @telemetry_group.command("delete")
734
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
735
+ @click.option(
736
+ "--confirm",
737
+ is_flag=True,
738
+ required=True,
739
+ help="Required flag to confirm config deletion",
740
+ )
741
+ @global_options
742
+ def telemetry_config_delete_cmd(
743
+ app_ctx: AppContext, vin_positional: str | None, confirm: bool
744
+ ) -> None:
745
+ """Remove fleet telemetry configuration from a vehicle (DESTRUCTIVE).
746
+
747
+ Requires --confirm flag.
748
+ """
749
+ run_async(_cmd_telemetry_config_delete(app_ctx, vin_positional))
750
+
751
+
752
+ async def _cmd_telemetry_config_delete(app_ctx: AppContext, vin_positional: str | None) -> None:
753
+ formatter = app_ctx.formatter
754
+ vin = require_vin(vin_positional, app_ctx.vin)
755
+ client, api = get_vehicle_api(app_ctx)
756
+ try:
757
+ data = await api.fleet_telemetry_config_delete(vin)
758
+ finally:
759
+ await client.close()
760
+
761
+ if formatter.format == "json":
762
+ formatter.output(data, command="vehicle.telemetry.delete")
763
+ else:
764
+ formatter.rich.info("Fleet telemetry config deleted.")
765
+
766
+
767
+ @telemetry_group.command("errors")
768
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
769
+ @global_options
770
+ def telemetry_errors_cmd(app_ctx: AppContext, vin_positional: str | None) -> None:
771
+ """Fetch fleet telemetry errors for a vehicle."""
772
+ run_async(_cmd_telemetry_errors(app_ctx, vin_positional))
773
+
774
+
775
+ async def _cmd_telemetry_errors(app_ctx: AppContext, vin_positional: str | None) -> None:
776
+ formatter = app_ctx.formatter
777
+ vin = require_vin(vin_positional, app_ctx.vin)
778
+ client, api = get_vehicle_api(app_ctx)
779
+ try:
780
+ data = await cached_api_call(
781
+ app_ctx,
782
+ scope="vin",
783
+ identifier=vin,
784
+ endpoint="vehicle.telemetry.errors",
785
+ fetch=lambda: api.fleet_telemetry_errors(vin),
786
+ ttl=TTL_SLOW,
787
+ )
788
+ finally:
789
+ await client.close()
790
+
791
+ if formatter.format == "json":
792
+ formatter.output(data, command="vehicle.telemetry.errors")
793
+ else:
794
+ formatter.rich.telemetry_errors(data)
795
+
796
+
797
+ # ---------------------------------------------------------------------------
798
+ # Power management commands
799
+ # ---------------------------------------------------------------------------
800
+
801
+
802
+ @vehicle_group.command("low-power")
803
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
804
+ @click.option("--on/--off", default=True, help="Enable or disable low power mode")
805
+ @global_options
806
+ def low_power_cmd(app_ctx: AppContext, vin_positional: str | None, on: bool) -> None:
807
+ """Enable or disable low power mode."""
808
+ state = "enabled" if on else "disabled"
809
+ run_async(
810
+ execute_command(
811
+ app_ctx,
812
+ vin_positional,
813
+ "set_low_power_mode",
814
+ "vehicle.low-power",
815
+ body={"enable": on},
816
+ success_message=f"Low power mode {state}.",
817
+ )
818
+ )
819
+
820
+
821
+ @vehicle_group.command("accessory-power")
822
+ @click.argument("vin_positional", required=False, default=None, metavar="VIN")
823
+ @click.option("--on/--off", default=True, help="Keep USB/outlets powered after exit")
824
+ @global_options
825
+ def accessory_power_cmd(app_ctx: AppContext, vin_positional: str | None, on: bool) -> None:
826
+ """Keep accessory power (USB/outlets) active after exiting the vehicle."""
827
+ state = "enabled" if on else "disabled"
828
+ run_async(
829
+ execute_command(
830
+ app_ctx,
831
+ vin_positional,
832
+ "keep_accessory_power_mode",
833
+ "vehicle.accessory-power",
834
+ body={"enable": on},
835
+ success_message=f"Accessory power mode {state}.",
836
+ )
837
+ )