tescmd 0.1.2__py3-none-any.whl → 0.2.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.
Files changed (47) hide show
  1. tescmd/__init__.py +1 -1
  2. tescmd/api/client.py +8 -1
  3. tescmd/api/errors.py +8 -0
  4. tescmd/api/vehicle.py +19 -1
  5. tescmd/cache/response_cache.py +3 -2
  6. tescmd/cli/auth.py +30 -2
  7. tescmd/cli/key.py +149 -14
  8. tescmd/cli/main.py +44 -0
  9. tescmd/cli/setup.py +230 -22
  10. tescmd/cli/vehicle.py +464 -1
  11. tescmd/crypto/__init__.py +3 -1
  12. tescmd/crypto/ecdh.py +9 -0
  13. tescmd/crypto/schnorr.py +191 -0
  14. tescmd/deploy/tailscale_serve.py +154 -0
  15. tescmd/models/__init__.py +0 -2
  16. tescmd/models/auth.py +19 -0
  17. tescmd/models/config.py +1 -0
  18. tescmd/models/energy.py +0 -9
  19. tescmd/protocol/session.py +10 -3
  20. tescmd/telemetry/__init__.py +19 -0
  21. tescmd/telemetry/dashboard.py +227 -0
  22. tescmd/telemetry/decoder.py +284 -0
  23. tescmd/telemetry/fields.py +248 -0
  24. tescmd/telemetry/flatbuf.py +162 -0
  25. tescmd/telemetry/protos/__init__.py +4 -0
  26. tescmd/telemetry/protos/vehicle_alert.proto +31 -0
  27. tescmd/telemetry/protos/vehicle_alert_pb2.py +42 -0
  28. tescmd/telemetry/protos/vehicle_alert_pb2.pyi +44 -0
  29. tescmd/telemetry/protos/vehicle_connectivity.proto +23 -0
  30. tescmd/telemetry/protos/vehicle_connectivity_pb2.py +40 -0
  31. tescmd/telemetry/protos/vehicle_connectivity_pb2.pyi +33 -0
  32. tescmd/telemetry/protos/vehicle_data.proto +768 -0
  33. tescmd/telemetry/protos/vehicle_data_pb2.py +136 -0
  34. tescmd/telemetry/protos/vehicle_data_pb2.pyi +1336 -0
  35. tescmd/telemetry/protos/vehicle_error.proto +23 -0
  36. tescmd/telemetry/protos/vehicle_error_pb2.py +44 -0
  37. tescmd/telemetry/protos/vehicle_error_pb2.pyi +39 -0
  38. tescmd/telemetry/protos/vehicle_metric.proto +22 -0
  39. tescmd/telemetry/protos/vehicle_metric_pb2.py +44 -0
  40. tescmd/telemetry/protos/vehicle_metric_pb2.pyi +37 -0
  41. tescmd/telemetry/server.py +293 -0
  42. tescmd/telemetry/tailscale.py +300 -0
  43. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/METADATA +72 -35
  44. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/RECORD +47 -22
  45. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/WHEEL +0 -0
  46. {tescmd-0.1.2.dist-info → tescmd-0.2.0.dist-info}/entry_points.txt +0 -0
  47. {tescmd-0.1.2.dist-info → tescmd-0.2.0.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.2.0"
tescmd/api/client.py CHANGED
@@ -3,6 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import json
6
7
  from typing import TYPE_CHECKING, Any
7
8
 
8
9
  import httpx
@@ -185,5 +186,11 @@ class TeslaFleetClient:
185
186
  status_code=response.status_code,
186
187
  )
187
188
 
188
- result: dict[str, Any] = response.json()
189
+ try:
190
+ result: dict[str, Any] = response.json()
191
+ except json.JSONDecodeError as exc:
192
+ raise TeslaAPIError(
193
+ f"Invalid JSON response: {response.text[:200]}",
194
+ status_code=response.status_code,
195
+ ) from exc
189
196
  return result
tescmd/api/errors.py CHANGED
@@ -74,3 +74,11 @@ class MissingScopesError(AuthError):
74
74
 
75
75
  class KeyNotEnrolledError(TeslaAPIError):
76
76
  """Vehicle rejected the command — key not enrolled."""
77
+
78
+
79
+ class TunnelError(ConfigError):
80
+ """Tunnel provider not available or setup failed."""
81
+
82
+
83
+ class TailscaleError(TunnelError):
84
+ """Tailscale not installed, not running, or Funnel setup failed."""
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")
@@ -196,10 +196,11 @@ class ResponseCache:
196
196
 
197
197
  def _write_entry(self, path: Path, data: dict[str, Any], ttl: int) -> None:
198
198
  self._cache_dir.mkdir(parents=True, exist_ok=True)
199
+ now = time.time()
199
200
  entry = {
200
201
  "data": data,
201
- "created_at": time.time(),
202
- "expires_at": time.time() + ttl,
202
+ "created_at": now,
203
+ "expires_at": now + ttl,
203
204
  }
204
205
  path.write_text(json.dumps(entry), encoding="utf-8")
205
206
 
tescmd/cli/auth.py CHANGED
@@ -20,7 +20,13 @@ from tescmd.auth.oauth import (
20
20
  )
21
21
  from tescmd.auth.token_store import TokenStore
22
22
  from tescmd.cli._options import global_options
23
- from tescmd.models.auth import DEFAULT_SCOPES, PARTNER_SCOPES, TokenData, decode_jwt_scopes
23
+ from tescmd.models.auth import (
24
+ DEFAULT_SCOPES,
25
+ PARTNER_SCOPES,
26
+ TokenData,
27
+ decode_jwt_payload,
28
+ decode_jwt_scopes,
29
+ )
24
30
  from tescmd.models.config import AppSettings
25
31
 
26
32
  if TYPE_CHECKING:
@@ -182,11 +188,23 @@ async def _cmd_status(app_ctx: AppContext) -> None:
182
188
  region: str = meta.get("region", "unknown")
183
189
  has_refresh = store.refresh_token is not None
184
190
 
185
- # Decode the JWT to show the *actual* granted scopes
191
+ # Decode the JWT to show the *actual* granted scopes and audience
186
192
  token_scopes: list[str] | None = None
193
+ audience: str | None = None
187
194
  access_token = store.access_token
188
195
  if access_token:
189
196
  token_scopes = decode_jwt_scopes(access_token)
197
+ jwt_payload = decode_jwt_payload(access_token)
198
+ if jwt_payload is not None:
199
+ aud_raw = jwt_payload.get("aud")
200
+ if isinstance(aud_raw, list) and aud_raw:
201
+ audience = aud_raw[0] if len(aud_raw) == 1 else ", ".join(str(a) for a in aud_raw)
202
+ elif isinstance(aud_raw, str):
203
+ audience = aud_raw
204
+ # Also check for 'ou' (origin URL) — Tesla-specific claim
205
+ ou = jwt_payload.get("ou")
206
+ if isinstance(ou, str) and ou:
207
+ audience = ou
190
208
 
191
209
  if formatter.format == "json":
192
210
  data: dict[str, object] = {
@@ -198,6 +216,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
198
216
  }
199
217
  if token_scopes is not None:
200
218
  data["token_scopes"] = token_scopes
219
+ if audience is not None:
220
+ data["audience"] = audience
201
221
  formatter.output(data, command="auth.status")
202
222
  else:
203
223
  formatter.rich.info("Authenticated: yes")
@@ -211,6 +231,8 @@ async def _cmd_status(app_ctx: AppContext) -> None:
211
231
  formatter.rich.info(
212
232
  f" [yellow]Warning: requested but not granted: {not_granted}[/yellow]"
213
233
  )
234
+ if audience is not None:
235
+ formatter.rich.info(f"Audience: {audience}")
214
236
  formatter.rich.info(f"Region: {region}")
215
237
  formatter.rich.info(f"Refresh token: {'yes' if has_refresh else 'no'}")
216
238
 
@@ -484,6 +506,12 @@ def _interactive_setup(
484
506
  info(f" Allowed Origin URL: [cyan]{origin_url}[/cyan]")
485
507
  info(f" Allowed Redirect URI: [cyan]{redirect_uri}[/cyan]")
486
508
  info(" Allowed Returned URL: (leave empty)")
509
+ info("")
510
+ info(
511
+ " [dim]For telemetry streaming, add your Tailscale hostname"
512
+ " as an additional origin:[/dim]"
513
+ )
514
+ info(" [dim] https://<machine>.tailnet.ts.net[/dim]")
487
515
  info(" Click Next.")
488
516
  info("")
489
517
 
tescmd/cli/key.py CHANGED
@@ -25,6 +25,7 @@ from tescmd.models.config import AppSettings
25
25
 
26
26
  if TYPE_CHECKING:
27
27
  from tescmd.cli.main import AppContext
28
+ from tescmd.output.formatter import OutputFormatter
28
29
 
29
30
 
30
31
  # ---------------------------------------------------------------------------
@@ -95,22 +96,19 @@ async def _cmd_generate(app_ctx: AppContext, force: bool) -> None:
95
96
  default=None,
96
97
  help="GitHub repo (e.g. user/user.github.io). Auto-detected if omitted.",
97
98
  )
99
+ @click.option(
100
+ "--method",
101
+ type=click.Choice(["auto", "github", "tailscale"]),
102
+ default="auto",
103
+ help="Deployment method (default: auto-detect).",
104
+ )
98
105
  @global_options
99
- def deploy_cmd(app_ctx: AppContext, repo: str | None) -> None:
100
- """Deploy the public key to GitHub Pages."""
101
- run_async(_cmd_deploy(app_ctx, repo))
102
-
106
+ def deploy_cmd(app_ctx: AppContext, repo: str | None, method: str) -> None:
107
+ """Deploy the public key to GitHub Pages or via Tailscale Funnel."""
108
+ run_async(_cmd_deploy(app_ctx, repo, method=method))
103
109
 
104
- async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
105
- from tescmd.deploy.github_pages import (
106
- create_pages_repo,
107
- deploy_public_key,
108
- get_gh_username,
109
- get_pages_domain,
110
- is_gh_authenticated,
111
- is_gh_available,
112
- )
113
110
 
111
+ async def _cmd_deploy(app_ctx: AppContext, repo: str | None, *, method: str = "auto") -> None:
114
112
  formatter = app_ctx.formatter
115
113
  settings = AppSettings()
116
114
  key_dir = Path(settings.config_dir).expanduser() / "keys"
@@ -127,6 +125,142 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
127
125
  formatter.rich.error("No key pair found. Run [cyan]tescmd key generate[/cyan] first.")
128
126
  return
129
127
 
128
+ # Resolve method
129
+ resolved = _resolve_deploy_method(method, settings)
130
+
131
+ if resolved == "tailscale":
132
+ await _cmd_deploy_tailscale(formatter, key_dir)
133
+ elif resolved == "github":
134
+ await _cmd_deploy_github(formatter, settings, key_dir, repo)
135
+ else:
136
+ if formatter.format == "json":
137
+ formatter.output_error(
138
+ code="no_deploy_method",
139
+ message=(
140
+ "No deployment method available. Install 'gh' CLI for GitHub Pages"
141
+ " or Tailscale for Funnel hosting."
142
+ ),
143
+ command="key.deploy",
144
+ )
145
+ else:
146
+ formatter.rich.error(
147
+ "No deployment method available. Install [cyan]gh[/cyan] CLI"
148
+ " (GitHub Pages) or [cyan]tailscale[/cyan] (Funnel hosting)."
149
+ )
150
+
151
+
152
+ def _resolve_deploy_method(method: str, settings: AppSettings) -> str | None:
153
+ """Resolve 'auto' to a concrete method, or validate explicit choice."""
154
+ if method in ("tailscale", "github"):
155
+ return method
156
+
157
+ # Auto-detect: GitHub Pages (always-on) > Tailscale (stable hostname)
158
+ from tescmd.deploy.github_pages import is_gh_authenticated, is_gh_available
159
+
160
+ if is_gh_available() and is_gh_authenticated():
161
+ return "github"
162
+
163
+ from tescmd.deploy.tailscale_serve import is_tailscale_serve_ready
164
+
165
+ if run_async(is_tailscale_serve_ready()):
166
+ return "tailscale"
167
+
168
+ # Check saved hosting method as last resort
169
+ if settings.hosting_method == "tailscale":
170
+ return "tailscale"
171
+ if settings.hosting_method == "github":
172
+ return "github"
173
+
174
+ return None
175
+
176
+
177
+ async def _cmd_deploy_tailscale(
178
+ formatter: OutputFormatter,
179
+ key_dir: Path,
180
+ ) -> None:
181
+ """Deploy public key via Tailscale Funnel."""
182
+ from tescmd.deploy.tailscale_serve import (
183
+ deploy_public_key_tailscale,
184
+ get_key_url,
185
+ is_tailscale_serve_ready,
186
+ start_key_serving,
187
+ wait_for_tailscale_deployment,
188
+ )
189
+
190
+ # Verify Tailscale is ready
191
+ if not await is_tailscale_serve_ready():
192
+ if formatter.format == "json":
193
+ formatter.output_error(
194
+ code="tailscale_not_ready",
195
+ message=(
196
+ "Tailscale is not available or Funnel is not enabled."
197
+ " Ensure Tailscale is running and Funnel is enabled in your tailnet ACL."
198
+ ),
199
+ command="key.deploy",
200
+ )
201
+ else:
202
+ formatter.rich.error(
203
+ "Tailscale is not available or Funnel is not enabled.\n"
204
+ " Ensure Tailscale is running and Funnel is enabled in your tailnet ACL."
205
+ )
206
+ return
207
+
208
+ if formatter.format != "json":
209
+ formatter.rich.info("Deploying public key via Tailscale Funnel...")
210
+
211
+ pem = load_public_key_pem(key_dir)
212
+ await deploy_public_key_tailscale(pem)
213
+ hostname = await start_key_serving()
214
+
215
+ url = get_key_url(hostname)
216
+ if formatter.format != "json":
217
+ formatter.rich.info("[green]Tailscale serve + Funnel started.[/green]")
218
+ formatter.rich.info(f" URL: {url}")
219
+ formatter.rich.info("")
220
+ formatter.rich.info("Waiting for key to become accessible...")
221
+
222
+ deployed = await wait_for_tailscale_deployment(hostname)
223
+
224
+ if formatter.format == "json":
225
+ formatter.output(
226
+ {
227
+ "status": "deployed" if deployed else "pending",
228
+ "method": "tailscale",
229
+ "hostname": hostname,
230
+ "url": url,
231
+ "accessible": deployed,
232
+ },
233
+ command="key.deploy",
234
+ )
235
+ elif deployed:
236
+ formatter.rich.info("[green]Key is live and accessible.[/green]")
237
+ formatter.rich.info("")
238
+ formatter.rich.info(
239
+ "[yellow]Note:[/yellow] The key is served by the Tailscale daemon."
240
+ " If your machine is off or Tailscale stops,"
241
+ " Tesla cannot reach your public key."
242
+ )
243
+ else:
244
+ formatter.rich.info("[yellow]Key deployed but not yet accessible.[/yellow]")
245
+ formatter.rich.info(" Run [cyan]tescmd key validate[/cyan] to check again later.")
246
+
247
+
248
+ async def _cmd_deploy_github(
249
+ formatter: OutputFormatter,
250
+ settings: AppSettings,
251
+ key_dir: Path,
252
+ repo: str | None,
253
+ ) -> None:
254
+ """Deploy public key via GitHub Pages."""
255
+ from tescmd.deploy.github_pages import (
256
+ create_pages_repo,
257
+ deploy_public_key,
258
+ get_gh_username,
259
+ get_pages_domain,
260
+ is_gh_authenticated,
261
+ is_gh_available,
262
+ )
263
+
130
264
  # Check gh CLI
131
265
  if not is_gh_available():
132
266
  if formatter.format == "json":
@@ -180,7 +314,7 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
180
314
  formatter.rich.info("[green]Key deployed.[/green]")
181
315
  formatter.rich.info(f" URL: {get_key_url(domain)}")
182
316
  formatter.rich.info("")
183
- formatter.rich.info("Waiting for GitHub Pages to publish (this may take a few minutes)...")
317
+ formatter.rich.info("Waiting for GitHub Pages to publish...")
184
318
 
185
319
  deployed = wait_for_pages_deployment(domain)
186
320
 
@@ -188,6 +322,7 @@ async def _cmd_deploy(app_ctx: AppContext, repo: str | None) -> None:
188
322
  formatter.output(
189
323
  {
190
324
  "status": "deployed" if deployed else "pending",
325
+ "method": "github",
191
326
  "repo": repo_name,
192
327
  "domain": domain,
193
328
  "url": get_key_url(domain),
tescmd/cli/main.py CHANGED
@@ -15,6 +15,7 @@ from tescmd.api.errors import (
15
15
  RegistrationRequiredError,
16
16
  SessionError,
17
17
  TierError,
18
+ TunnelError,
18
19
  VehicleAsleepError,
19
20
  )
20
21
  from tescmd.output.formatter import OutputFormatter
@@ -287,6 +288,9 @@ def _handle_known_error(
287
288
  if isinstance(exc, SessionError):
288
289
  _handle_session_error(exc, formatter, cmd_name)
289
290
  return True
291
+ if isinstance(exc, TunnelError):
292
+ _handle_tunnel_error(exc, formatter, cmd_name)
293
+ return True
290
294
 
291
295
  # Keyring failures (e.g. headless Linux with no keyring daemon)
292
296
  try:
@@ -599,3 +603,43 @@ def _handle_keyring_error(
599
603
  formatter.rich.info(
600
604
  "[dim]Tokens will be stored in plaintext with restricted file permissions.[/dim]"
601
605
  )
606
+
607
+
608
+ def _handle_tunnel_error(
609
+ exc: TunnelError,
610
+ formatter: OutputFormatter,
611
+ cmd_name: str,
612
+ ) -> None:
613
+ """Show a friendly error for tunnel-related failures.
614
+
615
+ Only shows install guidance when no provider is found on PATH.
616
+ TailscaleError means Tailscale IS installed but hit an operational
617
+ error — install guidance would be confusing.
618
+ """
619
+ message = str(exc) or "No tunnel provider available for telemetry streaming."
620
+
621
+ if formatter.format == "json":
622
+ formatter.output_error(
623
+ code="tunnel_error",
624
+ message=message,
625
+ command=cmd_name,
626
+ )
627
+ return
628
+
629
+ formatter.rich.error(message)
630
+
631
+ # Only show install guidance when no provider was found at all.
632
+ if "No tunnel provider" not in message:
633
+ return
634
+
635
+ formatter.rich.info("")
636
+ formatter.rich.info("Telemetry streaming requires Tailscale Funnel:")
637
+ formatter.rich.info("")
638
+ formatter.rich.info(" 1. Install Tailscale: [cyan]https://tailscale.com/download[/cyan]")
639
+ formatter.rich.info(" 2. Authenticate: [cyan]tailscale up[/cyan]")
640
+ formatter.rich.info(
641
+ " 3. Enable Funnel in your tailnet ACL:"
642
+ " [cyan]https://login.tailscale.com/admin/acls[/cyan]"
643
+ )
644
+ formatter.rich.info("")
645
+ formatter.rich.info("Install telemetry deps: [cyan]pip install tescmd[telemetry][/cyan]")