Tesla Fleet API: čtení SoC po příjezdu k wallboxu
All checks were successful
CI and deploy / deploy (push) Successful in 58s
CI and deploy / migration-check (push) Successful in 16s

- services/tesla_client.py: access token s cache + ROTACE refresh tokenu do
  ems.tesla_token (env jen seed — Tesla refresh token je jednorázový),
  vehicles → vehicle_data?endpoints=charge_state, 408 (spící auto) = tiché
  přeskočení, výběr vozidla dle VIN / jediného na účtu (VIN se auto-naučí)
- hook _patch_session_from_tesla v _on_ev_arrival: PŘED replanem doplní
  soc_at_connect_pct (+ target z charge_limit_soc) do otevřené session přes
  fn_ev_session_apply_patch (rozšířena o soc_at_connect_pct) — energii si
  odvodí fn_planning_site_context (SQL-first); selhání neblokuje replan
- V086: asset_vehicle.vin, api_type='tesla' pro tesla-my (Model Y, home-01),
  singleton ems.tesla_token; R__095: fn_tesla_token_get/upsert,
  fn_tesla_arrival_context, fn_vehicle_set_vin
- config: TESLA_CLIENT_ID/SECRET/REFRESH_TOKEN (prázdné = vypnuto)
- testy parserů; plná sada beze změny

Aktivace: env do /opt/ems-deploy/.env + recreate backendu (docs/tesla-fleet-api.md §Stav).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dusan Vojacek
2026-06-11 23:29:24 +02:00
parent 21b3d12955
commit 60176fc7b2
8 changed files with 382 additions and 6 deletions

View File

@@ -45,6 +45,11 @@ class Settings(BaseSettings):
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
planning_cheap_price_threshold: float = Field(default=0.85)
planning_expensive_price_threshold: float = Field(default=1.15)
# Tesla Fleet API (docs/tesla-fleet-api.md); prázdné = integrace vypnutá
tesla_client_id: str = Field(default="")
tesla_client_secret: str = Field(default="")
tesla_refresh_token: str = Field(default="")
planning_engine_version: str = Field(default="v1")
planning_engine_compare_enabled: bool = Field(default=False)

View File

@@ -32,6 +32,14 @@ async def _on_ev_arrival(site_id: int, charger_code: str) -> None:
from services.planning_engine import run_rolling_replan
async with _BG_POOL.acquire() as conn:
# Tesla: skutečné SoC do session PŘED replanem (selhání nesmí blokovat plán)
try:
await _patch_session_from_tesla(site_id, charger_code, conn)
except Exception:
logger.exception(
"Tesla SoC fetch failed (site=%s, %s) — replan jede na defaultech",
site_id, charger_code,
)
await run_rolling_replan(
site_id, conn, triggered_by=f"ev_arrival:{charger_code}"
)
@@ -46,6 +54,61 @@ async def _on_ev_arrival(site_id: int, charger_code: str) -> None:
"EV arrival replan failed (site=%s, charger=%s)", site_id, charger_code
)
async def _patch_session_from_tesla(
site_id: int, charger_code: str, conn: asyncpg.Connection
) -> None:
"""Po příjezdu: pro vozidlo s api_type='tesla' doplnit reálné SoC do session.
Energie se NEpočítá tady — soc_at_connect_pct + target_soc_pct si přebere
fn_planning_site_context (SQL-first). VIN se při prvním úspěchu naučí.
"""
from app.db_json import fetch_json
from services.tesla_client import get_charge_state
ctx = await fetch_json(
conn,
"select ems.fn_tesla_arrival_context($1::int, $2::text)",
site_id,
charger_code,
)
if not isinstance(ctx, dict) or ctx.get("api_type") != "tesla":
return
session_id = ctx.get("session_id")
if session_id is None:
logger.warning("Tesla hook: chybí otevřená session (%s)", charger_code)
return
state = await get_charge_state(conn, ctx.get("vin"))
if state is None:
return
if not ctx.get("vin") and state.get("vin"):
await conn.execute(
"select ems.fn_vehicle_set_vin($1::int, $2::text)",
int(ctx["vehicle_id"]),
str(state["vin"]),
)
patch: dict = {"soc_at_connect_pct": state["battery_level"]}
if state.get("charge_limit_soc"):
patch["target_soc_pct"] = state["charge_limit_soc"]
import json as _json
await fetch_json(
conn,
"select ems.fn_ev_session_apply_patch($1::int, $2::int, $3::jsonb)",
site_id,
int(session_id),
_json.dumps(patch),
)
logger.info(
"Tesla SoC -> session %s: level=%s%%, limit=%s%% (%s)",
session_id,
state["battery_level"],
state.get("charge_limit_soc"),
charger_code,
)
# Deye SUN holding registry (decimal adresa = přímo pro read_holding_registers)
DEYE_REG_RUN_STATE = 500
DEYE_REG_BATT_CHARGE_TODAY = 514

View File

@@ -0,0 +1,144 @@
"""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())

View File

@@ -0,0 +1,37 @@
"""Tesla Fleet API čisté parsery (bez sítě/DB)."""
from __future__ import annotations
import unittest
from services.tesla_client import parse_charge_state
class ParseChargeStateTests(unittest.TestCase):
def test_full_response(self) -> None:
data = {
"response": {
"vin": "5YJYGDEE0MF000000",
"charge_state": {
"battery_level": 47,
"charge_limit_soc": 80,
"charging_state": "Stopped",
},
}
}
out = parse_charge_state(data)
self.assertEqual(out["battery_level"], 47)
self.assertEqual(out["charge_limit_soc"], 80)
self.assertEqual(out["vin"], "5YJYGDEE0MF000000")
def test_missing_level_returns_none(self) -> None:
self.assertIsNone(parse_charge_state({"response": {"charge_state": {}}}))
self.assertIsNone(parse_charge_state({}))
def test_zero_limit_normalized_to_none(self) -> None:
data = {"response": {"charge_state": {"battery_level": 10, "charge_limit_soc": 0}}}
self.assertIsNone(parse_charge_state(data)["charge_limit_soc"])
if __name__ == "__main__":
unittest.main()