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/_client.py ADDED
@@ -0,0 +1,603 @@
1
+ """Shared helpers for building API clients and resolving VINs."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import time
8
+ from pathlib import Path
9
+ from typing import TYPE_CHECKING, Any, TypeVar
10
+
11
+ import click
12
+
13
+ from tescmd._internal.vin import resolve_vin
14
+ from tescmd.api.charging import ChargingAPI
15
+ from tescmd.api.client import TeslaFleetClient
16
+ from tescmd.api.command import CommandAPI
17
+ from tescmd.api.energy import EnergyAPI
18
+ from tescmd.api.errors import ConfigError, KeyNotEnrolledError, TierError, VehicleAsleepError
19
+ from tescmd.api.partner import PartnerAPI
20
+ from tescmd.api.sharing import SharingAPI
21
+ from tescmd.api.user import UserAPI
22
+ from tescmd.api.vehicle import VehicleAPI
23
+ from tescmd.auth.token_store import TokenStore
24
+ from tescmd.cache import ResponseCache
25
+ from tescmd.cache.keys import generic_cache_key
26
+ from tescmd.crypto.keys import has_key_pair, load_private_key
27
+ from tescmd.models.config import AppSettings
28
+ from tescmd.models.vehicle import VehicleData
29
+
30
+ if TYPE_CHECKING:
31
+ from collections.abc import Awaitable, Callable
32
+
33
+ from rich.status import Status
34
+
35
+ from tescmd.api.signed_command import SignedCommandAPI
36
+ from tescmd.cli.main import AppContext
37
+ from tescmd.output.formatter import OutputFormatter
38
+
39
+ T = TypeVar("T")
40
+
41
+ # ---------------------------------------------------------------------------
42
+ # Respect Tesla's 3 wakes/min limit — minimum 20s between requests.
43
+ # Vehicles typically take 10-60s to establish connectivity.
44
+ # See: https://developer.tesla.com/docs/fleet-api/billing-and-limits
45
+ # ---------------------------------------------------------------------------
46
+
47
+ _WAKE_INITIAL_DELAY = 20.0
48
+ _WAKE_MAX_DELAY = 30.0
49
+ _WAKE_BACKOFF_FACTOR = 1.5
50
+
51
+
52
+ def _make_token_refresher(
53
+ store: TokenStore, settings: AppSettings
54
+ ) -> Callable[[], Awaitable[str | None]] | None:
55
+ """Return an async callback that refreshes the access token, or *None*."""
56
+ refresh_token = store.refresh_token
57
+ client_id = settings.client_id
58
+ if not refresh_token or not client_id:
59
+ return None
60
+
61
+ async def _refresh() -> str | None:
62
+ from tescmd.auth.oauth import refresh_access_token
63
+
64
+ token_data = await refresh_access_token(
65
+ refresh_token=refresh_token,
66
+ client_id=client_id,
67
+ client_secret=settings.client_secret,
68
+ )
69
+ meta = store.metadata or {}
70
+ store.save(
71
+ access_token=token_data.access_token,
72
+ refresh_token=token_data.refresh_token or refresh_token,
73
+ expires_at=time.time() + token_data.expires_in,
74
+ scopes=meta.get("scopes", []),
75
+ region=meta.get("region", "na"),
76
+ )
77
+ return token_data.access_token
78
+
79
+ return _refresh
80
+
81
+
82
+ def _make_rate_limit_handler(
83
+ formatter: OutputFormatter,
84
+ ) -> Callable[[int, int, int], Awaitable[None]]:
85
+ """Return an async callback that shows a countdown during rate-limit waits."""
86
+
87
+ async def _wait(seconds: int, attempt: int, max_retries: int) -> None:
88
+ if formatter.format != "json":
89
+ with formatter.console.status("") as status:
90
+ for remaining in range(seconds, 0, -1):
91
+ status.update(
92
+ f"[yellow]Rate limited — retrying in {remaining}s"
93
+ f" (attempt {attempt}/{max_retries})…[/yellow]"
94
+ )
95
+ await asyncio.sleep(1)
96
+ else:
97
+ await asyncio.sleep(seconds)
98
+
99
+ return _wait
100
+
101
+
102
+ def get_client(app_ctx: AppContext) -> TeslaFleetClient:
103
+ """Build an authenticated :class:`TeslaFleetClient` from settings / token store."""
104
+ settings = AppSettings()
105
+ store = TokenStore(
106
+ profile=app_ctx.profile,
107
+ token_file=settings.token_file,
108
+ config_dir=settings.config_dir,
109
+ )
110
+
111
+ access_token = settings.access_token
112
+ if not access_token:
113
+ access_token = store.access_token
114
+
115
+ if not access_token:
116
+ raise ConfigError(
117
+ "No access token found. Run 'tescmd auth login' or set TESLA_ACCESS_TOKEN."
118
+ )
119
+
120
+ region = app_ctx.region or settings.region
121
+ return TeslaFleetClient(
122
+ access_token=access_token,
123
+ region=region,
124
+ on_token_refresh=_make_token_refresher(store, settings),
125
+ on_rate_limit_wait=_make_rate_limit_handler(app_ctx.formatter),
126
+ )
127
+
128
+
129
+ def get_energy_api(
130
+ app_ctx: AppContext,
131
+ ) -> tuple[TeslaFleetClient, EnergyAPI]:
132
+ """Build a :class:`TeslaFleetClient` + :class:`EnergyAPI`."""
133
+ client = get_client(app_ctx)
134
+ return client, EnergyAPI(client)
135
+
136
+
137
+ def get_billing_api(
138
+ app_ctx: AppContext,
139
+ ) -> tuple[TeslaFleetClient, ChargingAPI]:
140
+ """Build a :class:`TeslaFleetClient` + :class:`ChargingAPI` for billing commands."""
141
+ client = get_client(app_ctx)
142
+ return client, ChargingAPI(client)
143
+
144
+
145
+ def get_user_api(
146
+ app_ctx: AppContext,
147
+ ) -> tuple[TeslaFleetClient, UserAPI]:
148
+ """Build a :class:`TeslaFleetClient` + :class:`UserAPI`."""
149
+ client = get_client(app_ctx)
150
+ return client, UserAPI(client)
151
+
152
+
153
+ def get_sharing_api(
154
+ app_ctx: AppContext,
155
+ ) -> tuple[TeslaFleetClient, SharingAPI]:
156
+ """Build a :class:`TeslaFleetClient` + :class:`SharingAPI`."""
157
+ client = get_client(app_ctx)
158
+ return client, SharingAPI(client)
159
+
160
+
161
+ async def get_partner_api(
162
+ app_ctx: AppContext,
163
+ ) -> tuple[TeslaFleetClient, PartnerAPI]:
164
+ """Build a :class:`TeslaFleetClient` + :class:`PartnerAPI` using a partner token.
165
+
166
+ Partner endpoints require a ``client_credentials`` token rather than a
167
+ user access token. This helper obtains one via :func:`get_partner_token`.
168
+ """
169
+ from tescmd.auth.oauth import get_partner_token
170
+
171
+ settings = AppSettings()
172
+ if not settings.client_id or not settings.client_secret:
173
+ raise ConfigError(
174
+ "Partner endpoints require TESLA_CLIENT_ID and TESLA_CLIENT_SECRET. "
175
+ "Run 'tescmd setup' or set them in your environment."
176
+ )
177
+
178
+ region = app_ctx.region or settings.region
179
+ token, _scopes = await get_partner_token(
180
+ client_id=settings.client_id,
181
+ client_secret=settings.client_secret,
182
+ region=region,
183
+ )
184
+
185
+ client = TeslaFleetClient(
186
+ access_token=token,
187
+ region=region,
188
+ on_rate_limit_wait=_make_rate_limit_handler(app_ctx.formatter),
189
+ )
190
+ return client, PartnerAPI(client)
191
+
192
+
193
+ def get_vehicle_api(app_ctx: AppContext) -> tuple[TeslaFleetClient, VehicleAPI]:
194
+ """Build a :class:`TeslaFleetClient` + :class:`VehicleAPI`."""
195
+ client = get_client(app_ctx)
196
+ return client, VehicleAPI(client)
197
+
198
+
199
+ def get_command_api(
200
+ app_ctx: AppContext,
201
+ ) -> tuple[TeslaFleetClient, VehicleAPI, CommandAPI | SignedCommandAPI]:
202
+ """Build a :class:`TeslaFleetClient` + :class:`VehicleAPI` + :class:`CommandAPI`.
203
+
204
+ When keys are available and ``command_protocol`` is not ``"unsigned"``,
205
+ returns a :class:`SignedCommandAPI` that transparently routes signed
206
+ commands through the Vehicle Command Protocol.
207
+ """
208
+ settings = AppSettings()
209
+ client = get_client(app_ctx)
210
+ vehicle_api = VehicleAPI(client)
211
+ unsigned_api = CommandAPI(client)
212
+
213
+ protocol = settings.command_protocol
214
+ if protocol == "unsigned":
215
+ return client, vehicle_api, unsigned_api
216
+
217
+ key_dir = Path(settings.config_dir).expanduser() / "keys"
218
+ if has_key_pair(key_dir) and settings.setup_tier == "full":
219
+ from tescmd.api.signed_command import SignedCommandAPI
220
+ from tescmd.protocol.session import SessionManager
221
+
222
+ private_key = load_private_key(key_dir)
223
+ session_mgr = SessionManager(private_key, client)
224
+ return client, vehicle_api, SignedCommandAPI(client, session_mgr, unsigned_api)
225
+
226
+ if protocol == "signed":
227
+ raise ConfigError(
228
+ "command_protocol is 'signed' but no key pair found or tier is not 'full'. "
229
+ "Run 'tescmd setup' to configure full tier with key enrollment."
230
+ )
231
+
232
+ # protocol == "auto" with no keys → fall back to unsigned
233
+ return client, vehicle_api, unsigned_api
234
+
235
+
236
+ def require_vin(vin_positional: str | None, vin_flag: str | None) -> str:
237
+ """Resolve VIN or raise :class:`ConfigError`."""
238
+ vin = resolve_vin(vin_positional=vin_positional, vin_flag=vin_flag)
239
+ if not vin:
240
+ raise ConfigError(
241
+ "No VIN specified. Pass it as a positional argument, use --vin, or set TESLA_VIN."
242
+ )
243
+ return vin
244
+
245
+
246
+ # ---------------------------------------------------------------------------
247
+ # TTL tiers — calibrated to how frequently each data type changes.
248
+ # ---------------------------------------------------------------------------
249
+
250
+ TTL_STATIC = 3600 # 1h — specs, warranty, account info
251
+ TTL_SLOW = 300 # 5m — fleet lists, site config, drivers
252
+ TTL_DEFAULT = 60 # 1m — standard (matches TESLA_CACHE_TTL default)
253
+ TTL_FAST = 30 # 30s — nearby chargers, location-dependent
254
+
255
+ # ---------------------------------------------------------------------------
256
+ # Cache helpers
257
+ # ---------------------------------------------------------------------------
258
+
259
+
260
+ def get_cache(app_ctx: AppContext) -> ResponseCache:
261
+ """Build a :class:`ResponseCache` from settings and CLI flags."""
262
+ settings = AppSettings()
263
+ enabled = settings.cache_enabled and not app_ctx.no_cache
264
+ cache_dir = Path(settings.cache_dir).expanduser()
265
+ return ResponseCache(
266
+ cache_dir=cache_dir,
267
+ default_ttl=settings.cache_ttl,
268
+ enabled=enabled,
269
+ )
270
+
271
+
272
+ async def cached_api_call(
273
+ app_ctx: AppContext,
274
+ *,
275
+ scope: str,
276
+ identifier: str,
277
+ endpoint: str,
278
+ fetch: Callable[[], Awaitable[Any]],
279
+ ttl: int | None = None,
280
+ params: dict[str, str] | None = None,
281
+ ) -> Any:
282
+ """Fetch API data with transparent caching.
283
+
284
+ 1. Compute cache key via :func:`generic_cache_key`.
285
+ 2. On hit → emit cache metadata, return cached dict.
286
+ 3. On miss → call *fetch()*, serialise, store, return result.
287
+
288
+ The caller's ``formatter.output()`` handles both Pydantic models (miss)
289
+ and plain dicts (hit).
290
+ """
291
+ formatter = app_ctx.formatter
292
+ cache = get_cache(app_ctx)
293
+ key = generic_cache_key(scope, identifier, endpoint, params)
294
+
295
+ cached = cache.get_generic(key)
296
+ if cached is not None:
297
+ if formatter.format == "json":
298
+ formatter.set_cache_meta(
299
+ hit=True,
300
+ age_seconds=cached.age_seconds,
301
+ ttl_seconds=cached.ttl_seconds,
302
+ )
303
+ else:
304
+ formatter.rich.info(
305
+ f"[dim]Data cached {cached.age_seconds}s ago"
306
+ f" (TTL {cached.ttl_seconds}s)."
307
+ f" Use --fresh for live data.[/dim]"
308
+ )
309
+ return cached.data
310
+
311
+ result = await fetch()
312
+
313
+ # Serialise: Pydantic model → dict, list of models → list of dicts
314
+ if hasattr(result, "model_dump"):
315
+ data = result.model_dump()
316
+ elif isinstance(result, list):
317
+ data = [item.model_dump() if hasattr(item, "model_dump") else item for item in result]
318
+ elif isinstance(result, dict):
319
+ data = result
320
+ else:
321
+ # Scalar or unknown — wrap so JSON round-trips cleanly
322
+ data = {"_value": result}
323
+
324
+ cache.put_generic(key, data, ttl)
325
+ return result
326
+
327
+
328
+ async def cached_vehicle_data(
329
+ app_ctx: AppContext,
330
+ vehicle_api: VehicleAPI,
331
+ vin: str,
332
+ endpoints: list[str] | None = None,
333
+ ) -> VehicleData:
334
+ """Fetch vehicle data with caching and smart wake.
335
+
336
+ 1. Check disk cache — on hit, return immediately.
337
+ 2. On miss, check wake state cache — if recently online, try direct fetch.
338
+ 3. If direct fetch raises ``VehicleAsleepError``, fall back to ``auto_wake``.
339
+ 4. If no cached wake state, use ``auto_wake`` as normal.
340
+ 5. On success, cache the response and wake state.
341
+ """
342
+ formatter = app_ctx.formatter
343
+ cache = get_cache(app_ctx)
344
+
345
+ # 1. Cache hit
346
+ cached = cache.get(vin, endpoints)
347
+ if cached is not None:
348
+ if formatter.format == "json":
349
+ formatter.set_cache_meta(
350
+ hit=True,
351
+ age_seconds=cached.age_seconds,
352
+ ttl_seconds=cached.ttl_seconds,
353
+ )
354
+ else:
355
+ formatter.rich.info(
356
+ f"[dim]Data cached {cached.age_seconds}s ago"
357
+ f" (TTL {cached.ttl_seconds}s)."
358
+ f" Use --fresh for live data.[/dim]"
359
+ )
360
+ return VehicleData.model_validate(cached.data)
361
+
362
+ # 2. Smart wake — skip wake overhead if vehicle was recently online
363
+ if cache.get_wake_state(vin):
364
+ try:
365
+ vdata = await vehicle_api.get_vehicle_data(vin, endpoints=endpoints)
366
+ cache.put(vin, vdata.model_dump(), endpoints)
367
+ cache.put_wake_state(vin, "online")
368
+ return vdata
369
+ except VehicleAsleepError:
370
+ pass # Fall through to full auto_wake
371
+
372
+ # 3. Full auto_wake path
373
+ vdata = await auto_wake(
374
+ formatter,
375
+ vehicle_api,
376
+ vin,
377
+ lambda: vehicle_api.get_vehicle_data(vin, endpoints=endpoints),
378
+ auto=app_ctx.auto_wake,
379
+ )
380
+ cache.put(vin, vdata.model_dump(), endpoints)
381
+ cache.put_wake_state(vin, "online")
382
+ return vdata
383
+
384
+
385
+ def _check_signing_requirement(
386
+ cmd_api: CommandAPI | SignedCommandAPI,
387
+ command_name: str,
388
+ settings: AppSettings,
389
+ ) -> None:
390
+ """Raise a clear error when a signing-required command would silently fall back to unsigned.
391
+
392
+ VCSEC commands (lock, unlock, trunk, window, remote_start) require the
393
+ Vehicle Command Protocol. If keys are missing and ``command_protocol``
394
+ is ``"auto"``, the code falls back to ``CommandAPI`` (unsigned REST),
395
+ which will fail with a confusing API error. This guard catches that
396
+ case early and gives actionable guidance.
397
+ """
398
+ from tescmd.api.signed_command import SignedCommandAPI as _SignedAPI
399
+ from tescmd.protocol.commands import get_command_spec
400
+ from tescmd.protocol.protobuf.messages import Domain
401
+
402
+ if isinstance(cmd_api, _SignedAPI):
403
+ return # Already signed — nothing to guard
404
+
405
+ if settings.command_protocol == "unsigned":
406
+ return # User explicitly chose unsigned — respect the override
407
+
408
+ spec = get_command_spec(command_name)
409
+ if spec is None or not spec.requires_signing:
410
+ return # Unsigned command — no issue
411
+
412
+ if spec.domain == Domain.DOMAIN_VEHICLE_SECURITY:
413
+ key_dir = Path(settings.config_dir).expanduser() / "keys"
414
+ if not has_key_pair(key_dir):
415
+ raise ConfigError(
416
+ "This command requires a signed channel (Vehicle Command Protocol) "
417
+ "but no EC key pair was found. "
418
+ "Run 'tescmd key generate' then 'tescmd key deploy' and 'tescmd key enroll'."
419
+ )
420
+ raise KeyNotEnrolledError(
421
+ "This command requires a signed channel but the key is not active. "
422
+ "Enroll via 'tescmd key enroll' and approve in the Tesla app.",
423
+ status_code=422,
424
+ )
425
+
426
+
427
+ async def execute_command(
428
+ app_ctx: AppContext,
429
+ vin_positional: str | None,
430
+ method_name: str,
431
+ cmd_name: str,
432
+ body: dict[str, Any] | None = None,
433
+ *,
434
+ success_message: str = "",
435
+ ) -> None:
436
+ """Shared helper for simple vehicle commands (POST, no read data).
437
+
438
+ Resolves the VIN, obtains a :class:`CommandAPI`, calls *method_name* with
439
+ ``auto_wake``, invalidates the cache, and outputs the result.
440
+ """
441
+ # Tier enforcement — readonly tier cannot execute write commands
442
+ settings = AppSettings()
443
+ if settings.setup_tier == "readonly":
444
+ raise TierError("This command requires 'full' tier setup. Run 'tescmd setup' to upgrade.")
445
+
446
+ formatter = app_ctx.formatter
447
+ vin = require_vin(vin_positional, app_ctx.vin)
448
+ client, vehicle_api, cmd_api = get_command_api(app_ctx)
449
+
450
+ # Guard: VCSEC commands require signed channel — don't silently fall back
451
+ _check_signing_requirement(cmd_api, method_name, settings)
452
+
453
+ try:
454
+ method = getattr(cmd_api, method_name)
455
+ result = await auto_wake(
456
+ formatter,
457
+ vehicle_api,
458
+ vin,
459
+ lambda: method(vin, **body) if body else method(vin),
460
+ auto=app_ctx.auto_wake,
461
+ )
462
+ finally:
463
+ await client.close()
464
+
465
+ invalidate_cache_for_vin(app_ctx, vin)
466
+
467
+ if formatter.format == "json":
468
+ formatter.output(result, command=cmd_name)
469
+ else:
470
+ msg = result.response.reason or success_message
471
+ formatter.rich.command_result(result.response.result, msg)
472
+
473
+
474
+ def invalidate_cache_for_vin(app_ctx: AppContext, vin: str) -> None:
475
+ """Clear cached data for *vin* (called after state-changing commands)."""
476
+ cache = get_cache(app_ctx)
477
+ cache.clear(vin) # legacy {vin}_*.json keys
478
+ cache.clear_by_prefix(f"vin_{vin}_") # generic keys
479
+
480
+
481
+ def invalidate_cache_for_site(app_ctx: AppContext, site_id: int | str) -> None:
482
+ """Clear cached data for an energy *site_id* (called after site-changing commands)."""
483
+ cache = get_cache(app_ctx)
484
+ cache.clear_by_prefix(f"site_{site_id}_")
485
+
486
+
487
+ # ---------------------------------------------------------------------------
488
+ # Auto-wake helper
489
+ # ---------------------------------------------------------------------------
490
+
491
+
492
+ async def auto_wake(
493
+ formatter: OutputFormatter,
494
+ vehicle_api: VehicleAPI,
495
+ vin: str,
496
+ operation: Callable[[], Awaitable[T]],
497
+ *,
498
+ timeout: int = 90,
499
+ auto: bool = False,
500
+ ) -> T:
501
+ """Run *operation*; if the vehicle is asleep, wake it and retry.
502
+
503
+ When *auto* is ``False`` (default) and the output is a TTY, the user
504
+ is prompted before sending a billable wake API call. In JSON / piped
505
+ mode without *auto*, a ``VehicleAsleepError`` is raised immediately.
506
+
507
+ Pass ``auto=True`` (via ``--wake`` flag) to skip the prompt.
508
+ """
509
+ try:
510
+ return await operation()
511
+ except VehicleAsleepError:
512
+ pass
513
+
514
+ # Vehicle is asleep — decide whether to wake.
515
+ if not auto:
516
+ if formatter.format not in ("json",):
517
+ # Interactive TTY prompt with retry loop
518
+ while True:
519
+ formatter.rich.info("")
520
+ formatter.rich.info("[yellow]Vehicle is asleep.[/yellow]")
521
+ formatter.rich.info("")
522
+ formatter.rich.info(
523
+ " Waking via the Tesla app (iOS/Android) is [green]free[/green]."
524
+ )
525
+ formatter.rich.info(" Sending a wake via the API is [yellow]billable[/yellow].")
526
+ formatter.rich.info("")
527
+ choice = click.prompt(
528
+ " [W] Wake via API [R] Retry [C] Cancel",
529
+ type=click.Choice(["w", "r", "c"], case_sensitive=False),
530
+ default="c",
531
+ show_choices=False,
532
+ )
533
+ if choice.lower() == "r":
534
+ try:
535
+ return await operation()
536
+ except VehicleAsleepError:
537
+ continue # Still asleep — show prompt again
538
+ if choice.lower() == "w":
539
+ break # Fall through to wake-via-API path
540
+ # choice == "c"
541
+ raise VehicleAsleepError(
542
+ "Wake cancelled. You can wake the vehicle from the"
543
+ " Tesla app for free and re-run the command.",
544
+ status_code=408,
545
+ )
546
+ else:
547
+ # JSON / piped mode — no interactive prompt possible
548
+ raise VehicleAsleepError(
549
+ "Vehicle is asleep. Use --wake to send a billable wake via the API,"
550
+ " or wake from the Tesla app for free and re-run the command.",
551
+ status_code=408,
552
+ )
553
+
554
+ # Proceed with wake.
555
+ if formatter.format not in ("json",):
556
+ with formatter.console.status("", spinner="dots") as status:
557
+ await _wake_and_wait(vehicle_api, vin, timeout, status=status)
558
+ formatter.rich.info("[green]Vehicle is awake.[/green]")
559
+ else:
560
+ await _wake_and_wait(vehicle_api, vin, timeout)
561
+
562
+ # Retry the original operation.
563
+ return await operation()
564
+
565
+
566
+ async def _wake_and_wait(
567
+ vehicle_api: VehicleAPI,
568
+ vin: str,
569
+ timeout: int,
570
+ *,
571
+ status: Status | None = None,
572
+ ) -> None:
573
+ """Send a wake command and poll until the vehicle is online.
574
+
575
+ Uses exponential backoff starting at 20s (capped at 30s) to respect
576
+ Tesla's 3 wakes/min rate limit. RateLimitError (429) is handled
577
+ transparently by the client-level retry loop.
578
+ """
579
+ vehicle = await vehicle_api.wake(vin)
580
+ start = time.monotonic()
581
+ deadline = start + timeout
582
+ delay = _WAKE_INITIAL_DELAY
583
+
584
+ while time.monotonic() < deadline and vehicle.state != "online":
585
+ # Countdown within each delay interval (1s ticks for UI updates).
586
+ sleep_until = time.monotonic() + delay
587
+ while time.monotonic() < sleep_until:
588
+ if status:
589
+ elapsed = int(time.monotonic() - start)
590
+ status.update(
591
+ f"[bold yellow]Vehicle is asleep — waking up… ({elapsed}s)[/bold yellow]"
592
+ )
593
+ await asyncio.sleep(1)
594
+
595
+ delay = min(delay * _WAKE_BACKOFF_FACTOR, _WAKE_MAX_DELAY)
596
+ with contextlib.suppress(VehicleAsleepError):
597
+ vehicle = await vehicle_api.wake(vin)
598
+
599
+ if vehicle.state != "online":
600
+ raise VehicleAsleepError(
601
+ f"Vehicle did not wake within {timeout}s. Try again later.",
602
+ status_code=408,
603
+ )