tescmd 0.1.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- tescmd/__init__.py +3 -0
- tescmd/__main__.py +5 -0
- tescmd/_internal/__init__.py +0 -0
- tescmd/_internal/async_utils.py +25 -0
- tescmd/_internal/permissions.py +43 -0
- tescmd/_internal/vin.py +44 -0
- tescmd/api/__init__.py +1 -0
- tescmd/api/charging.py +102 -0
- tescmd/api/client.py +189 -0
- tescmd/api/command.py +540 -0
- tescmd/api/energy.py +146 -0
- tescmd/api/errors.py +76 -0
- tescmd/api/partner.py +40 -0
- tescmd/api/sharing.py +65 -0
- tescmd/api/signed_command.py +277 -0
- tescmd/api/user.py +38 -0
- tescmd/api/vehicle.py +150 -0
- tescmd/auth/__init__.py +1 -0
- tescmd/auth/oauth.py +312 -0
- tescmd/auth/server.py +108 -0
- tescmd/auth/token_store.py +273 -0
- tescmd/ble/__init__.py +0 -0
- tescmd/cache/__init__.py +6 -0
- tescmd/cache/keys.py +51 -0
- tescmd/cache/response_cache.py +213 -0
- tescmd/cli/__init__.py +0 -0
- tescmd/cli/_client.py +603 -0
- tescmd/cli/_options.py +126 -0
- tescmd/cli/auth.py +682 -0
- tescmd/cli/billing.py +240 -0
- tescmd/cli/cache.py +85 -0
- tescmd/cli/charge.py +610 -0
- tescmd/cli/climate.py +501 -0
- tescmd/cli/energy.py +385 -0
- tescmd/cli/key.py +611 -0
- tescmd/cli/main.py +601 -0
- tescmd/cli/media.py +146 -0
- tescmd/cli/nav.py +242 -0
- tescmd/cli/partner.py +112 -0
- tescmd/cli/raw.py +75 -0
- tescmd/cli/security.py +495 -0
- tescmd/cli/setup.py +786 -0
- tescmd/cli/sharing.py +188 -0
- tescmd/cli/software.py +81 -0
- tescmd/cli/status.py +106 -0
- tescmd/cli/trunk.py +240 -0
- tescmd/cli/user.py +145 -0
- tescmd/cli/vehicle.py +837 -0
- tescmd/config/__init__.py +0 -0
- tescmd/crypto/__init__.py +19 -0
- tescmd/crypto/ecdh.py +46 -0
- tescmd/crypto/keys.py +122 -0
- tescmd/deploy/__init__.py +0 -0
- tescmd/deploy/github_pages.py +268 -0
- tescmd/models/__init__.py +85 -0
- tescmd/models/auth.py +108 -0
- tescmd/models/command.py +18 -0
- tescmd/models/config.py +63 -0
- tescmd/models/energy.py +56 -0
- tescmd/models/sharing.py +26 -0
- tescmd/models/user.py +37 -0
- tescmd/models/vehicle.py +185 -0
- tescmd/output/__init__.py +5 -0
- tescmd/output/formatter.py +132 -0
- tescmd/output/json_output.py +83 -0
- tescmd/output/rich_output.py +809 -0
- tescmd/protocol/__init__.py +23 -0
- tescmd/protocol/commands.py +175 -0
- tescmd/protocol/encoder.py +122 -0
- tescmd/protocol/metadata.py +116 -0
- tescmd/protocol/payloads.py +621 -0
- tescmd/protocol/protobuf/__init__.py +6 -0
- tescmd/protocol/protobuf/messages.py +564 -0
- tescmd/protocol/session.py +318 -0
- tescmd/protocol/signer.py +84 -0
- tescmd/py.typed +0 -0
- tescmd-0.1.2.dist-info/METADATA +458 -0
- tescmd-0.1.2.dist-info/RECORD +81 -0
- tescmd-0.1.2.dist-info/WHEEL +4 -0
- tescmd-0.1.2.dist-info/entry_points.txt +2 -0
- tescmd-0.1.2.dist-info/licenses/LICENSE +21 -0
|
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
|
tescmd/models/command.py
ADDED
|
@@ -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
|