"""Tesla Fleet API – čtení stavu nabití vozidla (SoC) po příjezdu k wallboxu. Zásady: - Volat JEN při příjezdu (vehicle_data budí auto → vampire drain); žádný polling. - Refresh token Tesla při každém použití ROTUJE → runtime hodnota žije v DB (ems.tesla_token, fn_tesla_token_get/upsert); env TESLA_REFRESH_TOKEN je jen prvotní seed. Access token cache ~8 h dle expires_in. - Bez credentials (env prázdné) modul tiše nic nedělá — EV plánování běží na defaultech z asset_vehicle. Postup zřízení: docs/tesla-fleet-api.md. """ from __future__ import annotations import logging from datetime import datetime, timedelta, timezone from typing import Any, Optional import asyncpg import httpx from app.config import get_settings from app.db_json import fetch_json logger = logging.getLogger(__name__) AUTH_TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token" API_BASE = "https://fleet-api.prd.eu.vn.cloud.tesla.com" HTTP_TIMEOUT_S = 15.0 #: rezerva před expirací access tokenu ACCESS_EXPIRY_MARGIN_S = 120 def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None: """Čistý parser odpovědi vehicle_data → {battery_level, charge_limit_soc, charging_state}.""" resp = vehicle_data.get("response") or {} cs = resp.get("charge_state") or {} level = cs.get("battery_level") if level is None: return None return { "vin": resp.get("vin"), "battery_level": int(level), "charge_limit_soc": int(cs.get("charge_limit_soc") or 0) or None, "charging_state": cs.get("charging_state"), } async def _get_access_token(db: asyncpg.Connection) -> Optional[str]: s = get_settings() client_id = (getattr(s, "tesla_client_id", "") or "").strip() if not client_id: return None tok = await fetch_json(db, "select ems.fn_tesla_token_get()") if not isinstance(tok, dict): tok = {} refresh = (tok.get("refresh_token") or "").strip() if not refresh: refresh = (getattr(s, "tesla_refresh_token", "") or "").strip() if not refresh: logger.debug("Tesla: žádný refresh token (env ani DB) — přeskočeno") return None access = tok.get("access_token") exp_raw = tok.get("access_expires_at") if access and exp_raw: try: exp = datetime.fromisoformat(str(exp_raw)) if exp - timedelta(seconds=ACCESS_EXPIRY_MARGIN_S) > datetime.now(timezone.utc): return str(access) except ValueError: pass async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_S) as client: r = await client.post( AUTH_TOKEN_URL, data={ "grant_type": "refresh_token", "client_id": client_id, "refresh_token": refresh, }, ) r.raise_for_status() data = r.json() new_access = str(data["access_token"]) # rotace: Tesla vrací nový refresh token — starý přestává platit, ULOŽIT new_refresh = str(data.get("refresh_token") or refresh) expires_at = datetime.now(timezone.utc) + timedelta(seconds=int(data.get("expires_in") or 3600)) await db.execute( "select ems.fn_tesla_token_upsert($1::text, $2::text, $3::timestamptz)", new_refresh, new_access, expires_at, ) return new_access async def get_charge_state( db: asyncpg.Connection, vin: str | None ) -> dict[str, Any] | None: """SoC vozidla: dle VIN, nebo jediného vozidla na účtu (VIN vrací pro doplnění). Vrací parse_charge_state dict, nebo None (bez credentials / vozidlo nenalezeno / offline). Výjimky síťové vrstvy propadají volajícímu (hook je loguje). """ token = await _get_access_token(db) if token is None: return None headers = {"Authorization": f"Bearer {token}"} async with httpx.AsyncClient(timeout=HTTP_TIMEOUT_S, headers=headers) as client: r = await client.get(f"{API_BASE}/api/1/vehicles") r.raise_for_status() vehicles = (r.json().get("response") or []) if not vehicles: logger.warning("Tesla: účet nemá žádná vozidla") return None chosen = None if vin: chosen = next((v for v in vehicles if v.get("vin") == vin), None) if chosen is None: logger.warning("Tesla: VIN %s na účtu nenalezen", vin) return None elif len(vehicles) == 1: chosen = vehicles[0] else: logger.warning( "Tesla: %s vozidel na účtu a VIN v asset_vehicle chybí — doplň VIN", len(vehicles), ) return None r = await client.get( f"{API_BASE}/api/1/vehicles/{chosen['id']}/vehicle_data", params={"endpoints": "charge_state"}, ) if r.status_code == 408: logger.info("Tesla: vozidlo spí / nedostupné (408) — SoC nedoplněno") return None r.raise_for_status() return parse_charge_state(r.json())