Myšlenka uživatele: pondělní služebka ~150 km (~35 kWh) chce skoro plnou, konec týdne stačí míň, víkend = levné sloty na přípravu pondělka. - V089: ev_vehicle_obs (odometer+SoC při příjezdu/ODJEZDU — auto v obou okamžicích vzhůru, žádné buzení navíc), ev_trip (km z odometru, kWh z ΔSoC; nabíjení cestou → charged_away flag), ev_usage_stats per (vozidlo, DOW); asset_vehicle: target_soc_forecast_enabled (default false), min_target_soc_pct - R__096: fn_ev_build_trips (párování), fn_update_ev_usage_stats (job 00:50), fn_ev_next_departure (příští typický odjezd, >=4 vzorky, >=3 km), fn_ev_required_soc (P80 spotřeby dne + 10 p.b., clamp [min_target, 100]) - R__016: session při příjezdu bere forecast target+deadline (za per-vozidlo flagem, fallback defaulty, ruční patch vždy vyhrává) → víkendová session s pondělním deadline = v2 solver přirozeně nabije v levných slotech - tesla_client: + vehicle_state endpoint (odometer v MÍLÍCH → km), collector: departure hook, lifespan: job 00:50 Aktivace po nasbírání dat: update asset_vehicle set target_soc_forecast_enabled=true. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
154 lines
5.3 KiB
Python
154 lines
5.3 KiB
Python
"""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
|
||
|
||
|
||
MILES_TO_KM = 1.609344
|
||
|
||
|
||
def parse_charge_state(vehicle_data: dict[str, Any]) -> dict[str, Any] | None:
|
||
"""Parser vehicle_data → battery_level, charge_limit_soc, charging_state, odometer_km.
|
||
|
||
POZOR: Tesla API vrací odometer v MÍLÍCH → převod na km.
|
||
"""
|
||
resp = vehicle_data.get("response") or {}
|
||
cs = resp.get("charge_state") or {}
|
||
vs = resp.get("vehicle_state") or {}
|
||
level = cs.get("battery_level")
|
||
if level is None:
|
||
return None
|
||
odo_miles = vs.get("odometer")
|
||
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"),
|
||
"odometer_km": round(float(odo_miles) * MILES_TO_KM, 1) if odo_miles is not None else None,
|
||
}
|
||
|
||
|
||
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;vehicle_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())
|