tescmd 0.2.0__py3-none-any.whl → 0.4.0__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 +1 -1
- tescmd/api/client.py +41 -4
- tescmd/api/command.py +1 -1
- tescmd/api/errors.py +5 -0
- tescmd/api/signed_command.py +19 -14
- tescmd/auth/oauth.py +15 -1
- tescmd/auth/server.py +6 -1
- tescmd/auth/token_store.py +8 -1
- tescmd/cache/response_cache.py +8 -1
- tescmd/cli/_client.py +142 -20
- tescmd/cli/_options.py +2 -4
- tescmd/cli/auth.py +255 -106
- tescmd/cli/energy.py +2 -0
- tescmd/cli/key.py +6 -7
- tescmd/cli/main.py +27 -8
- tescmd/cli/mcp_cmd.py +153 -0
- tescmd/cli/nav.py +3 -1
- tescmd/cli/openclaw.py +169 -0
- tescmd/cli/security.py +7 -1
- tescmd/cli/serve.py +923 -0
- tescmd/cli/setup.py +147 -58
- tescmd/cli/sharing.py +2 -0
- tescmd/cli/status.py +1 -1
- tescmd/cli/trunk.py +8 -17
- tescmd/cli/user.py +16 -1
- tescmd/cli/vehicle.py +135 -462
- tescmd/deploy/github_pages.py +21 -2
- tescmd/deploy/tailscale_serve.py +96 -8
- tescmd/mcp/__init__.py +7 -0
- tescmd/mcp/server.py +648 -0
- tescmd/models/auth.py +5 -2
- tescmd/openclaw/__init__.py +23 -0
- tescmd/openclaw/bridge.py +330 -0
- tescmd/openclaw/config.py +167 -0
- tescmd/openclaw/dispatcher.py +529 -0
- tescmd/openclaw/emitter.py +175 -0
- tescmd/openclaw/filters.py +123 -0
- tescmd/openclaw/gateway.py +700 -0
- tescmd/openclaw/telemetry_store.py +53 -0
- tescmd/output/rich_output.py +46 -14
- tescmd/protocol/commands.py +2 -2
- tescmd/protocol/encoder.py +16 -13
- tescmd/protocol/payloads.py +132 -11
- tescmd/protocol/session.py +8 -5
- tescmd/protocol/signer.py +3 -17
- tescmd/telemetry/__init__.py +9 -0
- tescmd/telemetry/cache_sink.py +154 -0
- tescmd/telemetry/csv_sink.py +180 -0
- tescmd/telemetry/dashboard.py +4 -4
- tescmd/telemetry/fanout.py +49 -0
- tescmd/telemetry/fields.py +308 -129
- tescmd/telemetry/mapper.py +239 -0
- tescmd/telemetry/server.py +26 -19
- tescmd/telemetry/setup.py +468 -0
- tescmd/telemetry/tailscale.py +78 -16
- tescmd/telemetry/tui.py +1716 -0
- tescmd/triggers/__init__.py +18 -0
- tescmd/triggers/manager.py +264 -0
- tescmd/triggers/models.py +93 -0
- tescmd-0.4.0.dist-info/METADATA +300 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/RECORD +64 -42
- tescmd-0.2.0.dist-info/METADATA +0 -495
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/WHEEL +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/entry_points.txt +0 -0
- {tescmd-0.2.0.dist-info → tescmd-0.4.0.dist-info}/licenses/LICENSE +0 -0
tescmd/__init__.py
CHANGED
tescmd/api/client.py
CHANGED
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import json
|
|
7
|
+
import os as _os
|
|
7
8
|
from typing import TYPE_CHECKING, Any
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
@@ -18,6 +19,7 @@ from tescmd.api.errors import (
|
|
|
18
19
|
RateLimitError,
|
|
19
20
|
RegistrationRequiredError,
|
|
20
21
|
TeslaAPIError,
|
|
22
|
+
VCPRequiredError,
|
|
21
23
|
VehicleAsleepError,
|
|
22
24
|
)
|
|
23
25
|
|
|
@@ -25,6 +27,10 @@ from tescmd.api.errors import (
|
|
|
25
27
|
# Region -> base URL mapping
|
|
26
28
|
# ---------------------------------------------------------------------------
|
|
27
29
|
|
|
30
|
+
# Allow a full override via TESLA_API_BASE_URL (e.g. for proxies / staging).
|
|
31
|
+
# When set the region parameter is ignored and all requests go to this URL.
|
|
32
|
+
_API_BASE_OVERRIDE: str | None = _os.environ.get("TESLA_API_BASE_URL")
|
|
33
|
+
|
|
28
34
|
REGION_BASE_URLS: dict[str, str] = {
|
|
29
35
|
"na": "https://fleet-api.prd.na.vn.cloud.tesla.com",
|
|
30
36
|
"eu": "https://fleet-api.prd.eu.vn.cloud.tesla.com",
|
|
@@ -56,7 +62,7 @@ class TeslaFleetClient:
|
|
|
56
62
|
on_token_refresh: Callable[[], Awaitable[str | None]] | None = None,
|
|
57
63
|
on_rate_limit_wait: Callable[[int, int, int], Awaitable[None]] | None = None,
|
|
58
64
|
) -> None:
|
|
59
|
-
base_url = REGION_BASE_URLS.get(region)
|
|
65
|
+
base_url = _API_BASE_OVERRIDE or REGION_BASE_URLS.get(region)
|
|
60
66
|
if base_url is None:
|
|
61
67
|
msg = f"Unknown region {region!r}; expected one of {sorted(REGION_BASE_URLS)}"
|
|
62
68
|
raise ValueError(msg)
|
|
@@ -160,11 +166,30 @@ class TeslaFleetClient:
|
|
|
160
166
|
"""Translate HTTP status codes to domain exceptions and return JSON."""
|
|
161
167
|
if response.status_code == 429:
|
|
162
168
|
raw = response.headers.get("retry-after")
|
|
163
|
-
retry_after: int | None =
|
|
169
|
+
retry_after: int | None = None
|
|
170
|
+
if raw is not None:
|
|
171
|
+
try:
|
|
172
|
+
retry_after = int(raw)
|
|
173
|
+
except ValueError:
|
|
174
|
+
retry_after = 30 # Fallback for HTTP-date strings
|
|
164
175
|
raise RateLimitError(retry_after=retry_after)
|
|
165
176
|
|
|
166
177
|
if response.status_code == 408:
|
|
167
|
-
|
|
178
|
+
# Tesla returns HTTP 408 for both "vehicle is asleep" and general
|
|
179
|
+
# request timeouts. Inspect the body to distinguish the two.
|
|
180
|
+
body_text = response.text[:300]
|
|
181
|
+
body_lower = body_text.lower()
|
|
182
|
+
if "asleep" in body_lower or "offline" in body_lower or "unavailable" in body_lower:
|
|
183
|
+
raise VehicleAsleepError("Vehicle is asleep", status_code=408)
|
|
184
|
+
# No explicit sleep indicator — still raise VehicleAsleepError for
|
|
185
|
+
# backwards compatibility (most 408s *are* sleep), but include the
|
|
186
|
+
# raw body so callers can see what actually happened.
|
|
187
|
+
msg = (
|
|
188
|
+
f"Vehicle request timed out (HTTP 408): {body_text}"
|
|
189
|
+
if body_text
|
|
190
|
+
else "Vehicle is asleep"
|
|
191
|
+
)
|
|
192
|
+
raise VehicleAsleepError(msg, status_code=408)
|
|
168
193
|
|
|
169
194
|
if response.status_code == 412:
|
|
170
195
|
raise RegistrationRequiredError(
|
|
@@ -175,8 +200,16 @@ class TeslaFleetClient:
|
|
|
175
200
|
|
|
176
201
|
if response.status_code == 403:
|
|
177
202
|
text = response.text[:200]
|
|
178
|
-
|
|
203
|
+
text_lower = text.lower()
|
|
204
|
+
if "missing scopes" in text_lower:
|
|
179
205
|
raise MissingScopesError(text, status_code=403)
|
|
206
|
+
if "vehicle command protocol required" in text_lower:
|
|
207
|
+
raise VCPRequiredError(
|
|
208
|
+
"This command is not available via the Fleet API when "
|
|
209
|
+
"Vehicle Command Protocol is enabled. Use the Tesla app "
|
|
210
|
+
"or run tescmd behind a tesla-http-proxy.",
|
|
211
|
+
status_code=403,
|
|
212
|
+
)
|
|
180
213
|
raise AuthError(f"HTTP 403: {text}", status_code=403)
|
|
181
214
|
|
|
182
215
|
if response.status_code >= 400:
|
|
@@ -186,6 +219,10 @@ class TeslaFleetClient:
|
|
|
186
219
|
status_code=response.status_code,
|
|
187
220
|
)
|
|
188
221
|
|
|
222
|
+
# 204 No Content — return empty dict (no body to parse)
|
|
223
|
+
if response.status_code == 204:
|
|
224
|
+
return {}
|
|
225
|
+
|
|
189
226
|
try:
|
|
190
227
|
result: dict[str, Any] = response.json()
|
|
191
228
|
except json.JSONDecodeError as exc:
|
tescmd/api/command.py
CHANGED
|
@@ -491,7 +491,7 @@ class CommandAPI:
|
|
|
491
491
|
return await self._command(vin, "actuate_trunk", {"which_trunk": which_trunk})
|
|
492
492
|
|
|
493
493
|
async def window_control(
|
|
494
|
-
self, vin: str, *, command: str, lat: float, lon: float
|
|
494
|
+
self, vin: str, *, command: str, lat: float = 0.0, lon: float = 0.0
|
|
495
495
|
) -> CommandResponse:
|
|
496
496
|
return await self._command(
|
|
497
497
|
vin, "window_control", {"command": command, "lat": lat, "lon": lon}
|
tescmd/api/errors.py
CHANGED
|
@@ -72,6 +72,11 @@ class MissingScopesError(AuthError):
|
|
|
72
72
|
"""Raised on HTTP 403 when the token lacks required OAuth scopes."""
|
|
73
73
|
|
|
74
74
|
|
|
75
|
+
class VCPRequiredError(TeslaAPIError):
|
|
76
|
+
"""Raised when the vehicle requires Vehicle Command Protocol for a command
|
|
77
|
+
that has no VCP proto definition (e.g. navigation commands)."""
|
|
78
|
+
|
|
79
|
+
|
|
75
80
|
class KeyNotEnrolledError(TeslaAPIError):
|
|
76
81
|
"""Vehicle rejected the command — key not enrolled."""
|
|
77
82
|
|
tescmd/api/signed_command.py
CHANGED
|
@@ -103,7 +103,7 @@ class SignedCommandAPI:
|
|
|
103
103
|
|
|
104
104
|
# Metadata + HMAC
|
|
105
105
|
counter = session.next_counter()
|
|
106
|
-
expires_at = default_expiry(
|
|
106
|
+
expires_at = default_expiry(time_zero=session.time_zero)
|
|
107
107
|
metadata_bytes = encode_metadata(
|
|
108
108
|
epoch=session.epoch,
|
|
109
109
|
expires_at=expires_at,
|
|
@@ -115,17 +115,16 @@ class SignedCommandAPI:
|
|
|
115
115
|
session.signing_key,
|
|
116
116
|
metadata_bytes,
|
|
117
117
|
payload,
|
|
118
|
-
domain=domain,
|
|
119
118
|
)
|
|
120
119
|
|
|
121
120
|
logger.debug(
|
|
122
121
|
"Signed command '%s' for %s: counter=%d, expires_at=%d "
|
|
123
|
-
"(
|
|
122
|
+
"(time_zero=%.1f), payload=%s, metadata=%s, hmac_tag=%s",
|
|
124
123
|
command,
|
|
125
124
|
vin,
|
|
126
125
|
counter,
|
|
127
126
|
expires_at,
|
|
128
|
-
session.
|
|
127
|
+
session.time_zero,
|
|
129
128
|
payload.hex(),
|
|
130
129
|
metadata_bytes.hex(),
|
|
131
130
|
hmac_tag.hex(),
|
|
@@ -168,15 +167,18 @@ class SignedCommandAPI:
|
|
|
168
167
|
# etc.) propagate so callers like auto_wake() can handle them.
|
|
169
168
|
raise
|
|
170
169
|
|
|
171
|
-
result = self._parse_signed_response(data, vin, command)
|
|
170
|
+
result, fault = self._parse_signed_response(data, vin, command, domain)
|
|
172
171
|
if result is not None:
|
|
173
172
|
return result
|
|
174
173
|
|
|
175
174
|
# Stale session — _parse_signed_response already invalidated the cache.
|
|
176
175
|
# Retry once with a fresh handshake.
|
|
177
176
|
if _retried:
|
|
177
|
+
fault_name = fault.name if fault else "unknown"
|
|
178
|
+
desc = FAULT_DESCRIPTIONS.get(fault, fault_name) if fault else fault_name
|
|
178
179
|
raise SessionError(
|
|
179
|
-
f"Signed command '{command}' failed for {vin} after session refresh"
|
|
180
|
+
f"Signed command '{command}' failed for {vin} after session refresh "
|
|
181
|
+
f"(vehicle fault: {desc})"
|
|
180
182
|
)
|
|
181
183
|
logger.debug(
|
|
182
184
|
"Stale session for %s (%s), retrying with fresh handshake",
|
|
@@ -194,15 +196,16 @@ class SignedCommandAPI:
|
|
|
194
196
|
data: dict[str, Any],
|
|
195
197
|
vin: str,
|
|
196
198
|
command: str,
|
|
197
|
-
|
|
199
|
+
domain: Domain,
|
|
200
|
+
) -> tuple[CommandResponse | None, MessageFault | None]:
|
|
198
201
|
"""Parse a signed_command endpoint response.
|
|
199
202
|
|
|
200
203
|
The endpoint returns ``{"response": "<base64-encoded RoutableMessage>"}``.
|
|
201
204
|
Decodes the protobuf, checks for vehicle-side fault codes, and returns
|
|
202
|
-
|
|
205
|
+
``(CommandResponse, None)`` on success.
|
|
203
206
|
|
|
204
|
-
Returns ``None`` if the fault indicates a stale session — the
|
|
205
|
-
should invalidate the session and retry with a fresh handshake.
|
|
207
|
+
Returns ``(None, fault)`` if the fault indicates a stale session — the
|
|
208
|
+
caller should invalidate the session and retry with a fresh handshake.
|
|
206
209
|
"""
|
|
207
210
|
response_b64: str = data.get("response", "")
|
|
208
211
|
if not response_b64:
|
|
@@ -237,19 +240,21 @@ class SignedCommandAPI:
|
|
|
237
240
|
|
|
238
241
|
# Stale session — invalidate and signal caller to retry.
|
|
239
242
|
if fault in _STALE_SESSION_FAULTS:
|
|
240
|
-
self._session_mgr.invalidate(vin)
|
|
243
|
+
self._session_mgr.invalidate(vin, domain)
|
|
241
244
|
logger.debug(
|
|
242
245
|
"Stale session fault %s for %s, invalidated cache",
|
|
243
246
|
fault.name,
|
|
244
247
|
vin,
|
|
245
248
|
)
|
|
246
|
-
return None
|
|
249
|
+
return None, fault
|
|
247
250
|
|
|
248
251
|
# All other faults — raise with descriptive message.
|
|
249
|
-
raise SessionError(
|
|
252
|
+
raise SessionError(
|
|
253
|
+
f"Vehicle rejected command '{command}' for {vin}: {desc} (fault: {fault.name})"
|
|
254
|
+
)
|
|
250
255
|
|
|
251
256
|
logger.debug("Signed command '%s' succeeded for %s", command, vin)
|
|
252
|
-
return CommandResponse(response=CommandResult(result=True))
|
|
257
|
+
return CommandResponse(response=CommandResult(result=True)), None
|
|
253
258
|
|
|
254
259
|
# ------------------------------------------------------------------
|
|
255
260
|
# Named method delegation
|
tescmd/auth/oauth.py
CHANGED
|
@@ -85,9 +85,13 @@ async def exchange_code(
|
|
|
85
85
|
code_verifier: str,
|
|
86
86
|
client_id: str,
|
|
87
87
|
client_secret: str | None = None,
|
|
88
|
-
redirect_uri: str =
|
|
88
|
+
redirect_uri: str | None = None,
|
|
89
89
|
) -> TokenData:
|
|
90
90
|
"""Exchange an authorization code for tokens."""
|
|
91
|
+
if redirect_uri is None:
|
|
92
|
+
from tescmd.models.auth import DEFAULT_REDIRECT_URI
|
|
93
|
+
|
|
94
|
+
redirect_uri = DEFAULT_REDIRECT_URI
|
|
91
95
|
payload: dict[str, Any] = {
|
|
92
96
|
"grant_type": "authorization_code",
|
|
93
97
|
"code": code,
|
|
@@ -204,6 +208,14 @@ async def register_partner_account(
|
|
|
204
208
|
)
|
|
205
209
|
|
|
206
210
|
if resp.status_code >= 400:
|
|
211
|
+
# HTTP 422 with "already been taken" means the key or domain is
|
|
212
|
+
# already registered. Treat as success — registration is idempotent.
|
|
213
|
+
if resp.status_code == 422 and "already been taken" in resp.text:
|
|
214
|
+
logger.info(
|
|
215
|
+
"Partner already registered for %s region (key already taken)",
|
|
216
|
+
region,
|
|
217
|
+
)
|
|
218
|
+
return {"already_registered": True}, granted_scopes
|
|
207
219
|
raise AuthError(
|
|
208
220
|
f"Partner registration failed (HTTP {resp.status_code}): {resp.text}",
|
|
209
221
|
status_code=resp.status_code,
|
|
@@ -278,6 +290,8 @@ async def login_flow(
|
|
|
278
290
|
state,
|
|
279
291
|
force_consent=force_consent,
|
|
280
292
|
)
|
|
293
|
+
logger.info("Authorization URL: %s", url)
|
|
294
|
+
print(f"\n {url}\n")
|
|
281
295
|
webbrowser.open(url)
|
|
282
296
|
|
|
283
297
|
code, callback_state = server.wait_for_callback(timeout=120)
|
tescmd/auth/server.py
CHANGED
|
@@ -75,7 +75,11 @@ class _CallbackHandler(BaseHTTPRequestHandler):
|
|
|
75
75
|
class OAuthCallbackServer(HTTPServer):
|
|
76
76
|
"""HTTPServer that waits for a single OAuth redirect callback."""
|
|
77
77
|
|
|
78
|
-
def __init__(self, port: int =
|
|
78
|
+
def __init__(self, port: int | None = None) -> None:
|
|
79
|
+
if port is None:
|
|
80
|
+
from tescmd.models.auth import DEFAULT_PORT
|
|
81
|
+
|
|
82
|
+
port = DEFAULT_PORT
|
|
79
83
|
super().__init__(("127.0.0.1", port), _CallbackHandler)
|
|
80
84
|
self.callback_event = threading.Event()
|
|
81
85
|
self.callback_result: tuple[str | None, str | None, str | None] = (
|
|
@@ -104,5 +108,6 @@ class OAuthCallbackServer(HTTPServer):
|
|
|
104
108
|
def stop(self) -> None:
|
|
105
109
|
"""Shut down the server and join the daemon thread."""
|
|
106
110
|
self.shutdown()
|
|
111
|
+
self.server_close()
|
|
107
112
|
if self._thread is not None:
|
|
108
113
|
self._thread.join(timeout=5)
|
tescmd/auth/token_store.py
CHANGED
|
@@ -4,12 +4,15 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import contextlib
|
|
6
6
|
import json
|
|
7
|
+
import logging
|
|
7
8
|
import os
|
|
8
9
|
import tempfile
|
|
9
10
|
import warnings
|
|
10
11
|
from pathlib import Path
|
|
11
12
|
from typing import Any, Protocol
|
|
12
13
|
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
13
16
|
SERVICE_NAME = "tescmd"
|
|
14
17
|
|
|
15
18
|
# Module-level flag so the file-fallback warning fires at most once.
|
|
@@ -227,7 +230,11 @@ class TokenStore:
|
|
|
227
230
|
raw = self._backend.get(self._key("metadata"))
|
|
228
231
|
if raw is None:
|
|
229
232
|
return None
|
|
230
|
-
|
|
233
|
+
try:
|
|
234
|
+
result: dict[str, Any] = json.loads(raw)
|
|
235
|
+
except (json.JSONDecodeError, ValueError):
|
|
236
|
+
logger.warning("Corrupt token metadata — ignoring")
|
|
237
|
+
return None
|
|
231
238
|
return result
|
|
232
239
|
|
|
233
240
|
# -- mutators ------------------------------------------------------------
|
tescmd/cache/response_cache.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import json
|
|
6
|
+
import logging
|
|
6
7
|
import time
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from typing import TYPE_CHECKING, Any
|
|
@@ -12,6 +13,8 @@ from tescmd.cache.keys import cache_key
|
|
|
12
13
|
if TYPE_CHECKING:
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
15
18
|
|
|
16
19
|
@dataclass(frozen=True)
|
|
17
20
|
class CacheResult:
|
|
@@ -202,7 +205,10 @@ class ResponseCache:
|
|
|
202
205
|
"created_at": now,
|
|
203
206
|
"expires_at": now + ttl,
|
|
204
207
|
}
|
|
205
|
-
|
|
208
|
+
# Atomic write: temp file + rename avoids torn reads.
|
|
209
|
+
tmp = path.with_suffix(".tmp")
|
|
210
|
+
tmp.write_text(json.dumps(entry), encoding="utf-8")
|
|
211
|
+
tmp.rename(path)
|
|
206
212
|
|
|
207
213
|
@staticmethod
|
|
208
214
|
def _read_json(path: Path) -> dict[str, Any] | None:
|
|
@@ -211,4 +217,5 @@ class ResponseCache:
|
|
|
211
217
|
try:
|
|
212
218
|
return json.loads(path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
|
|
213
219
|
except (json.JSONDecodeError, OSError):
|
|
220
|
+
logger.warning("Corrupt or unreadable cache file: %s", path)
|
|
214
221
|
return None
|
tescmd/cli/_client.py
CHANGED
|
@@ -4,6 +4,8 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
6
|
import contextlib
|
|
7
|
+
import logging
|
|
8
|
+
import sys
|
|
7
9
|
import time
|
|
8
10
|
from pathlib import Path
|
|
9
11
|
from typing import TYPE_CHECKING, Any, TypeVar
|
|
@@ -30,12 +32,15 @@ from tescmd.models.vehicle import VehicleData
|
|
|
30
32
|
if TYPE_CHECKING:
|
|
31
33
|
from collections.abc import Awaitable, Callable
|
|
32
34
|
|
|
35
|
+
from pydantic import BaseModel
|
|
33
36
|
from rich.status import Status
|
|
34
37
|
|
|
35
38
|
from tescmd.api.signed_command import SignedCommandAPI
|
|
36
39
|
from tescmd.cli.main import AppContext
|
|
37
40
|
from tescmd.output.formatter import OutputFormatter
|
|
38
41
|
|
|
42
|
+
logger = logging.getLogger(__name__)
|
|
43
|
+
|
|
39
44
|
T = TypeVar("T")
|
|
40
45
|
|
|
41
46
|
# ---------------------------------------------------------------------------
|
|
@@ -52,20 +57,32 @@ _WAKE_BACKOFF_FACTOR = 1.5
|
|
|
52
57
|
def _make_token_refresher(
|
|
53
58
|
store: TokenStore, settings: AppSettings
|
|
54
59
|
) -> Callable[[], Awaitable[str | None]] | None:
|
|
55
|
-
"""Return an async callback that refreshes the access token, or *None*.
|
|
60
|
+
"""Return an async callback that refreshes the access token, or *None*.
|
|
61
|
+
|
|
62
|
+
When the refresh token is expired/revoked (``login_required``),
|
|
63
|
+
prompts the user to re-authenticate via the browser on TTY.
|
|
64
|
+
In non-TTY mode, raises with guidance to run ``tescmd auth login``.
|
|
65
|
+
"""
|
|
56
66
|
refresh_token = store.refresh_token
|
|
57
67
|
client_id = settings.client_id
|
|
58
68
|
if not refresh_token or not client_id:
|
|
59
69
|
return None
|
|
60
70
|
|
|
61
71
|
async def _refresh() -> str | None:
|
|
72
|
+
from tescmd.api.errors import AuthError
|
|
62
73
|
from tescmd.auth.oauth import refresh_access_token
|
|
63
74
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
75
|
+
try:
|
|
76
|
+
token_data = await refresh_access_token(
|
|
77
|
+
refresh_token=refresh_token,
|
|
78
|
+
client_id=client_id,
|
|
79
|
+
client_secret=settings.client_secret,
|
|
80
|
+
)
|
|
81
|
+
except AuthError as exc:
|
|
82
|
+
if "login_required" in str(exc):
|
|
83
|
+
return await reauth_on_expired_refresh(store, settings)
|
|
84
|
+
raise
|
|
85
|
+
|
|
69
86
|
meta = store.metadata or {}
|
|
70
87
|
store.save(
|
|
71
88
|
access_token=token_data.access_token,
|
|
@@ -79,6 +96,66 @@ def _make_token_refresher(
|
|
|
79
96
|
return _refresh
|
|
80
97
|
|
|
81
98
|
|
|
99
|
+
async def reauth_on_expired_refresh(store: TokenStore, settings: AppSettings) -> str:
|
|
100
|
+
"""Handle an expired/revoked refresh token by prompting for re-auth.
|
|
101
|
+
|
|
102
|
+
On TTY: asks the user to re-authenticate via the browser.
|
|
103
|
+
Non-TTY: raises with guidance to run ``tescmd auth login``.
|
|
104
|
+
|
|
105
|
+
Returns the new access token on success.
|
|
106
|
+
"""
|
|
107
|
+
from tescmd.api.errors import AuthError
|
|
108
|
+
from tescmd.models.auth import DEFAULT_SCOPES
|
|
109
|
+
|
|
110
|
+
client_id = settings.client_id
|
|
111
|
+
if not client_id:
|
|
112
|
+
raise AuthError(
|
|
113
|
+
"Refresh token expired and TESLA_CLIENT_ID is not set. "
|
|
114
|
+
"Run 'tescmd auth login' to re-authenticate.",
|
|
115
|
+
status_code=401,
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
if not sys.stdin.isatty():
|
|
119
|
+
raise AuthError(
|
|
120
|
+
"Refresh token is invalid or expired. Run 'tescmd auth login' to re-authenticate.",
|
|
121
|
+
status_code=401,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
click.echo("")
|
|
125
|
+
click.echo("Your refresh token has expired or been revoked.")
|
|
126
|
+
if not click.confirm("Re-authenticate via browser?", default=True):
|
|
127
|
+
raise AuthError(
|
|
128
|
+
"Re-authentication cancelled. Run 'tescmd auth login' to authenticate manually.",
|
|
129
|
+
status_code=401,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
from tescmd.auth.oauth import login_flow
|
|
133
|
+
|
|
134
|
+
meta = store.metadata or {}
|
|
135
|
+
region = meta.get("region", "na")
|
|
136
|
+
scopes = meta.get("scopes", DEFAULT_SCOPES)
|
|
137
|
+
from tescmd.models.auth import DEFAULT_PORT
|
|
138
|
+
|
|
139
|
+
port = DEFAULT_PORT
|
|
140
|
+
|
|
141
|
+
click.echo("")
|
|
142
|
+
click.echo("Opening your browser to sign in to Tesla...")
|
|
143
|
+
click.echo("When prompted, click 'Select All' and then 'Allow'.")
|
|
144
|
+
|
|
145
|
+
token_data = await login_flow(
|
|
146
|
+
client_id=client_id,
|
|
147
|
+
client_secret=settings.client_secret,
|
|
148
|
+
redirect_uri=f"http://localhost:{port}/callback",
|
|
149
|
+
scopes=scopes,
|
|
150
|
+
port=port,
|
|
151
|
+
token_store=store,
|
|
152
|
+
region=region,
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
click.echo("Re-authenticated successfully.")
|
|
156
|
+
return token_data.access_token
|
|
157
|
+
|
|
158
|
+
|
|
82
159
|
def _make_rate_limit_handler(
|
|
83
160
|
formatter: OutputFormatter,
|
|
84
161
|
) -> Callable[[int, int, int], Awaitable[None]]:
|
|
@@ -278,15 +355,17 @@ async def cached_api_call(
|
|
|
278
355
|
fetch: Callable[[], Awaitable[Any]],
|
|
279
356
|
ttl: int | None = None,
|
|
280
357
|
params: dict[str, str] | None = None,
|
|
358
|
+
model_class: type[BaseModel] | None = None,
|
|
281
359
|
) -> Any:
|
|
282
360
|
"""Fetch API data with transparent caching.
|
|
283
361
|
|
|
284
362
|
1. Compute cache key via :func:`generic_cache_key`.
|
|
285
|
-
2. On hit →
|
|
286
|
-
3. On miss → call *fetch()*, serialise
|
|
363
|
+
2. On hit → reconstruct via *model_class* (if given), else return dict.
|
|
364
|
+
3. On miss → call *fetch()*, serialise to dict for cache, return original.
|
|
287
365
|
|
|
288
|
-
|
|
289
|
-
|
|
366
|
+
When *model_class* is provided, cache hits are reconstructed with
|
|
367
|
+
``model_class.model_validate(cached_dict)`` so callers always receive
|
|
368
|
+
Pydantic models regardless of hit/miss.
|
|
290
369
|
"""
|
|
291
370
|
formatter = app_ctx.formatter
|
|
292
371
|
cache = get_cache(app_ctx)
|
|
@@ -306,7 +385,7 @@ async def cached_api_call(
|
|
|
306
385
|
f" (TTL {cached.ttl_seconds}s)."
|
|
307
386
|
f" Use --fresh for live data.[/dim]"
|
|
308
387
|
)
|
|
309
|
-
return cached.data
|
|
388
|
+
return _reconstruct(cached.data, model_class)
|
|
310
389
|
|
|
311
390
|
result = await fetch()
|
|
312
391
|
|
|
@@ -325,6 +404,37 @@ async def cached_api_call(
|
|
|
325
404
|
return result
|
|
326
405
|
|
|
327
406
|
|
|
407
|
+
def _reconstruct(data: Any, model_class: type[BaseModel] | None) -> Any:
|
|
408
|
+
"""Rebuild a Pydantic model from cached dict data.
|
|
409
|
+
|
|
410
|
+
* *model_class* provided + dict → ``model_class.model_validate(data)``
|
|
411
|
+
* *model_class* provided + list → validate each element
|
|
412
|
+
* *model_class* provided + ``{"_value": x}`` wrapper → unwrap scalar
|
|
413
|
+
* *model_class* is ``None`` → return raw cached data (dict / list)
|
|
414
|
+
|
|
415
|
+
If validation fails (e.g. cache corruption, schema change after upgrade),
|
|
416
|
+
falls back to returning the raw cached data with a warning log.
|
|
417
|
+
"""
|
|
418
|
+
if model_class is None:
|
|
419
|
+
return data
|
|
420
|
+
if isinstance(data, dict) and "_value" in data and len(data) == 1:
|
|
421
|
+
return data["_value"]
|
|
422
|
+
try:
|
|
423
|
+
if isinstance(data, list):
|
|
424
|
+
return [
|
|
425
|
+
model_class.model_validate(item) if isinstance(item, dict) else item
|
|
426
|
+
for item in data
|
|
427
|
+
]
|
|
428
|
+
if isinstance(data, dict):
|
|
429
|
+
return model_class.model_validate(data)
|
|
430
|
+
except Exception:
|
|
431
|
+
logger.warning(
|
|
432
|
+
"Cache reconstruction failed for %s — returning raw dict",
|
|
433
|
+
model_class.__name__,
|
|
434
|
+
)
|
|
435
|
+
return data
|
|
436
|
+
|
|
437
|
+
|
|
328
438
|
async def cached_vehicle_data(
|
|
329
439
|
app_ctx: AppContext,
|
|
330
440
|
vehicle_api: VehicleAPI,
|
|
@@ -424,6 +534,23 @@ def _check_signing_requirement(
|
|
|
424
534
|
)
|
|
425
535
|
|
|
426
536
|
|
|
537
|
+
def check_command_guards(
|
|
538
|
+
cmd_api: CommandAPI | SignedCommandAPI,
|
|
539
|
+
method_name: str,
|
|
540
|
+
) -> None:
|
|
541
|
+
"""Enforce tier + VCSEC signing guards before executing a write command.
|
|
542
|
+
|
|
543
|
+
Raises :class:`TierError`, :class:`ConfigError`, or
|
|
544
|
+
:class:`KeyNotEnrolledError` on violation.
|
|
545
|
+
|
|
546
|
+
Called by both CLI :func:`execute_command` and the OpenClaw dispatcher.
|
|
547
|
+
"""
|
|
548
|
+
settings = AppSettings()
|
|
549
|
+
if settings.setup_tier == "readonly":
|
|
550
|
+
raise TierError("This command requires 'full' tier setup. Run 'tescmd setup' to upgrade.")
|
|
551
|
+
_check_signing_requirement(cmd_api, method_name, settings)
|
|
552
|
+
|
|
553
|
+
|
|
427
554
|
async def execute_command(
|
|
428
555
|
app_ctx: AppContext,
|
|
429
556
|
vin_positional: str | None,
|
|
@@ -438,17 +565,12 @@ async def execute_command(
|
|
|
438
565
|
Resolves the VIN, obtains a :class:`CommandAPI`, calls *method_name* with
|
|
439
566
|
``auto_wake``, invalidates the cache, and outputs the result.
|
|
440
567
|
"""
|
|
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
568
|
formatter = app_ctx.formatter
|
|
447
569
|
vin = require_vin(vin_positional, app_ctx.vin)
|
|
448
570
|
client, vehicle_api, cmd_api = get_command_api(app_ctx)
|
|
449
571
|
|
|
450
|
-
# Guard: VCSEC
|
|
451
|
-
|
|
572
|
+
# Guard: tier + VCSEC signing — fail fast before waking the vehicle
|
|
573
|
+
check_command_guards(cmd_api, method_name)
|
|
452
574
|
|
|
453
575
|
try:
|
|
454
576
|
method = getattr(cmd_api, method_name)
|
|
@@ -513,7 +635,7 @@ async def auto_wake(
|
|
|
513
635
|
|
|
514
636
|
# Vehicle is asleep — decide whether to wake.
|
|
515
637
|
if not auto:
|
|
516
|
-
if formatter.format not in ("json",):
|
|
638
|
+
if formatter.format not in ("json",) and sys.stdin.isatty():
|
|
517
639
|
# Interactive TTY prompt with retry loop
|
|
518
640
|
while True:
|
|
519
641
|
formatter.rich.info("")
|
|
@@ -552,7 +674,7 @@ async def auto_wake(
|
|
|
552
674
|
)
|
|
553
675
|
|
|
554
676
|
# Proceed with wake.
|
|
555
|
-
if formatter.format not in ("json",):
|
|
677
|
+
if formatter.format not in ("json",) and sys.stdin.isatty():
|
|
556
678
|
with formatter.console.status("", spinner="dots") as status:
|
|
557
679
|
await _wake_and_wait(vehicle_api, vin, timeout, status=status)
|
|
558
680
|
formatter.rich.info("[green]Vehicle is awake.[/green]")
|
tescmd/cli/_options.py
CHANGED
|
@@ -101,10 +101,8 @@ def global_options(f: Any) -> Any:
|
|
|
101
101
|
app_ctx.region = local_region
|
|
102
102
|
if local_verbose:
|
|
103
103
|
app_ctx.verbose = True
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
format="%(name)s %(levelname)s: %(message)s",
|
|
107
|
-
)
|
|
104
|
+
# Set root logger to DEBUG — basicConfig is called once in main.py
|
|
105
|
+
logging.getLogger().setLevel(logging.DEBUG)
|
|
108
106
|
if local_no_cache:
|
|
109
107
|
app_ctx.no_cache = True
|
|
110
108
|
if local_wake:
|