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/main.py
ADDED
|
@@ -0,0 +1,601 @@
|
|
|
1
|
+
"""CLI entry-point: Click command group and dispatch."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
|
|
10
|
+
from tescmd._internal.async_utils import run_async
|
|
11
|
+
from tescmd.api.errors import (
|
|
12
|
+
AuthError,
|
|
13
|
+
KeyNotEnrolledError,
|
|
14
|
+
MissingScopesError,
|
|
15
|
+
RegistrationRequiredError,
|
|
16
|
+
SessionError,
|
|
17
|
+
TierError,
|
|
18
|
+
VehicleAsleepError,
|
|
19
|
+
)
|
|
20
|
+
from tescmd.output.formatter import OutputFormatter
|
|
21
|
+
|
|
22
|
+
# ---------------------------------------------------------------------------
|
|
23
|
+
# Application context (stored in ctx.obj)
|
|
24
|
+
# ---------------------------------------------------------------------------
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclasses.dataclass
|
|
28
|
+
class AppContext:
|
|
29
|
+
"""Shared state passed to every Click command via ``@click.pass_obj``."""
|
|
30
|
+
|
|
31
|
+
vin: str | None
|
|
32
|
+
profile: str
|
|
33
|
+
output_format: str | None
|
|
34
|
+
quiet: bool
|
|
35
|
+
region: str | None
|
|
36
|
+
verbose: bool
|
|
37
|
+
no_cache: bool = False
|
|
38
|
+
auto_wake: bool = False
|
|
39
|
+
temp_unit: str = "F"
|
|
40
|
+
distance_unit: str = "mi"
|
|
41
|
+
pressure_unit: str = "psi"
|
|
42
|
+
_formatter: OutputFormatter | None = dataclasses.field(default=None, repr=False)
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def formatter(self) -> OutputFormatter:
|
|
46
|
+
if self._formatter is None:
|
|
47
|
+
from tescmd.output.rich_output import (
|
|
48
|
+
DisplayUnits,
|
|
49
|
+
DistanceUnit,
|
|
50
|
+
PressureUnit,
|
|
51
|
+
TempUnit,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
force = "quiet" if self.quiet else self.output_format
|
|
55
|
+
units = DisplayUnits(
|
|
56
|
+
temp=TempUnit(self.temp_unit),
|
|
57
|
+
distance=DistanceUnit(self.distance_unit),
|
|
58
|
+
pressure=PressureUnit(self.pressure_unit),
|
|
59
|
+
)
|
|
60
|
+
self._formatter = OutputFormatter(force_format=force, units=units)
|
|
61
|
+
return self._formatter
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
# Root Click group
|
|
66
|
+
# ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@click.group()
|
|
70
|
+
@click.option("--vin", default=None, envvar="TESLA_VIN", help="Vehicle VIN")
|
|
71
|
+
@click.option("--profile", default="default", help="Config profile name")
|
|
72
|
+
@click.option(
|
|
73
|
+
"--format",
|
|
74
|
+
"output_format",
|
|
75
|
+
type=click.Choice(["rich", "json", "quiet"]),
|
|
76
|
+
default=None,
|
|
77
|
+
help="Output format (default: auto-detect)",
|
|
78
|
+
)
|
|
79
|
+
@click.option("--quiet", is_flag=True, default=False, help="Suppress normal output")
|
|
80
|
+
@click.option(
|
|
81
|
+
"--region",
|
|
82
|
+
type=click.Choice(["na", "eu", "cn"]),
|
|
83
|
+
default=None,
|
|
84
|
+
help="Tesla API region",
|
|
85
|
+
)
|
|
86
|
+
@click.option("--verbose", is_flag=True, default=False, help="Enable verbose logging")
|
|
87
|
+
@click.option(
|
|
88
|
+
"--no-cache", "--fresh", "no_cache", is_flag=True, default=False, help="Bypass response cache"
|
|
89
|
+
)
|
|
90
|
+
@click.option(
|
|
91
|
+
"--wake", is_flag=True, default=False, help="Auto-wake vehicle without confirmation (billable)"
|
|
92
|
+
)
|
|
93
|
+
@click.option(
|
|
94
|
+
"--units",
|
|
95
|
+
type=click.Choice(["us", "metric"]),
|
|
96
|
+
default=None,
|
|
97
|
+
help="Display units preset (us: °F/mi/psi, metric: °C/km/bar)",
|
|
98
|
+
)
|
|
99
|
+
@click.pass_context
|
|
100
|
+
def cli(
|
|
101
|
+
ctx: click.Context,
|
|
102
|
+
vin: str | None,
|
|
103
|
+
profile: str,
|
|
104
|
+
output_format: str | None,
|
|
105
|
+
quiet: bool,
|
|
106
|
+
region: str | None,
|
|
107
|
+
verbose: bool,
|
|
108
|
+
no_cache: bool,
|
|
109
|
+
wake: bool,
|
|
110
|
+
units: str | None,
|
|
111
|
+
) -> None:
|
|
112
|
+
"""Query and control Tesla vehicles via the Fleet API."""
|
|
113
|
+
if verbose:
|
|
114
|
+
logging.basicConfig(
|
|
115
|
+
level=logging.DEBUG,
|
|
116
|
+
format="%(name)s %(levelname)s: %(message)s",
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
# Resolve display units: --units flag > env vars > defaults
|
|
120
|
+
from tescmd.models.config import AppSettings
|
|
121
|
+
|
|
122
|
+
settings = AppSettings()
|
|
123
|
+
if units == "metric":
|
|
124
|
+
temp_unit, distance_unit, pressure_unit = "C", "km", "bar"
|
|
125
|
+
elif units == "us":
|
|
126
|
+
temp_unit, distance_unit, pressure_unit = "F", "mi", "psi"
|
|
127
|
+
else:
|
|
128
|
+
temp_unit = settings.temp_unit
|
|
129
|
+
distance_unit = settings.distance_unit
|
|
130
|
+
pressure_unit = settings.pressure_unit
|
|
131
|
+
|
|
132
|
+
ctx.ensure_object(dict)
|
|
133
|
+
ctx.obj = AppContext(
|
|
134
|
+
vin=vin,
|
|
135
|
+
profile=profile,
|
|
136
|
+
output_format=output_format,
|
|
137
|
+
quiet=quiet,
|
|
138
|
+
region=region,
|
|
139
|
+
verbose=verbose,
|
|
140
|
+
no_cache=no_cache,
|
|
141
|
+
auto_wake=wake,
|
|
142
|
+
temp_unit=temp_unit,
|
|
143
|
+
distance_unit=distance_unit,
|
|
144
|
+
pressure_unit=pressure_unit,
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
# Register subcommand groups (lazy imports keep startup fast)
|
|
150
|
+
# ---------------------------------------------------------------------------
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _register_commands() -> None:
|
|
154
|
+
"""Import and attach all subcommand groups to the root CLI."""
|
|
155
|
+
from tescmd.cli.auth import auth_group
|
|
156
|
+
from tescmd.cli.billing import billing_group
|
|
157
|
+
from tescmd.cli.cache import cache_group
|
|
158
|
+
from tescmd.cli.charge import charge_group
|
|
159
|
+
from tescmd.cli.climate import climate_group
|
|
160
|
+
from tescmd.cli.energy import energy_group
|
|
161
|
+
from tescmd.cli.key import key_group
|
|
162
|
+
from tescmd.cli.media import media_group
|
|
163
|
+
from tescmd.cli.nav import nav_group
|
|
164
|
+
from tescmd.cli.partner import partner_group
|
|
165
|
+
from tescmd.cli.raw import raw_group
|
|
166
|
+
from tescmd.cli.security import security_group
|
|
167
|
+
from tescmd.cli.setup import setup_cmd
|
|
168
|
+
from tescmd.cli.sharing import sharing_group
|
|
169
|
+
from tescmd.cli.software import software_group
|
|
170
|
+
from tescmd.cli.status import status_cmd
|
|
171
|
+
from tescmd.cli.trunk import trunk_group
|
|
172
|
+
from tescmd.cli.user import user_group
|
|
173
|
+
from tescmd.cli.vehicle import vehicle_group
|
|
174
|
+
|
|
175
|
+
cli.add_command(auth_group)
|
|
176
|
+
cli.add_command(cache_group)
|
|
177
|
+
cli.add_command(charge_group)
|
|
178
|
+
cli.add_command(billing_group)
|
|
179
|
+
cli.add_command(climate_group)
|
|
180
|
+
cli.add_command(energy_group)
|
|
181
|
+
cli.add_command(key_group)
|
|
182
|
+
cli.add_command(media_group)
|
|
183
|
+
cli.add_command(nav_group)
|
|
184
|
+
cli.add_command(partner_group)
|
|
185
|
+
cli.add_command(raw_group)
|
|
186
|
+
cli.add_command(security_group)
|
|
187
|
+
cli.add_command(setup_cmd)
|
|
188
|
+
cli.add_command(sharing_group)
|
|
189
|
+
cli.add_command(status_cmd)
|
|
190
|
+
cli.add_command(software_group)
|
|
191
|
+
cli.add_command(trunk_group)
|
|
192
|
+
cli.add_command(user_group)
|
|
193
|
+
cli.add_command(vehicle_group)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
_register_commands()
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
# ---------------------------------------------------------------------------
|
|
200
|
+
# Entry point
|
|
201
|
+
# ---------------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def main(argv: list[str] | None = None) -> None:
|
|
205
|
+
"""Parse arguments and dispatch to the appropriate command handler."""
|
|
206
|
+
try:
|
|
207
|
+
cli(args=argv, standalone_mode=False)
|
|
208
|
+
except click.exceptions.Exit as exc:
|
|
209
|
+
raise SystemExit(exc.exit_code) from None
|
|
210
|
+
except click.exceptions.Abort:
|
|
211
|
+
raise SystemExit(1) from None
|
|
212
|
+
except KeyboardInterrupt:
|
|
213
|
+
raise SystemExit(130) from None
|
|
214
|
+
except SystemExit:
|
|
215
|
+
raise
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
# Reconstruct command name for error messages
|
|
218
|
+
app_ctx = _extract_app_ctx()
|
|
219
|
+
formatter = app_ctx.formatter if app_ctx else OutputFormatter()
|
|
220
|
+
cmd_name = _get_command_name()
|
|
221
|
+
|
|
222
|
+
if _handle_known_error(exc, app_ctx, formatter, cmd_name):
|
|
223
|
+
raise SystemExit(1) from exc
|
|
224
|
+
|
|
225
|
+
formatter.output_error(
|
|
226
|
+
code=type(exc).__name__,
|
|
227
|
+
message=str(exc),
|
|
228
|
+
command=cmd_name,
|
|
229
|
+
)
|
|
230
|
+
raise SystemExit(1) from exc
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
# ---------------------------------------------------------------------------
|
|
234
|
+
# Helpers for error handling
|
|
235
|
+
# ---------------------------------------------------------------------------
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def _extract_app_ctx() -> AppContext | None:
|
|
239
|
+
"""Try to extract AppContext from the current Click context."""
|
|
240
|
+
ctx = click.get_current_context(silent=True)
|
|
241
|
+
while ctx is not None:
|
|
242
|
+
if isinstance(ctx.obj, AppContext):
|
|
243
|
+
return ctx.obj
|
|
244
|
+
ctx = ctx.parent
|
|
245
|
+
return None
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _get_command_name() -> str:
|
|
249
|
+
"""Reconstruct a dotted command name from the Click context chain."""
|
|
250
|
+
ctx = click.get_current_context(silent=True)
|
|
251
|
+
parts: list[str] = []
|
|
252
|
+
while ctx is not None:
|
|
253
|
+
if ctx.info_name and ctx.info_name != "cli":
|
|
254
|
+
parts.append(ctx.info_name)
|
|
255
|
+
ctx = ctx.parent
|
|
256
|
+
return ".".join(reversed(parts)) or "unknown"
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _handle_known_error(
|
|
260
|
+
exc: Exception,
|
|
261
|
+
app_ctx: AppContext | None,
|
|
262
|
+
formatter: OutputFormatter,
|
|
263
|
+
cmd_name: str,
|
|
264
|
+
) -> bool:
|
|
265
|
+
"""Handle well-known errors with friendly output.
|
|
266
|
+
|
|
267
|
+
Returns ``True`` if the error was handled and the caller should exit.
|
|
268
|
+
"""
|
|
269
|
+
if isinstance(exc, MissingScopesError):
|
|
270
|
+
_handle_missing_scopes(exc, formatter, cmd_name)
|
|
271
|
+
return True
|
|
272
|
+
if isinstance(exc, AuthError):
|
|
273
|
+
_handle_auth_error(exc, formatter, cmd_name)
|
|
274
|
+
return True
|
|
275
|
+
if isinstance(exc, VehicleAsleepError):
|
|
276
|
+
_handle_vehicle_asleep(exc, formatter, cmd_name)
|
|
277
|
+
return True
|
|
278
|
+
if isinstance(exc, RegistrationRequiredError):
|
|
279
|
+
_handle_registration_required(exc, app_ctx, formatter, cmd_name)
|
|
280
|
+
return True
|
|
281
|
+
if isinstance(exc, TierError):
|
|
282
|
+
_handle_tier_error(exc, formatter, cmd_name)
|
|
283
|
+
return True
|
|
284
|
+
if isinstance(exc, KeyNotEnrolledError):
|
|
285
|
+
_handle_key_not_enrolled(exc, formatter, cmd_name)
|
|
286
|
+
return True
|
|
287
|
+
if isinstance(exc, SessionError):
|
|
288
|
+
_handle_session_error(exc, formatter, cmd_name)
|
|
289
|
+
return True
|
|
290
|
+
|
|
291
|
+
# Keyring failures (e.g. headless Linux with no keyring daemon)
|
|
292
|
+
try:
|
|
293
|
+
from keyring.errors import KeyringError
|
|
294
|
+
|
|
295
|
+
if isinstance(exc, KeyringError):
|
|
296
|
+
_handle_keyring_error(exc, formatter, cmd_name)
|
|
297
|
+
return True
|
|
298
|
+
except ImportError:
|
|
299
|
+
pass
|
|
300
|
+
|
|
301
|
+
return False
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _handle_missing_scopes(
|
|
305
|
+
exc: MissingScopesError,
|
|
306
|
+
formatter: OutputFormatter,
|
|
307
|
+
cmd_name: str,
|
|
308
|
+
) -> None:
|
|
309
|
+
"""Show a friendly error when the token lacks required OAuth scopes."""
|
|
310
|
+
message = "Your OAuth token is missing required scopes for this command."
|
|
311
|
+
|
|
312
|
+
if formatter.format == "json":
|
|
313
|
+
formatter.output_error(
|
|
314
|
+
code="missing_scopes",
|
|
315
|
+
message=(
|
|
316
|
+
f"{message} Re-login with 'tescmd auth login'. If the error persists, "
|
|
317
|
+
"check your Tesla Developer Portal app and ensure all required "
|
|
318
|
+
"scopes are enabled. Note: 'tescmd auth status' shows requested scopes, "
|
|
319
|
+
"not what Tesla granted — Tesla silently drops scopes your app "
|
|
320
|
+
"isn't configured for."
|
|
321
|
+
),
|
|
322
|
+
command=cmd_name,
|
|
323
|
+
)
|
|
324
|
+
return
|
|
325
|
+
|
|
326
|
+
formatter.rich.error(message)
|
|
327
|
+
formatter.rich.info("")
|
|
328
|
+
formatter.rich.info("Next steps:")
|
|
329
|
+
formatter.rich.info(" 1. Re-login to refresh your token:")
|
|
330
|
+
formatter.rich.info(" [cyan]tescmd auth login[/cyan]")
|
|
331
|
+
formatter.rich.info("")
|
|
332
|
+
formatter.rich.info(" 2. If the error persists, check your Tesla Developer Portal app")
|
|
333
|
+
formatter.rich.info(" at [cyan]https://developer.tesla.com[/cyan] and ensure all")
|
|
334
|
+
formatter.rich.info(" required scopes are enabled (user_data, energy_device_data, etc.).")
|
|
335
|
+
formatter.rich.info("")
|
|
336
|
+
formatter.rich.info(
|
|
337
|
+
"[dim]Note: 'tescmd auth status' shows scopes that were requested, not necessarily"
|
|
338
|
+
" what Tesla granted. Tesla silently drops scopes your Developer Portal app"
|
|
339
|
+
" isn't configured for.[/dim]"
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _handle_auth_error(
|
|
344
|
+
exc: AuthError,
|
|
345
|
+
formatter: OutputFormatter,
|
|
346
|
+
cmd_name: str,
|
|
347
|
+
) -> None:
|
|
348
|
+
"""Show a friendly authentication error with next steps.
|
|
349
|
+
|
|
350
|
+
Uses the exception's message when available, falling back to a generic
|
|
351
|
+
description.
|
|
352
|
+
"""
|
|
353
|
+
message = str(exc) or "Authentication failed. Your access token may be expired or invalid."
|
|
354
|
+
hint = "Run 'tescmd auth login' to re-authenticate."
|
|
355
|
+
|
|
356
|
+
if formatter.format == "json":
|
|
357
|
+
formatter.output_error(
|
|
358
|
+
code="auth_failed",
|
|
359
|
+
message=f"{message} {hint}",
|
|
360
|
+
command=cmd_name,
|
|
361
|
+
)
|
|
362
|
+
return
|
|
363
|
+
|
|
364
|
+
formatter.rich.error(message)
|
|
365
|
+
formatter.rich.info("")
|
|
366
|
+
formatter.rich.info("Next steps:")
|
|
367
|
+
formatter.rich.info(" [cyan]tescmd auth login[/cyan]")
|
|
368
|
+
formatter.rich.info("")
|
|
369
|
+
formatter.rich.info(
|
|
370
|
+
"[dim]If a refresh token is available and TESLA_CLIENT_ID is set,"
|
|
371
|
+
" tescmd will auto-refresh on the next request.[/dim]"
|
|
372
|
+
)
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _handle_vehicle_asleep(
|
|
376
|
+
exc: VehicleAsleepError,
|
|
377
|
+
formatter: OutputFormatter,
|
|
378
|
+
cmd_name: str,
|
|
379
|
+
) -> None:
|
|
380
|
+
"""Show a friendly message when the vehicle is asleep.
|
|
381
|
+
|
|
382
|
+
Uses the exception's message directly — it already distinguishes
|
|
383
|
+
between user-cancelled wake and actual API failure.
|
|
384
|
+
"""
|
|
385
|
+
message = str(exc)
|
|
386
|
+
hint = "Use --wake to send a billable wake via the API, or wake from the Tesla app for free."
|
|
387
|
+
|
|
388
|
+
if formatter.format == "json":
|
|
389
|
+
formatter.output_error(
|
|
390
|
+
code="vehicle_asleep",
|
|
391
|
+
message=f"{message} {hint}",
|
|
392
|
+
command=cmd_name,
|
|
393
|
+
)
|
|
394
|
+
return
|
|
395
|
+
|
|
396
|
+
formatter.rich.info(f"[yellow]{message}[/yellow]")
|
|
397
|
+
formatter.rich.info("")
|
|
398
|
+
formatter.rich.info("Next steps:")
|
|
399
|
+
formatter.rich.info(" [cyan]tescmd vehicle wake --wait[/cyan] (billable)")
|
|
400
|
+
formatter.rich.info(" Or wake from the Tesla app (free), then retry.")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _handle_registration_required(
|
|
404
|
+
exc: RegistrationRequiredError,
|
|
405
|
+
app_ctx: AppContext | None,
|
|
406
|
+
formatter: OutputFormatter,
|
|
407
|
+
cmd_name: str,
|
|
408
|
+
) -> None:
|
|
409
|
+
"""Try auto-registration, or show friendly instructions."""
|
|
410
|
+
from tescmd.auth.oauth import register_partner_account
|
|
411
|
+
from tescmd.models.config import AppSettings
|
|
412
|
+
|
|
413
|
+
settings = AppSettings()
|
|
414
|
+
region = (app_ctx.region if app_ctx else None) or settings.region
|
|
415
|
+
|
|
416
|
+
message = str(exc) or "Your application is not registered with the Fleet API."
|
|
417
|
+
if formatter.format == "json":
|
|
418
|
+
formatter.output_error(
|
|
419
|
+
code="registration_required",
|
|
420
|
+
message=f"{message} Run 'tescmd auth register' to fix this.",
|
|
421
|
+
command=cmd_name,
|
|
422
|
+
)
|
|
423
|
+
return
|
|
424
|
+
|
|
425
|
+
can_register = settings.client_id and settings.client_secret
|
|
426
|
+
domain = settings.domain
|
|
427
|
+
|
|
428
|
+
# Prompt for domain if we have credentials but no domain
|
|
429
|
+
if can_register and not domain:
|
|
430
|
+
from tescmd.cli.auth import _prompt_for_domain
|
|
431
|
+
|
|
432
|
+
domain = _prompt_for_domain(formatter)
|
|
433
|
+
|
|
434
|
+
# Try auto-fix if we have everything
|
|
435
|
+
if can_register and domain:
|
|
436
|
+
assert settings.client_id is not None
|
|
437
|
+
assert settings.client_secret is not None
|
|
438
|
+
formatter.rich.info(
|
|
439
|
+
"[yellow]Your app is not yet registered with the Fleet API."
|
|
440
|
+
" Registering now...[/yellow]"
|
|
441
|
+
)
|
|
442
|
+
try:
|
|
443
|
+
_result, _scopes = run_async(
|
|
444
|
+
register_partner_account(
|
|
445
|
+
client_id=settings.client_id,
|
|
446
|
+
client_secret=settings.client_secret,
|
|
447
|
+
domain=domain,
|
|
448
|
+
region=region,
|
|
449
|
+
)
|
|
450
|
+
)
|
|
451
|
+
formatter.rich.info("[green]Registration successful![/green]")
|
|
452
|
+
formatter.rich.info("")
|
|
453
|
+
formatter.rich.info("Please re-run your command:")
|
|
454
|
+
formatter.rich.info(f" [cyan]tescmd {cmd_name.replace('.', ' ')}[/cyan]")
|
|
455
|
+
return
|
|
456
|
+
except Exception as reg_exc:
|
|
457
|
+
formatter.rich.info(f"[red]Registration failed:[/red] {reg_exc}")
|
|
458
|
+
|
|
459
|
+
formatter.rich.info("")
|
|
460
|
+
formatter.rich.info(
|
|
461
|
+
"[yellow]Your application is not registered with the"
|
|
462
|
+
" Tesla Fleet API for this region.[/yellow]"
|
|
463
|
+
)
|
|
464
|
+
formatter.rich.info("")
|
|
465
|
+
formatter.rich.info("To fix this, run:")
|
|
466
|
+
formatter.rich.info(" [cyan]tescmd auth register[/cyan]")
|
|
467
|
+
formatter.rich.info("")
|
|
468
|
+
formatter.rich.info(
|
|
469
|
+
"[dim]This is a one-time step after creating your developer application.[/dim]"
|
|
470
|
+
)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _handle_tier_error(
|
|
474
|
+
exc: TierError,
|
|
475
|
+
formatter: OutputFormatter,
|
|
476
|
+
cmd_name: str,
|
|
477
|
+
) -> None:
|
|
478
|
+
"""Show a friendly tier-mismatch error with upgrade guidance."""
|
|
479
|
+
message = str(exc) or "This command requires 'full' tier setup."
|
|
480
|
+
|
|
481
|
+
if formatter.format == "json":
|
|
482
|
+
formatter.output_error(
|
|
483
|
+
code="tier_readonly",
|
|
484
|
+
message=message,
|
|
485
|
+
command=cmd_name,
|
|
486
|
+
)
|
|
487
|
+
return
|
|
488
|
+
|
|
489
|
+
formatter.rich.error(message)
|
|
490
|
+
formatter.rich.info("")
|
|
491
|
+
formatter.rich.info("Your setup tier is [yellow]readonly[/yellow].")
|
|
492
|
+
formatter.rich.info(
|
|
493
|
+
"Vehicle commands (lock, charge, climate, etc.) require [cyan]full[/cyan] tier."
|
|
494
|
+
)
|
|
495
|
+
formatter.rich.info("")
|
|
496
|
+
formatter.rich.info("To upgrade:")
|
|
497
|
+
formatter.rich.info(" [cyan]tescmd setup[/cyan]")
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _handle_key_not_enrolled(
|
|
501
|
+
exc: KeyNotEnrolledError,
|
|
502
|
+
formatter: OutputFormatter,
|
|
503
|
+
cmd_name: str,
|
|
504
|
+
) -> None:
|
|
505
|
+
"""Show a friendly key-not-enrolled error with enrollment steps."""
|
|
506
|
+
from tescmd.models.config import AppSettings
|
|
507
|
+
|
|
508
|
+
message = str(exc) or "Your key is not enrolled on this vehicle."
|
|
509
|
+
settings = AppSettings()
|
|
510
|
+
domain = settings.domain
|
|
511
|
+
|
|
512
|
+
if formatter.format == "json":
|
|
513
|
+
if domain:
|
|
514
|
+
message += f" Enroll at https://tesla.com/_ak/{domain}"
|
|
515
|
+
formatter.output_error(
|
|
516
|
+
code="key_not_enrolled",
|
|
517
|
+
message=message,
|
|
518
|
+
command=cmd_name,
|
|
519
|
+
)
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
formatter.rich.error(message)
|
|
523
|
+
formatter.rich.info("")
|
|
524
|
+
if domain:
|
|
525
|
+
enroll_url = f"https://tesla.com/_ak/{domain}"
|
|
526
|
+
formatter.rich.info("To enroll your key:")
|
|
527
|
+
formatter.rich.info(f" 1. Open [link={enroll_url}]{enroll_url}[/link] on your phone")
|
|
528
|
+
formatter.rich.info(" 2. Tap [bold]Finish Setup[/bold] on the web page")
|
|
529
|
+
formatter.rich.info(
|
|
530
|
+
" 3. Approve the [bold]Add Virtual Key[/bold] prompt in the Tesla app"
|
|
531
|
+
)
|
|
532
|
+
formatter.rich.info("")
|
|
533
|
+
formatter.rich.info(
|
|
534
|
+
"Or run [cyan]tescmd key enroll[/cyan] to see the full enrollment guide."
|
|
535
|
+
)
|
|
536
|
+
else:
|
|
537
|
+
formatter.rich.info("To enroll your key:")
|
|
538
|
+
formatter.rich.info(" [cyan]tescmd key enroll[/cyan]")
|
|
539
|
+
formatter.rich.info("")
|
|
540
|
+
formatter.rich.info("[dim]Set TESLA_DOMAIN first if not already configured.[/dim]")
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _handle_session_error(
|
|
544
|
+
exc: SessionError,
|
|
545
|
+
formatter: OutputFormatter,
|
|
546
|
+
cmd_name: str,
|
|
547
|
+
) -> None:
|
|
548
|
+
"""Show a friendly session handshake error with recovery steps."""
|
|
549
|
+
message = str(exc) or "Session handshake with the vehicle failed."
|
|
550
|
+
|
|
551
|
+
if formatter.format == "json":
|
|
552
|
+
formatter.output_error(
|
|
553
|
+
code="session_error",
|
|
554
|
+
message=message,
|
|
555
|
+
command=cmd_name,
|
|
556
|
+
)
|
|
557
|
+
return
|
|
558
|
+
|
|
559
|
+
formatter.rich.error(message)
|
|
560
|
+
formatter.rich.info("")
|
|
561
|
+
formatter.rich.info("Possible causes:")
|
|
562
|
+
formatter.rich.info(" - Vehicle is temporarily unreachable")
|
|
563
|
+
formatter.rich.info(" - Local key pair is corrupted")
|
|
564
|
+
formatter.rich.info(" - Key was re-generated but not re-enrolled")
|
|
565
|
+
formatter.rich.info("")
|
|
566
|
+
formatter.rich.info("Try again, or if the problem persists:")
|
|
567
|
+
formatter.rich.info(" [cyan]tescmd key generate --force[/cyan] (regenerate key pair)")
|
|
568
|
+
formatter.rich.info(" [cyan]tescmd key deploy[/cyan] (re-deploy public key)")
|
|
569
|
+
formatter.rich.info(" [cyan]tescmd key enroll[/cyan] (re-enroll on vehicle)")
|
|
570
|
+
|
|
571
|
+
|
|
572
|
+
def _handle_keyring_error(
|
|
573
|
+
exc: Exception,
|
|
574
|
+
formatter: OutputFormatter,
|
|
575
|
+
cmd_name: str,
|
|
576
|
+
) -> None:
|
|
577
|
+
"""Show a friendly error when the OS keyring is unavailable."""
|
|
578
|
+
message = f"OS keyring error: {exc}"
|
|
579
|
+
|
|
580
|
+
if formatter.format == "json":
|
|
581
|
+
formatter.output_error(
|
|
582
|
+
code="keyring_error",
|
|
583
|
+
message=(
|
|
584
|
+
f"{message} Set TESLA_TOKEN_FILE to store tokens in a file "
|
|
585
|
+
"instead of the OS keyring."
|
|
586
|
+
),
|
|
587
|
+
command=cmd_name,
|
|
588
|
+
)
|
|
589
|
+
return
|
|
590
|
+
|
|
591
|
+
formatter.rich.error(message)
|
|
592
|
+
formatter.rich.info("")
|
|
593
|
+
formatter.rich.info("The OS keyring (macOS Keychain, GNOME Keyring, etc.) is unavailable.")
|
|
594
|
+
formatter.rich.info("This is common in Docker, headless Linux, and CI environments.")
|
|
595
|
+
formatter.rich.info("")
|
|
596
|
+
formatter.rich.info("To fix this, set a file-based token path:")
|
|
597
|
+
formatter.rich.info(" [cyan]export TESLA_TOKEN_FILE=~/.config/tescmd/tokens.json[/cyan]")
|
|
598
|
+
formatter.rich.info("")
|
|
599
|
+
formatter.rich.info(
|
|
600
|
+
"[dim]Tokens will be stored in plaintext with restricted file permissions.[/dim]"
|
|
601
|
+
)
|