tescmd 0.1.2__py3-none-any.whl → 0.3.1__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 (90) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +49 -5
  3. tescmd/api/command.py +1 -1
  4. tescmd/api/errors.py +13 -0
  5. tescmd/api/signed_command.py +19 -14
  6. tescmd/api/vehicle.py +19 -1
  7. tescmd/auth/oauth.py +5 -1
  8. tescmd/auth/server.py +6 -1
  9. tescmd/auth/token_store.py +8 -1
  10. tescmd/cache/response_cache.py +11 -3
  11. tescmd/cli/_client.py +142 -20
  12. tescmd/cli/_options.py +2 -4
  13. tescmd/cli/auth.py +121 -11
  14. tescmd/cli/energy.py +2 -0
  15. tescmd/cli/key.py +149 -14
  16. tescmd/cli/main.py +70 -7
  17. tescmd/cli/mcp_cmd.py +153 -0
  18. tescmd/cli/nav.py +3 -1
  19. tescmd/cli/openclaw.py +169 -0
  20. tescmd/cli/security.py +7 -1
  21. tescmd/cli/serve.py +923 -0
  22. tescmd/cli/setup.py +244 -25
  23. tescmd/cli/sharing.py +2 -0
  24. tescmd/cli/status.py +1 -1
  25. tescmd/cli/trunk.py +8 -17
  26. tescmd/cli/user.py +16 -1
  27. tescmd/cli/vehicle.py +156 -20
  28. tescmd/crypto/__init__.py +3 -1
  29. tescmd/crypto/ecdh.py +9 -0
  30. tescmd/crypto/schnorr.py +191 -0
  31. tescmd/deploy/github_pages.py +8 -0
  32. tescmd/deploy/tailscale_serve.py +154 -0
  33. tescmd/mcp/__init__.py +7 -0
  34. tescmd/mcp/server.py +648 -0
  35. tescmd/models/__init__.py +0 -2
  36. tescmd/models/auth.py +24 -2
  37. tescmd/models/config.py +1 -0
  38. tescmd/models/energy.py +0 -9
  39. tescmd/openclaw/__init__.py +23 -0
  40. tescmd/openclaw/bridge.py +330 -0
  41. tescmd/openclaw/config.py +167 -0
  42. tescmd/openclaw/dispatcher.py +522 -0
  43. tescmd/openclaw/emitter.py +175 -0
  44. tescmd/openclaw/filters.py +123 -0
  45. tescmd/openclaw/gateway.py +687 -0
  46. tescmd/openclaw/telemetry_store.py +53 -0
  47. tescmd/output/rich_output.py +46 -14
  48. tescmd/protocol/commands.py +2 -2
  49. tescmd/protocol/encoder.py +16 -13
  50. tescmd/protocol/payloads.py +132 -11
  51. tescmd/protocol/session.py +18 -8
  52. tescmd/protocol/signer.py +3 -17
  53. tescmd/telemetry/__init__.py +28 -0
  54. tescmd/telemetry/cache_sink.py +154 -0
  55. tescmd/telemetry/csv_sink.py +180 -0
  56. tescmd/telemetry/dashboard.py +227 -0
  57. tescmd/telemetry/decoder.py +284 -0
  58. tescmd/telemetry/fanout.py +49 -0
  59. tescmd/telemetry/fields.py +427 -0
  60. tescmd/telemetry/flatbuf.py +162 -0
  61. tescmd/telemetry/mapper.py +239 -0
  62. tescmd/telemetry/protos/__init__.py +4 -0
  63. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  64. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  65. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  66. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  67. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  68. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  69. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  70. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  71. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  72. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  73. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  74. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  75. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  76. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  77. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  78. tescmd/telemetry/server.py +300 -0
  79. tescmd/telemetry/setup.py +468 -0
  80. tescmd/telemetry/tailscale.py +300 -0
  81. tescmd/telemetry/tui.py +1716 -0
  82. tescmd/triggers/__init__.py +18 -0
  83. tescmd/triggers/manager.py +264 -0
  84. tescmd/triggers/models.py +93 -0
  85. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/METADATA +125 -40
  86. tescmd-0.3.1.dist-info/RECORD +128 -0
  87. tescmd-0.1.2.dist-info/RECORD +0 -81
  88. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/WHEEL +0 -0
  89. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/entry_points.txt +0 -0
  90. {tescmd-0.1.2.dist-info → tescmd-0.3.1.dist-info}/licenses/LICENSE +0 -0
tescmd/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """tescmd — A Python CLI for querying and controlling Tesla vehicles via the Fleet API."""
2
2
 
3
- __version__ = "0.1.2"
3
+ __version__ = "0.3.1"
tescmd/api/client.py CHANGED
@@ -3,6 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
7
+ import os as _os
6
8
  from typing import TYPE_CHECKING, Any
7
9
 
8
10
  import httpx
@@ -17,6 +19,7 @@ from tescmd.api.errors import (
17
19
  RateLimitError,
18
20
  RegistrationRequiredError,
19
21
  TeslaAPIError,
22
+ VCPRequiredError,
20
23
  VehicleAsleepError,
21
24
  )
22
25
 
@@ -24,6 +27,10 @@ from tescmd.api.errors import (
24
27
  # Region -> base URL mapping
25
28
  # ---------------------------------------------------------------------------
26
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
+
27
34
  REGION_BASE_URLS: dict[str, str] = {
28
35
  "na": "https://fleet-api.prd.na.vn.cloud.tesla.com",
29
36
  "eu": "https://fleet-api.prd.eu.vn.cloud.tesla.com",
@@ -55,7 +62,7 @@ class TeslaFleetClient:
55
62
  on_token_refresh: Callable[[], Awaitable[str | None]] | None = None,
56
63
  on_rate_limit_wait: Callable[[int, int, int], Awaitable[None]] | None = None,
57
64
  ) -> None:
58
- base_url = REGION_BASE_URLS.get(region)
65
+ base_url = _API_BASE_OVERRIDE or REGION_BASE_URLS.get(region)
59
66
  if base_url is None:
60
67
  msg = f"Unknown region {region!r}; expected one of {sorted(REGION_BASE_URLS)}"
61
68
  raise ValueError(msg)
@@ -159,11 +166,30 @@ class TeslaFleetClient:
159
166
  """Translate HTTP status codes to domain exceptions and return JSON."""
160
167
  if response.status_code == 429:
161
168
  raw = response.headers.get("retry-after")
162
- retry_after: int | None = int(raw) if raw is not None else 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
163
175
  raise RateLimitError(retry_after=retry_after)
164
176
 
165
177
  if response.status_code == 408:
166
- raise VehicleAsleepError("Vehicle is asleep", status_code=408)
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)
167
193
 
168
194
  if response.status_code == 412:
169
195
  raise RegistrationRequiredError(
@@ -174,8 +200,16 @@ class TeslaFleetClient:
174
200
 
175
201
  if response.status_code == 403:
176
202
  text = response.text[:200]
177
- if "missing scopes" in text.lower():
203
+ text_lower = text.lower()
204
+ if "missing scopes" in text_lower:
178
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
+ )
179
213
  raise AuthError(f"HTTP 403: {text}", status_code=403)
180
214
 
181
215
  if response.status_code >= 400:
@@ -185,5 +219,15 @@ class TeslaFleetClient:
185
219
  status_code=response.status_code,
186
220
  )
187
221
 
188
- result: dict[str, Any] = response.json()
222
+ # 204 No Content — return empty dict (no body to parse)
223
+ if response.status_code == 204:
224
+ return {}
225
+
226
+ try:
227
+ result: dict[str, Any] = response.json()
228
+ except json.JSONDecodeError as exc:
229
+ raise TeslaAPIError(
230
+ f"Invalid JSON response: {response.text[:200]}",
231
+ status_code=response.status_code,
232
+ ) from exc
189
233
  return result
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,5 +72,18 @@ 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."""
82
+
83
+
84
+ class TunnelError(ConfigError):
85
+ """Tunnel provider not available or setup failed."""
86
+
87
+
88
+ class TailscaleError(TunnelError):
89
+ """Tailscale not installed, not running, or Funnel setup failed."""
@@ -103,7 +103,7 @@ class SignedCommandAPI:
103
103
 
104
104
  # Metadata + HMAC
105
105
  counter = session.next_counter()
106
- expires_at = default_expiry(clock_offset=session.clock_offset)
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
- "(clock_offset=%d), payload=%s, metadata=%s, hmac_tag=%s",
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.clock_offset,
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
- ) -> CommandResponse | None:
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
- a ``CommandResponse`` on success.
205
+ ``(CommandResponse, None)`` on success.
203
206
 
204
- Returns ``None`` if the fault indicates a stale session — the caller
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(f"Vehicle rejected command '{command}' for {vin}: {desc}")
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/api/vehicle.py CHANGED
@@ -132,11 +132,29 @@ class VehicleAPI:
132
132
  return result
133
133
 
134
134
  async def fleet_telemetry_config_create(self, *, config: dict[str, Any]) -> dict[str, Any]:
135
- """Create or update fleet telemetry server configuration."""
135
+ """Create or update fleet telemetry server configuration.
136
+
137
+ .. deprecated::
138
+ Tesla now requires the Vehicle Command HTTP Proxy for this
139
+ endpoint. Use :meth:`fleet_telemetry_config_create_jws` instead.
140
+ """
136
141
  data = await self._client.post("/api/1/vehicles/fleet_telemetry_config", json=config)
137
142
  result: dict[str, Any] = data.get("response", {})
138
143
  return result
139
144
 
145
+ async def fleet_telemetry_config_create_jws(
146
+ self, *, vins: list[str], token: str
147
+ ) -> dict[str, Any]:
148
+ """Create fleet telemetry config via the JWS-signed endpoint.
149
+
150
+ *token* is a compact JWS signed with the Tesla.SS256 algorithm
151
+ (Schnorr/P-256). See :func:`tescmd.crypto.schnorr.sign_fleet_telemetry_config`.
152
+ """
153
+ payload = {"vins": vins, "token": token}
154
+ data = await self._client.post("/api/1/vehicles/fleet_telemetry_config_jws", json=payload)
155
+ result: dict[str, Any] = data.get("response", {})
156
+ return result
157
+
140
158
  async def fleet_telemetry_config_delete(self, vin: str) -> dict[str, Any]:
141
159
  """Remove fleet telemetry configuration from a vehicle."""
142
160
  data = await self._client.delete(f"/api/1/vehicles/{vin}/fleet_telemetry_config")
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 = "http://localhost:8085/callback",
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,
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 = 8085) -> None:
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)
@@ -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
- result: dict[str, Any] = json.loads(raw)
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 ------------------------------------------------------------
@@ -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:
@@ -196,12 +199,16 @@ class ResponseCache:
196
199
 
197
200
  def _write_entry(self, path: Path, data: dict[str, Any], ttl: int) -> None:
198
201
  self._cache_dir.mkdir(parents=True, exist_ok=True)
202
+ now = time.time()
199
203
  entry = {
200
204
  "data": data,
201
- "created_at": time.time(),
202
- "expires_at": time.time() + ttl,
205
+ "created_at": now,
206
+ "expires_at": now + ttl,
203
207
  }
204
- path.write_text(json.dumps(entry), encoding="utf-8")
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)
205
212
 
206
213
  @staticmethod
207
214
  def _read_json(path: Path) -> dict[str, Any] | None:
@@ -210,4 +217,5 @@ class ResponseCache:
210
217
  try:
211
218
  return json.loads(path.read_text(encoding="utf-8")) # type: ignore[no-any-return]
212
219
  except (json.JSONDecodeError, OSError):
220
+ logger.warning("Corrupt or unreadable cache file: %s", path)
213
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
- token_data = await refresh_access_token(
65
- refresh_token=refresh_token,
66
- client_id=client_id,
67
- client_secret=settings.client_secret,
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 → emit cache metadata, return cached dict.
286
- 3. On miss → call *fetch()*, serialise, store, return result.
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
- The caller's ``formatter.output()`` handles both Pydantic models (miss)
289
- and plain dicts (hit).
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 commands require signed channel don't silently fall back
451
- _check_signing_requirement(cmd_api, method_name, settings)
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
- logging.basicConfig(
105
- level=logging.DEBUG,
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: