Files
ems/backend/services/tesla_client.py
Dusan Vojacek 4095f0f912
All checks were successful
CI and deploy / migration-check (push) Successful in 19s
CI and deploy / deploy (push) Successful in 56s
EV spotřební forecast: týdenní rytmus vozidla → target SoC a deadline session
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>
2026-06-12 09:06:10 +02:00

154 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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())