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.
Files changed (81) hide show
  1. tescmd/__init__.py +3 -0
  2. tescmd/__main__.py +5 -0
  3. tescmd/_internal/__init__.py +0 -0
  4. tescmd/_internal/async_utils.py +25 -0
  5. tescmd/_internal/permissions.py +43 -0
  6. tescmd/_internal/vin.py +44 -0
  7. tescmd/api/__init__.py +1 -0
  8. tescmd/api/charging.py +102 -0
  9. tescmd/api/client.py +189 -0
  10. tescmd/api/command.py +540 -0
  11. tescmd/api/energy.py +146 -0
  12. tescmd/api/errors.py +76 -0
  13. tescmd/api/partner.py +40 -0
  14. tescmd/api/sharing.py +65 -0
  15. tescmd/api/signed_command.py +277 -0
  16. tescmd/api/user.py +38 -0
  17. tescmd/api/vehicle.py +150 -0
  18. tescmd/auth/__init__.py +1 -0
  19. tescmd/auth/oauth.py +312 -0
  20. tescmd/auth/server.py +108 -0
  21. tescmd/auth/token_store.py +273 -0
  22. tescmd/ble/__init__.py +0 -0
  23. tescmd/cache/__init__.py +6 -0
  24. tescmd/cache/keys.py +51 -0
  25. tescmd/cache/response_cache.py +213 -0
  26. tescmd/cli/__init__.py +0 -0
  27. tescmd/cli/_client.py +603 -0
  28. tescmd/cli/_options.py +126 -0
  29. tescmd/cli/auth.py +682 -0
  30. tescmd/cli/billing.py +240 -0
  31. tescmd/cli/cache.py +85 -0
  32. tescmd/cli/charge.py +610 -0
  33. tescmd/cli/climate.py +501 -0
  34. tescmd/cli/energy.py +385 -0
  35. tescmd/cli/key.py +611 -0
  36. tescmd/cli/main.py +601 -0
  37. tescmd/cli/media.py +146 -0
  38. tescmd/cli/nav.py +242 -0
  39. tescmd/cli/partner.py +112 -0
  40. tescmd/cli/raw.py +75 -0
  41. tescmd/cli/security.py +495 -0
  42. tescmd/cli/setup.py +786 -0
  43. tescmd/cli/sharing.py +188 -0
  44. tescmd/cli/software.py +81 -0
  45. tescmd/cli/status.py +106 -0
  46. tescmd/cli/trunk.py +240 -0
  47. tescmd/cli/user.py +145 -0
  48. tescmd/cli/vehicle.py +837 -0
  49. tescmd/config/__init__.py +0 -0
  50. tescmd/crypto/__init__.py +19 -0
  51. tescmd/crypto/ecdh.py +46 -0
  52. tescmd/crypto/keys.py +122 -0
  53. tescmd/deploy/__init__.py +0 -0
  54. tescmd/deploy/github_pages.py +268 -0
  55. tescmd/models/__init__.py +85 -0
  56. tescmd/models/auth.py +108 -0
  57. tescmd/models/command.py +18 -0
  58. tescmd/models/config.py +63 -0
  59. tescmd/models/energy.py +56 -0
  60. tescmd/models/sharing.py +26 -0
  61. tescmd/models/user.py +37 -0
  62. tescmd/models/vehicle.py +185 -0
  63. tescmd/output/__init__.py +5 -0
  64. tescmd/output/formatter.py +132 -0
  65. tescmd/output/json_output.py +83 -0
  66. tescmd/output/rich_output.py +809 -0
  67. tescmd/protocol/__init__.py +23 -0
  68. tescmd/protocol/commands.py +175 -0
  69. tescmd/protocol/encoder.py +122 -0
  70. tescmd/protocol/metadata.py +116 -0
  71. tescmd/protocol/payloads.py +621 -0
  72. tescmd/protocol/protobuf/__init__.py +6 -0
  73. tescmd/protocol/protobuf/messages.py +564 -0
  74. tescmd/protocol/session.py +318 -0
  75. tescmd/protocol/signer.py +84 -0
  76. tescmd/py.typed +0 -0
  77. tescmd-0.1.2.dist-info/METADATA +458 -0
  78. tescmd-0.1.2.dist-info/RECORD +81 -0
  79. tescmd-0.1.2.dist-info/WHEEL +4 -0
  80. tescmd-0.1.2.dist-info/entry_points.txt +2 -0
  81. tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
File without changes
@@ -0,0 +1,19 @@
1
+ """EC key management for Tesla Fleet API command signing."""
2
+
3
+ from tescmd.crypto.keys import (
4
+ generate_ec_key_pair,
5
+ get_key_fingerprint,
6
+ get_public_key_path,
7
+ has_key_pair,
8
+ load_private_key,
9
+ load_public_key_pem,
10
+ )
11
+
12
+ __all__ = [
13
+ "generate_ec_key_pair",
14
+ "get_key_fingerprint",
15
+ "get_public_key_path",
16
+ "has_key_pair",
17
+ "load_private_key",
18
+ "load_public_key_pem",
19
+ ]
tescmd/crypto/ecdh.py ADDED
@@ -0,0 +1,46 @@
1
+ """ECDH key exchange for Tesla Vehicle Command Protocol sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+
7
+ from cryptography.hazmat.primitives.asymmetric import ec
8
+
9
+
10
+ def derive_session_key(
11
+ private_key: ec.EllipticCurvePrivateKey,
12
+ vehicle_public_key_bytes: bytes,
13
+ ) -> bytes:
14
+ """Derive a 16-byte session key via ECDH + SHA-1 truncation.
15
+
16
+ Parameters
17
+ ----------
18
+ private_key:
19
+ The client's EC P-256 private key.
20
+ vehicle_public_key_bytes:
21
+ The vehicle's public key as an uncompressed 65-byte point
22
+ (``0x04 || X || Y``) received in the SessionInfo response.
23
+
24
+ Returns
25
+ -------
26
+ bytes
27
+ A 16-byte session key: ``SHA1(shared_secret)[:16]``.
28
+ """
29
+ vehicle_pub = ec.EllipticCurvePublicKey.from_encoded_point(
30
+ ec.SECP256R1(), vehicle_public_key_bytes
31
+ )
32
+ shared_secret = private_key.exchange(ec.ECDH(), vehicle_pub)
33
+ return hashlib.sha1(shared_secret).digest()[:16]
34
+
35
+
36
+ def get_uncompressed_public_key(private_key: ec.EllipticCurvePrivateKey) -> bytes:
37
+ """Return the uncompressed 65-byte public key point (0x04 || X || Y)."""
38
+ from cryptography.hazmat.primitives.serialization import (
39
+ Encoding,
40
+ PublicFormat,
41
+ )
42
+
43
+ return private_key.public_key().public_bytes(
44
+ encoding=Encoding.X962,
45
+ format=PublicFormat.UncompressedPoint,
46
+ )
tescmd/crypto/keys.py ADDED
@@ -0,0 +1,122 @@
1
+ """EC P-256 key generation and loading for Tesla Fleet API command signing."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from pathlib import Path
7
+
8
+ from cryptography.hazmat.primitives import serialization
9
+ from cryptography.hazmat.primitives.asymmetric import ec
10
+
11
+ DEFAULT_KEY_DIR = "~/.config/tescmd/keys"
12
+ PRIVATE_KEY_FILE = "private_key.pem"
13
+ PUBLIC_KEY_FILE = "public_key.pem"
14
+
15
+
16
+ def generate_ec_key_pair(
17
+ key_dir: Path | str | None = None,
18
+ *,
19
+ overwrite: bool = False,
20
+ ) -> tuple[Path, Path]:
21
+ """Generate an EC P-256 key pair and write PEM files.
22
+
23
+ Returns the (private_key_path, public_key_path) tuple.
24
+ """
25
+ resolved = _resolve_key_dir(key_dir)
26
+ resolved.mkdir(parents=True, exist_ok=True)
27
+
28
+ priv_path = resolved / PRIVATE_KEY_FILE
29
+ pub_path = resolved / PUBLIC_KEY_FILE
30
+
31
+ if priv_path.exists() and not overwrite:
32
+ raise FileExistsError(
33
+ f"Key pair already exists at {resolved}. Use overwrite=True to replace."
34
+ )
35
+
36
+ private_key = ec.generate_private_key(ec.SECP256R1())
37
+
38
+ # Write private key — PEM, no encryption
39
+ priv_pem = private_key.private_bytes(
40
+ encoding=serialization.Encoding.PEM,
41
+ format=serialization.PrivateFormat.PKCS8,
42
+ encryption_algorithm=serialization.NoEncryption(),
43
+ )
44
+ priv_path.write_bytes(priv_pem)
45
+
46
+ from tescmd._internal.permissions import secure_file
47
+
48
+ secure_file(priv_path)
49
+
50
+ # Write public key — PEM
51
+ pub_pem = private_key.public_key().public_bytes(
52
+ encoding=serialization.Encoding.PEM,
53
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
54
+ )
55
+ pub_path.write_bytes(pub_pem)
56
+
57
+ return (priv_path, pub_path)
58
+
59
+
60
+ def load_private_key(key_dir: Path | str | None = None) -> ec.EllipticCurvePrivateKey:
61
+ """Load the private key from disk."""
62
+ resolved = _resolve_key_dir(key_dir)
63
+ priv_path = resolved / PRIVATE_KEY_FILE
64
+
65
+ if not priv_path.exists():
66
+ raise FileNotFoundError(
67
+ f"Private key not found at {priv_path}. Run 'tescmd key generate' first."
68
+ )
69
+
70
+ key = serialization.load_pem_private_key(priv_path.read_bytes(), password=None)
71
+ if not isinstance(key, ec.EllipticCurvePrivateKey):
72
+ raise TypeError(f"Expected EC private key, got {type(key).__name__}")
73
+ return key
74
+
75
+
76
+ def load_public_key_pem(key_dir: Path | str | None = None) -> str:
77
+ """Load the public key PEM as a string (for deployment)."""
78
+ resolved = _resolve_key_dir(key_dir)
79
+ pub_path = resolved / PUBLIC_KEY_FILE
80
+
81
+ if not pub_path.exists():
82
+ raise FileNotFoundError(
83
+ f"Public key not found at {pub_path}. Run 'tescmd key generate' first."
84
+ )
85
+
86
+ return pub_path.read_text()
87
+
88
+
89
+ def get_public_key_path(key_dir: Path | str | None = None) -> Path:
90
+ """Return the resolved path to the public key file."""
91
+ return _resolve_key_dir(key_dir) / PUBLIC_KEY_FILE
92
+
93
+
94
+ def has_key_pair(key_dir: Path | str | None = None) -> bool:
95
+ """Return True if both private and public key files exist."""
96
+ resolved = _resolve_key_dir(key_dir)
97
+ return (resolved / PRIVATE_KEY_FILE).exists() and (resolved / PUBLIC_KEY_FILE).exists()
98
+
99
+
100
+ def get_key_fingerprint(key_dir: Path | str | None = None) -> str:
101
+ """Return the SHA-256 hex fingerprint of the public key (DER-encoded)."""
102
+ resolved = _resolve_key_dir(key_dir)
103
+ pub_path = resolved / PUBLIC_KEY_FILE
104
+
105
+ if not pub_path.exists():
106
+ raise FileNotFoundError(
107
+ f"Public key not found at {pub_path}. Run 'tescmd key generate' first."
108
+ )
109
+
110
+ key = serialization.load_pem_public_key(pub_path.read_bytes())
111
+ der = key.public_bytes(
112
+ encoding=serialization.Encoding.DER,
113
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
114
+ )
115
+ return hashlib.sha256(der).hexdigest()
116
+
117
+
118
+ def _resolve_key_dir(key_dir: Path | str | None) -> Path:
119
+ """Resolve the key directory, expanding ~ and defaulting if None."""
120
+ if key_dir is None:
121
+ return Path(DEFAULT_KEY_DIR).expanduser()
122
+ return Path(key_dir).expanduser()
File without changes
@@ -0,0 +1,268 @@
1
+ """GitHub Pages deployment helpers for Tesla Fleet API public keys.
2
+
3
+ All Git/GitHub operations go through ``_run_gh`` and ``_run_git`` helpers
4
+ so that tests can mock subprocess calls cleanly.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import shutil
11
+ import subprocess
12
+ import tempfile
13
+ import time
14
+ from pathlib import Path
15
+
16
+ import httpx
17
+
18
+ WELL_KNOWN_PATH = ".well-known/appspecific/com.tesla.3p.public-key.pem"
19
+
20
+ # Timeout and polling for GitHub Pages deployment
21
+ DEFAULT_DEPLOY_TIMEOUT = 180 # seconds
22
+ POLL_INTERVAL = 5 # seconds
23
+
24
+
25
+ # ---------------------------------------------------------------------------
26
+ # Subprocess helpers
27
+ # ---------------------------------------------------------------------------
28
+
29
+
30
+ def _run_gh(args: list[str], *, check: bool = True) -> subprocess.CompletedProcess[str]:
31
+ """Run a ``gh`` CLI command and return the result."""
32
+ return subprocess.run(
33
+ ["gh", *args],
34
+ capture_output=True,
35
+ text=True,
36
+ check=check,
37
+ )
38
+
39
+
40
+ def _run_git(
41
+ args: list[str],
42
+ *,
43
+ cwd: Path | str | None = None,
44
+ check: bool = True,
45
+ ) -> subprocess.CompletedProcess[str]:
46
+ """Run a ``git`` command and return the result."""
47
+ return subprocess.run(
48
+ ["git", *args],
49
+ capture_output=True,
50
+ text=True,
51
+ cwd=cwd,
52
+ check=check,
53
+ )
54
+
55
+
56
+ # ---------------------------------------------------------------------------
57
+ # GitHub CLI queries
58
+ # ---------------------------------------------------------------------------
59
+
60
+
61
+ def is_gh_available() -> bool:
62
+ """Return True if the ``gh`` CLI is installed on PATH."""
63
+ return shutil.which("gh") is not None
64
+
65
+
66
+ def is_gh_authenticated() -> bool:
67
+ """Return True if ``gh`` is authenticated with GitHub."""
68
+ if not is_gh_available():
69
+ return False
70
+ result = _run_gh(["auth", "status"], check=False)
71
+ return result.returncode == 0
72
+
73
+
74
+ def get_gh_username() -> str:
75
+ """Return the authenticated GitHub username.
76
+
77
+ Raises ``RuntimeError`` if ``gh`` is not authenticated.
78
+ """
79
+ result = _run_gh(["api", "user", "--jq", ".login"])
80
+ username = result.stdout.strip()
81
+ if not username:
82
+ raise RuntimeError("Could not determine GitHub username from 'gh api user'.")
83
+ return username
84
+
85
+
86
+ def repo_exists(repo_name: str) -> bool:
87
+ """Return True if the given ``owner/repo`` exists on GitHub."""
88
+ result = _run_gh(["repo", "view", repo_name], check=False)
89
+ return result.returncode == 0
90
+
91
+
92
+ # ---------------------------------------------------------------------------
93
+ # Repository creation and key deployment
94
+ # ---------------------------------------------------------------------------
95
+
96
+
97
+ def create_pages_repo(username: str) -> str:
98
+ """Create ``<username>.github.io`` if it doesn't exist.
99
+
100
+ Returns the full repo name (``username/username.github.io``).
101
+ """
102
+ repo_name = f"{username}/{username}.github.io"
103
+
104
+ if repo_exists(repo_name):
105
+ return repo_name
106
+
107
+ _run_gh(
108
+ [
109
+ "repo",
110
+ "create",
111
+ repo_name,
112
+ "--public",
113
+ "--description",
114
+ "GitHub Pages site for Tesla Fleet API key hosting",
115
+ ]
116
+ )
117
+
118
+ return repo_name
119
+
120
+
121
+ def deploy_public_key(public_key_pem: str, repo_name: str) -> None:
122
+ """Clone *repo_name*, add the Tesla public key, and push.
123
+
124
+ Handles:
125
+ - Empty repos (no initial commit)
126
+ - Existing ``_config.yml`` (merges ``include`` directive)
127
+ - Already-deployed key (skips commit if no changes)
128
+ """
129
+ with tempfile.TemporaryDirectory() as tmpdir:
130
+ clone_dir = Path(tmpdir) / "repo"
131
+ _clone_or_init(repo_name, clone_dir)
132
+
133
+ # Ensure .well-known directory structure
134
+ key_path = clone_dir / WELL_KNOWN_PATH
135
+ key_path.parent.mkdir(parents=True, exist_ok=True)
136
+ key_path.write_text(public_key_pem)
137
+
138
+ # Ensure _config.yml includes .well-known
139
+ _ensure_jekyll_config(clone_dir)
140
+
141
+ # Ensure .nojekyll exists
142
+ nojekyll = clone_dir / ".nojekyll"
143
+ if not nojekyll.exists():
144
+ nojekyll.touch()
145
+
146
+ # Create minimal index.html if repo is empty
147
+ index = clone_dir / "index.html"
148
+ if not index.exists():
149
+ index.write_text(
150
+ "<!DOCTYPE html>\n"
151
+ "<html><head><title>Tesla Fleet API</title></head>\n"
152
+ "<body><p>Tesla Fleet API key host.</p></body></html>\n"
153
+ )
154
+
155
+ # Stage all changes
156
+ _run_git(["add", "-A"], cwd=clone_dir)
157
+
158
+ # Check if there are changes to commit
159
+ status = _run_git(["status", "--porcelain"], cwd=clone_dir)
160
+ if not status.stdout.strip():
161
+ return # Nothing to commit — key already deployed
162
+
163
+ _run_git(
164
+ ["commit", "-m", "Deploy Tesla Fleet API public key"],
165
+ cwd=clone_dir,
166
+ )
167
+ _run_git(["push", "origin", "HEAD"], cwd=clone_dir)
168
+
169
+
170
+ def _clone_or_init(repo_name: str, clone_dir: Path) -> None:
171
+ """Clone the repo, handling the empty-repo case."""
172
+ result = _run_gh(
173
+ ["repo", "clone", repo_name, str(clone_dir), "--", "--depth=1"],
174
+ check=False,
175
+ )
176
+
177
+ if result.returncode == 0:
178
+ return
179
+
180
+ # Empty repo: gh clone fails — manually init + add remote
181
+ clone_dir.mkdir(parents=True, exist_ok=True)
182
+ _run_git(["init"], cwd=clone_dir)
183
+ _run_git(
184
+ ["remote", "add", "origin", f"https://github.com/{repo_name}.git"],
185
+ cwd=clone_dir,
186
+ )
187
+ # Set default branch to main
188
+ _run_git(["checkout", "-b", "main"], cwd=clone_dir)
189
+
190
+
191
+ def _ensure_jekyll_config(clone_dir: Path) -> None:
192
+ """Ensure ``_config.yml`` includes ``.well-known`` in its ``include`` list."""
193
+ config_path = clone_dir / "_config.yml"
194
+
195
+ if config_path.exists():
196
+ content = config_path.read_text()
197
+ if ".well-known" in content:
198
+ return # Already configured
199
+ # Append include directive
200
+ content = content.rstrip("\n") + "\n\ninclude:\n - .well-known\n"
201
+ config_path.write_text(content)
202
+ else:
203
+ config_path.write_text('include:\n - ".well-known"\n')
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # Deployment validation
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ def get_key_url(domain: str) -> str:
212
+ """Return the full URL where the public key should be served."""
213
+ return f"https://{domain}/{WELL_KNOWN_PATH}"
214
+
215
+
216
+ def validate_key_url(domain: str) -> bool:
217
+ """Return True if the public key is accessible at the expected URL."""
218
+ url = get_key_url(domain)
219
+ try:
220
+ resp = httpx.get(url, follow_redirects=True, timeout=10)
221
+ return resp.status_code == 200 and "BEGIN PUBLIC KEY" in resp.text
222
+ except httpx.HTTPError:
223
+ return False
224
+
225
+
226
+ def wait_for_pages_deployment(
227
+ domain: str,
228
+ *,
229
+ timeout: int = DEFAULT_DEPLOY_TIMEOUT,
230
+ ) -> bool:
231
+ """Poll the key URL until it responds successfully or *timeout* elapses.
232
+
233
+ Returns True if the key became accessible, False on timeout.
234
+ """
235
+ deadline = time.monotonic() + timeout
236
+
237
+ while time.monotonic() < deadline:
238
+ if validate_key_url(domain):
239
+ return True
240
+ time.sleep(POLL_INTERVAL)
241
+
242
+ return False
243
+
244
+
245
+ def get_pages_domain(repo_name: str) -> str:
246
+ """Infer the GitHub Pages domain from a repo name.
247
+
248
+ For ``user/user.github.io`` → ``user.github.io``.
249
+ For ``user/other-repo`` → ``user.github.io/other-repo`` (project page).
250
+
251
+ The result is always lowercased because the Tesla Fleet API rejects
252
+ domains with uppercase characters.
253
+ """
254
+ parts = repo_name.split("/", 1)
255
+ if len(parts) != 2:
256
+ raise ValueError(f"Invalid repo name: {repo_name!r}")
257
+
258
+ owner, name = parts
259
+ if name.lower() == f"{owner.lower()}.github.io":
260
+ return name.lower()
261
+ return f"{owner.lower()}.github.io/{name.lower()}"
262
+
263
+
264
+ def get_repo_info(repo_name: str) -> dict[str, str]:
265
+ """Return basic repo metadata from the GitHub API."""
266
+ result = _run_gh(["repo", "view", repo_name, "--json", "name,owner,url"])
267
+ data: dict[str, str] = json.loads(result.stdout)
268
+ return data
@@ -0,0 +1,85 @@
1
+ from __future__ import annotations
2
+
3
+ from tescmd.models.auth import (
4
+ AUTH_BASE_URL,
5
+ AUTHORIZE_URL,
6
+ DEFAULT_PORT,
7
+ DEFAULT_REDIRECT_URI,
8
+ DEFAULT_SCOPES,
9
+ ENERGY_SCOPES,
10
+ PARTNER_SCOPES,
11
+ TOKEN_URL,
12
+ USER_SCOPES,
13
+ VEHICLE_SCOPES,
14
+ AuthConfig,
15
+ TokenData,
16
+ TokenMeta,
17
+ )
18
+ from tescmd.models.command import CommandResponse, CommandResult
19
+ from tescmd.models.config import AppSettings, Profile
20
+ from tescmd.models.energy import (
21
+ CalendarHistory,
22
+ EnergySite,
23
+ GridImportExportConfig,
24
+ LiveStatus,
25
+ SiteInfo,
26
+ )
27
+ from tescmd.models.sharing import ShareDriverInfo, ShareInvite
28
+ from tescmd.models.user import FeatureConfig, UserInfo, UserRegion, VehicleOrder
29
+ from tescmd.models.vehicle import (
30
+ ChargeState,
31
+ ClimateState,
32
+ DestChargerInfo,
33
+ DriveState,
34
+ GuiSettings,
35
+ NearbyChargingSites,
36
+ SoftwareUpdateInfo,
37
+ SuperchargerInfo,
38
+ Vehicle,
39
+ VehicleConfig,
40
+ VehicleData,
41
+ VehicleState,
42
+ )
43
+
44
+ __all__ = [
45
+ "AUTHORIZE_URL",
46
+ "AUTH_BASE_URL",
47
+ "DEFAULT_PORT",
48
+ "DEFAULT_REDIRECT_URI",
49
+ "DEFAULT_SCOPES",
50
+ "ENERGY_SCOPES",
51
+ "PARTNER_SCOPES",
52
+ "TOKEN_URL",
53
+ "USER_SCOPES",
54
+ "VEHICLE_SCOPES",
55
+ "AppSettings",
56
+ "AuthConfig",
57
+ "CalendarHistory",
58
+ "ChargeState",
59
+ "ClimateState",
60
+ "CommandResponse",
61
+ "CommandResult",
62
+ "DestChargerInfo",
63
+ "DriveState",
64
+ "EnergySite",
65
+ "FeatureConfig",
66
+ "GridImportExportConfig",
67
+ "GuiSettings",
68
+ "LiveStatus",
69
+ "NearbyChargingSites",
70
+ "Profile",
71
+ "ShareDriverInfo",
72
+ "ShareInvite",
73
+ "SiteInfo",
74
+ "SoftwareUpdateInfo",
75
+ "SuperchargerInfo",
76
+ "TokenData",
77
+ "TokenMeta",
78
+ "UserInfo",
79
+ "UserRegion",
80
+ "Vehicle",
81
+ "VehicleConfig",
82
+ "VehicleData",
83
+ "VehicleOrder",
84
+ "VehicleState",
85
+ ]
tescmd/models/auth.py ADDED
@@ -0,0 +1,108 @@
1
+ from __future__ import annotations
2
+
3
+ import base64
4
+ import json
5
+ import logging
6
+ from typing import Any
7
+
8
+ from pydantic import BaseModel
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+ # ---------------------------------------------------------------------------
13
+ # Scope constants
14
+ # ---------------------------------------------------------------------------
15
+
16
+ VEHICLE_SCOPES: list[str] = [
17
+ "vehicle_device_data",
18
+ "vehicle_cmds",
19
+ "vehicle_charging_cmds",
20
+ ]
21
+
22
+ ENERGY_SCOPES: list[str] = [
23
+ "energy_device_data",
24
+ "energy_cmds",
25
+ ]
26
+
27
+ USER_SCOPES: list[str] = [
28
+ "user_data",
29
+ ]
30
+
31
+ PARTNER_SCOPES: list[str] = [
32
+ "openid",
33
+ *VEHICLE_SCOPES,
34
+ *ENERGY_SCOPES,
35
+ *USER_SCOPES,
36
+ ]
37
+
38
+ DEFAULT_SCOPES: list[str] = [
39
+ "openid",
40
+ "offline_access",
41
+ *VEHICLE_SCOPES,
42
+ *ENERGY_SCOPES,
43
+ *USER_SCOPES,
44
+ ]
45
+
46
+ DEFAULT_PORT: int = 8085
47
+ DEFAULT_REDIRECT_URI: str = f"http://localhost:{DEFAULT_PORT}/callback"
48
+
49
+ AUTH_BASE_URL: str = "https://auth.tesla.com"
50
+ AUTHORIZE_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/authorize"
51
+ TOKEN_URL: str = f"{AUTH_BASE_URL}/oauth2/v3/token"
52
+
53
+ # ---------------------------------------------------------------------------
54
+ # Models
55
+ # ---------------------------------------------------------------------------
56
+
57
+
58
+ class TokenData(BaseModel, extra="allow"):
59
+ """Raw token response from the Tesla OAuth endpoint."""
60
+
61
+ access_token: str
62
+ token_type: str
63
+ expires_in: int
64
+ refresh_token: str | None = None
65
+ id_token: str | None = None
66
+ scope: str | None = None # space-separated granted scopes (if returned by server)
67
+
68
+
69
+ class TokenMeta(BaseModel):
70
+ """Metadata stored alongside the persisted token."""
71
+
72
+ expires_at: float
73
+ scopes: list[str]
74
+ region: str
75
+
76
+
77
+ def decode_jwt_scopes(token: str) -> list[str] | None:
78
+ """Extract scopes from a JWT access token without verifying the signature.
79
+
80
+ Tesla access tokens are JWTs with an ``scp`` claim containing the
81
+ granted scopes. Returns ``None`` if the token isn't a JWT or the
82
+ ``scp`` claim is absent.
83
+ """
84
+ parts = token.split(".")
85
+ if len(parts) != 3:
86
+ return None
87
+ try:
88
+ # JWT uses base64url encoding; add padding for stdlib decoder
89
+ payload_b64 = parts[1] + "=" * (-len(parts[1]) % 4)
90
+ payload_bytes = base64.urlsafe_b64decode(payload_b64)
91
+ payload: dict[str, Any] = json.loads(payload_bytes)
92
+ except (ValueError, json.JSONDecodeError):
93
+ logger.debug("Failed to decode JWT payload")
94
+ return None
95
+
96
+ scp = payload.get("scp")
97
+ if isinstance(scp, list):
98
+ return [str(s) for s in scp]
99
+ return None
100
+
101
+
102
+ class AuthConfig(BaseModel):
103
+ """Configuration needed to start an OAuth flow."""
104
+
105
+ client_id: str
106
+ client_secret: str | None = None
107
+ redirect_uri: str = DEFAULT_REDIRECT_URI
108
+ scopes: list[str] = DEFAULT_SCOPES
@@ -0,0 +1,18 @@
1
+ from __future__ import annotations
2
+
3
+ from pydantic import BaseModel, ConfigDict
4
+
5
+
6
+ class CommandResult(BaseModel):
7
+ """The inner result payload of a vehicle command."""
8
+
9
+ result: bool
10
+ reason: str = ""
11
+
12
+
13
+ class CommandResponse(BaseModel):
14
+ """Envelope returned by the Fleet API for command endpoints."""
15
+
16
+ model_config = ConfigDict(extra="allow")
17
+
18
+ response: CommandResult