Tesla: graceful 400 na token refresh + one-shot reauth skript
Some checks failed
CI and deploy / migration-check (push) Failing after 16m36s
CI and deploy / deploy (push) Has been skipped

400 invalid_grant = spálený token rotací NEBO ~10min výpadek po revokaci
souhlasu (Tesla) — místo tracebacku log s návodem a return None (EMS jede
dál na defaultech). deploy/tesla/reauth.sh: authorize URL → výměna → DB →
ověření v jednom kroku (žádná příležitost pro rotační past).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-12 13:14:04 +02:00
parent 5a10da57e9
commit ce1ca8eecb
2 changed files with 84 additions and 2 deletions

View File

@@ -47,7 +47,11 @@ def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None:
if level is None:
return None
odo_miles = vs.get("odometer")
ds = resp.get("drive_state") or {}
return {
"latitude": ds.get("latitude"),
"longitude": ds.get("longitude"),
"shift_state": ds.get("shift_state"),
"vin": resp.get("vin"),
"battery_level": int(level),
"charge_limit_soc": int(cs.get("charge_limit_soc") or 0) or None,
@@ -91,7 +95,18 @@ async def _get_access_token(db: asyncpg.Connection) -> Optional[str]:
"refresh_token": refresh,
},
)
r.raise_for_status()
if r.status_code >= 400:
# 400 invalid_grant = token spálený rotací NEBO ~10min výpadek po
# revokaci souhlasu (Tesla docs). Neshazovat volajícího tracebackem.
body = r.text[:300]
logger.error(
"Tesla token refresh selhal (HTTP %s): %s — pokud jsi právě "
"revokoval souhlas, počkej ~10 min; jinak obnov token dle "
"docs/tesla-fleet-api.md (deploy/tesla/reauth.sh)",
r.status_code,
body,
)
return None
data = r.json()
new_access = str(data["access_token"])
@@ -144,7 +159,7 @@ async def get_charge_state(
r = await client.get(
f"{API_BASE}/api/1/vehicles/{chosen['id']}/vehicle_data",
params={"endpoints": "charge_state;vehicle_state"},
params={"endpoints": "charge_state;vehicle_state;location_data"},
)
if r.status_code == 408:
logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno")

67
deploy/tesla/reauth.sh Executable file
View File

@@ -0,0 +1,67 @@
#!/usr/bin/env bash
# Jednorázová obnova Tesla refresh tokenu (rotační past: žádné mezikroky).
# Použití NA SERVERU:
# 1) otevři authorize URL (vypíše ji tento skript bez argumentů)
# 2) ./reauth.sh <CODE> — výměna → zápis do DB → ověřovací test
# Env: CID, CSEC (client id/secret). Redirect URI: https://ems.vojacek.eu/t-auth
set -euo pipefail
REDIRECT="https://ems.vojacek.eu/t-auth"
AUTH="https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3"
: "${CID:?export CID='<client_id>'}"
: "${CSEC:?export CSEC='<client_secret>'}"
if [[ $# -lt 1 ]]; then
echo "Otevři v prohlížeči (consent musí ukázat 'Vehicle location'):"
echo "$AUTH/authorize?response_type=code&client_id=$CID&redirect_uri=$REDIRECT&scope=openid%20offline_access%20vehicle_device_data%20vehicle_location&state=ems"
echo
echo "Pak: $0 <CODE> (do minuty!)"
exit 0
fi
CODE="$1"
RESP=$(curl -s "$AUTH/token" \
-d grant_type=authorization_code \
--data-urlencode "client_id=$CID" \
--data-urlencode "client_secret=$CSEC" \
--data-urlencode "redirect_uri=$REDIRECT" \
--data-urlencode "code=$CODE")
RT=$(echo "$RESP" | python3 -c "import json,sys; print(json.load(sys.stdin).get('refresh_token',''))" 2>/dev/null || true)
if [[ -z "$RT" ]]; then
echo "VÝMĚNA SELHALA:"; echo "$RESP"; exit 1
fi
echo "refresh token OK (${#RT} znaků) → DB"
docker exec -i ems-deploy-db-1 psql -U ems_user -d ems -c \
"update ems.tesla_token set refresh_token='$RT', access_token=null, access_expires_at=null, updated_at=now() where id=1;"
echo "=== ověření ==="
docker exec -i ems-deploy-backend-1 python - <<'PY'
import asyncio, asyncpg, os, httpx
from services.tesla_client import _get_access_token, API_BASE
async def m():
c = await asyncpg.connect(host=os.environ["DB_HOST"], port=int(os.environ["DB_PORT"]),
user=os.environ["DB_USER"], password=os.environ["DB_PASSWORD"], database="ems")
try:
tok = await _get_access_token(c)
if not tok:
print("REFRESH SELHAL (viz log backendu — po revokaci počkej ~10 min)"); return
h = {"Authorization": f"Bearer {tok}"}
async with httpx.AsyncClient(timeout=15, headers=h) as cl:
r = await cl.get(f"{API_BASE}/api/1/vehicles")
v = (r.json().get("response") or [None])[0]
print("vozidlo:", v and v.get("state"))
if v and v.get("state") == "online":
r2 = await cl.get(f"{API_BASE}/api/1/vehicles/{v['id']}/vehicle_data",
params={"endpoints": "location_data"})
ds = (r2.json().get("response") or {}).get("drive_state") or {}
print("location scope:", "OK" if ds.get("latitude") is not None
else f"CHYBI (HTTP {r2.status_code})")
else:
print("auto spí — refresh OK, location doověřit po jízdě")
finally:
await c.close()
asyncio.run(m())
PY