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.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- 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
|
+
)
|