|
#!/usr/bin/env python3 |
|
""" |
|
One-time Tesla Fleet API partner registration (for EVCC). |
|
|
|
Fixes the "412 Precondition Failed" in EVCC. That error means your partner |
|
account isn't registered with Tesla yet -- not that your tokens are wrong. |
|
Registration must happen in BOTH regions (eu + na), because EVCC detects the |
|
region by trying the NA endpoint first. |
|
|
|
Usage (run on your laptop, same folder as your .env): |
|
python3 tesla_register.py |
|
|
|
Flow: |
|
1st run -> generates an EC keypair and explains where to host the public key. |
|
It then stops until the file is online. |
|
2nd run -> sees that the key is reachable and registers in eu + na. |
|
|
|
Requires: Python 3 (stdlib) + openssl in PATH (or the cryptography package as a |
|
fallback). No pip installs needed when openssl is available. |
|
""" |
|
|
|
import json |
|
import os |
|
import shutil |
|
import subprocess |
|
import sys |
|
import urllib.error |
|
import urllib.parse |
|
import urllib.request |
|
|
|
|
|
def load_dotenv(path): |
|
"""Minimal .env parser; real env vars take precedence.""" |
|
if not os.path.exists(path): |
|
return |
|
with open(path, encoding="utf-8") as fh: |
|
for raw in fh: |
|
line = raw.strip() |
|
if not line or line.startswith("#") or "=" not in line: |
|
continue |
|
key, _, val = line.partition("=") |
|
os.environ.setdefault(key.strip(), val.strip().strip('"').strip("'")) |
|
|
|
|
|
_here = os.path.dirname(os.path.abspath(__file__)) |
|
load_dotenv(os.environ.get("TESLA_ENV_FILE", os.path.join(_here, ".env"))) |
|
|
|
CLIENT_ID = os.environ.get("CLIENT_ID", "") |
|
CLIENT_SECRET = os.environ.get("CLIENT_SECRET", "") |
|
DOMAIN = os.environ.get("DOMAIN", "").replace("https://", "").strip("/") |
|
|
|
if not CLIENT_ID or not CLIENT_SECRET or not DOMAIN: |
|
sys.exit( |
|
"CLIENT_ID, CLIENT_SECRET and/or DOMAIN are missing.\n" |
|
"Put them in your .env file (see .env.example)." |
|
) |
|
|
|
# Read-only data -> no command scopes. |
|
SCOPE = os.environ.get("PARTNER_SCOPE", "openid vehicle_device_data") |
|
TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" |
|
REGIONS = { |
|
"eu": "https://fleet-api.prd.eu.vn.cloud.tesla.com", |
|
"na": "https://fleet-api.prd.na.vn.cloud.tesla.com", |
|
} |
|
|
|
PRIV_KEY = os.path.join(_here, "tesla-private-key.pem") |
|
PUB_KEY = os.path.join(_here, "com.tesla.3p.public-key.pem") |
|
WELL_KNOWN = f"https://{DOMAIN}/.well-known/appspecific/com.tesla.3p.public-key.pem" |
|
|
|
|
|
def sh(cmd): |
|
subprocess.run(cmd, check=True, capture_output=True) |
|
|
|
|
|
def _keygen_openssl(openssl): |
|
print(f"Generating EC keypair (prime256v1) via openssl ({openssl}) ...") |
|
sh([openssl, "ecparam", "-name", "prime256v1", "-genkey", "-noout", |
|
"-out", PRIV_KEY]) |
|
sh([openssl, "ec", "-in", PRIV_KEY, "-pubout", "-out", PUB_KEY]) |
|
|
|
|
|
def _keygen_cryptography(): |
|
"""Fallback without openssl, via the 'cryptography' package.""" |
|
from cryptography.hazmat.primitives import serialization |
|
from cryptography.hazmat.primitives.asymmetric import ec |
|
print("Generating EC keypair (P-256) via the cryptography package ...") |
|
priv = ec.generate_private_key(ec.SECP256R1()) |
|
with open(PRIV_KEY, "wb") as fh: |
|
fh.write(priv.private_bytes( |
|
serialization.Encoding.PEM, |
|
serialization.PrivateFormat.TraditionalOpenSSL, |
|
serialization.NoEncryption(), |
|
)) |
|
with open(PUB_KEY, "wb") as fh: |
|
fh.write(priv.public_key().public_bytes( |
|
serialization.Encoding.PEM, |
|
serialization.PublicFormat.SubjectPublicKeyInfo, |
|
)) |
|
|
|
|
|
def ensure_keypair(): |
|
"""Generate a NIST P-256 EC keypair if it doesn't exist yet. |
|
Uses openssl if available, otherwise the cryptography package.""" |
|
if os.path.exists(PRIV_KEY) and os.path.exists(PUB_KEY): |
|
print(f"Keypair already exists: {PRIV_KEY}") |
|
return |
|
|
|
openssl = shutil.which("openssl") # also finds openssl.exe on Windows |
|
if openssl: |
|
_keygen_openssl(openssl) |
|
else: |
|
try: |
|
_keygen_cryptography() |
|
except ImportError: |
|
sys.exit( |
|
"No openssl found and the 'cryptography' package is missing.\n" |
|
"Pick one of the two:\n" |
|
" - install openssl (Linux: apt install openssl | " |
|
"macOS: brew install openssl | Windows: via Git for Windows or " |
|
"https://slproweb.com/products/Win32OpenSSL.html), or\n" |
|
" - pip install cryptography\n" |
|
"then run this script again." |
|
) |
|
print(f" private key: {PRIV_KEY} (keep SECRET, do not share)") |
|
print(f" public key: {PUB_KEY}") |
|
|
|
|
|
def _norm(text): |
|
return "".join(text.split()) |
|
|
|
|
|
def public_key_is_live(): |
|
"""True if the hosted public key matches the local one.""" |
|
with open(PUB_KEY, encoding="utf-8") as fh: |
|
local = _norm(fh.read()) |
|
try: |
|
req = urllib.request.Request(WELL_KNOWN, headers={"User-Agent": "curl/8"}) |
|
with urllib.request.urlopen(req, timeout=10) as resp: |
|
remote = _norm(resp.read().decode("utf-8", errors="replace")) |
|
except Exception as e: |
|
print(f" Could not fetch {WELL_KNOWN}: {e}") |
|
return False |
|
if remote != local: |
|
print(" File is reachable, but its content does NOT match your local " |
|
"public key. Host exactly com.tesla.3p.public-key.pem.") |
|
return False |
|
return True |
|
|
|
|
|
def hosting_instructions(): |
|
print("\n>>> Action needed: host your public key on your domain <<<") |
|
print(f"Place the file {os.path.basename(PUB_KEY)} so that this URL works" |
|
" exactly:") |
|
print(f" {WELL_KNOWN}") |
|
print("Requirements: https with a valid certificate, NO redirect, and serve" |
|
" the raw .pem (no HTML around it).") |
|
print("Quick check once it's online:") |
|
print(f" curl -sSL {WELL_KNOWN}") |
|
print("Then run this script again to finish the registration.\n") |
|
|
|
|
|
def partner_token(audience): |
|
data = urllib.parse.urlencode({ |
|
"grant_type": "client_credentials", |
|
"client_id": CLIENT_ID, |
|
"client_secret": CLIENT_SECRET, |
|
"scope": SCOPE, |
|
"audience": audience, |
|
}).encode("utf-8") |
|
req = urllib.request.Request( |
|
TOKEN_URL, data=data, |
|
headers={"Content-Type": "application/x-www-form-urlencoded"}, |
|
) |
|
with urllib.request.urlopen(req, timeout=15) as resp: |
|
return json.load(resp)["access_token"] |
|
|
|
|
|
def register(base, token): |
|
body = json.dumps({"domain": DOMAIN}).encode("utf-8") |
|
req = urllib.request.Request( |
|
f"{base}/api/1/partner_accounts", data=body, |
|
headers={"Authorization": f"Bearer {token}", |
|
"Content-Type": "application/json"}, |
|
) |
|
with urllib.request.urlopen(req, timeout=15) as resp: |
|
return resp.status, resp.read().decode("utf-8", errors="replace") |
|
|
|
|
|
def main(): |
|
print(f"Domain: {DOMAIN}\n") |
|
ensure_keypair() |
|
|
|
print(f"\nChecking whether the public key is reachable at:\n {WELL_KNOWN}") |
|
if not public_key_is_live(): |
|
hosting_instructions() |
|
sys.exit(0) |
|
print(" OK, hosted key matches.\n") |
|
|
|
ok = True |
|
for region, base in REGIONS.items(): |
|
print(f"[{region}] fetching partner token and registering ...") |
|
try: |
|
token = partner_token(base) |
|
status, body = register(base, token) |
|
print(f"[{region}] HTTP {status}") |
|
if status != 200: |
|
ok = False |
|
print(f"[{region}] response: {body}") |
|
except urllib.error.HTTPError as e: |
|
ok = False |
|
print(f"[{region}] ERROR HTTP {e.code}: " |
|
f"{e.read().decode(errors='replace')}") |
|
except Exception as e: |
|
ok = False |
|
print(f"[{region}] ERROR: {e}") |
|
|
|
print() |
|
if ok: |
|
print("Done: registered in both regions. Restart EVCC or re-add the " |
|
"Tesla -- the 412 should now be gone.") |
|
print("Read-only setup: pairing the key with your car is NOT needed " |
|
"(that's only for signed commands).") |
|
else: |
|
print("Not fully successful -- see the error messages above.") |
|
sys.exit(1) |
|
|
|
|
|
if __name__ == "__main__": |
|
main() |