Tesla Fleet API: čtení SoC po příjezdu k wallboxu
- 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:
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
144
backend/services/tesla_client.py
Normal file
144
backend/services/tesla_client.py
Normal 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())
|
||||
37
backend/tests/test_tesla_client.py
Normal file
37
backend/tests/test_tesla_client.py
Normal 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()
|
||||
25
db/migration/V086__vehicle_vin_tesla_token.sql
Normal file
25
db/migration/V086__vehicle_vin_tesla_token.sql
Normal file
@@ -0,0 +1,25 @@
|
||||
-- Tesla Fleet API: VIN na vozidle, aktivace api_type pro Model Y (home-01),
|
||||
-- singleton tabulka tokenů (refresh token Tesla ROTUJE při každém použití —
|
||||
-- nelze ho držet jen v .env, runtime hodnota žije zde; .env je jen seed).
|
||||
|
||||
alter table ems.asset_vehicle
|
||||
add column if not exists vin text;
|
||||
|
||||
comment on column ems.asset_vehicle.vin is
|
||||
'VIN pro párování s vozidlem v API výrobce (Tesla Fleet). NULL = doplní se automaticky při prvním úspěšném čtení (jediné vozidlo na účtu), jinak nutno vyplnit ručně.';
|
||||
|
||||
update ems.asset_vehicle
|
||||
set api_type = 'tesla'
|
||||
where code = 'tesla-my'
|
||||
and site_id = (select id from ems.site where code = 'home-01');
|
||||
|
||||
create table if not exists ems.tesla_token (
|
||||
id int primary key default 1 check (id = 1),
|
||||
refresh_token text not null,
|
||||
access_token text,
|
||||
access_expires_at timestamptz,
|
||||
updated_at timestamptz not null default now()
|
||||
);
|
||||
|
||||
comment on table ems.tesla_token is
|
||||
'Singleton: aktuální Tesla Fleet API tokeny. Seed refresh tokenu z env TESLA_REFRESH_TOKEN při prvním použití; rotace ukládá fn_tesla_token_upsert.';
|
||||
@@ -9,7 +9,9 @@ as $fn$
|
||||
declare
|
||||
v_id int;
|
||||
begin
|
||||
if not (p_patch ? 'target_soc_pct') and not (p_patch ? 'target_deadline') then
|
||||
if not (p_patch ? 'target_soc_pct')
|
||||
and not (p_patch ? 'target_deadline')
|
||||
and not (p_patch ? 'soc_at_connect_pct') then
|
||||
return jsonb_build_object('success', false, 'error', 'no_fields');
|
||||
end if;
|
||||
|
||||
@@ -24,6 +26,16 @@ begin
|
||||
end
|
||||
else es.target_soc_pct
|
||||
end,
|
||||
-- skutečné SoC při připojení (Tesla Fleet API po příjezdu)
|
||||
soc_at_connect_pct = case
|
||||
when p_patch ? 'soc_at_connect_pct' then
|
||||
case
|
||||
when p_patch->'soc_at_connect_pct' is null
|
||||
or jsonb_typeof(p_patch->'soc_at_connect_pct') = 'null' then null
|
||||
else (p_patch->>'soc_at_connect_pct')::double precision
|
||||
end
|
||||
else es.soc_at_connect_pct
|
||||
end,
|
||||
target_deadline = case
|
||||
when p_patch ? 'target_deadline' then
|
||||
case
|
||||
@@ -46,4 +58,4 @@ end;
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_ev_session_apply_patch(int, int, jsonb) is
|
||||
'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline).';
|
||||
'PATCH EV session – jen klíče přítomné v JSON (ISO string pro deadline; soc_at_connect_pct z Tesla API).';
|
||||
|
||||
80
db/routines/R__095_fn_tesla.sql
Normal file
80
db/routines/R__095_fn_tesla.sql
Normal file
@@ -0,0 +1,80 @@
|
||||
-- Tesla Fleet API – DB vrstva (SQL-first): tokeny, kontext příjezdu, učení VIN.
|
||||
|
||||
create or replace function ems.fn_tesla_token_get()
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
select coalesce(
|
||||
(select jsonb_build_object(
|
||||
'refresh_token', t.refresh_token,
|
||||
'access_token', t.access_token,
|
||||
'access_expires_at', t.access_expires_at,
|
||||
'updated_at', t.updated_at
|
||||
) from ems.tesla_token t where t.id = 1),
|
||||
'{}'::jsonb
|
||||
);
|
||||
$fn$;
|
||||
|
||||
create or replace function ems.fn_tesla_token_upsert(
|
||||
p_refresh_token text,
|
||||
p_access_token text,
|
||||
p_access_expires_at timestamptz
|
||||
)
|
||||
returns void
|
||||
language sql
|
||||
as $fn$
|
||||
insert into ems.tesla_token (id, refresh_token, access_token, access_expires_at, updated_at)
|
||||
values (1, p_refresh_token, p_access_token, p_access_expires_at, now())
|
||||
on conflict (id) do update set
|
||||
refresh_token = excluded.refresh_token,
|
||||
access_token = excluded.access_token,
|
||||
access_expires_at = excluded.access_expires_at,
|
||||
updated_at = now();
|
||||
$fn$;
|
||||
|
||||
-- Kontext pro hook po příjezdu EV: vozidlo navázané na charger (default_charger_id)
|
||||
-- + otevřená session. NULL vehicle => nic nedělat.
|
||||
create or replace function ems.fn_tesla_arrival_context(
|
||||
p_site_id int,
|
||||
p_charger_code text
|
||||
)
|
||||
returns jsonb
|
||||
language sql
|
||||
stable
|
||||
as $fn$
|
||||
select coalesce(jsonb_build_object(
|
||||
'vehicle_id', v.id,
|
||||
'api_type', v.api_type,
|
||||
'vin', v.vin,
|
||||
'battery_capacity_kwh', v.battery_capacity_kwh,
|
||||
'session_id', s.id,
|
||||
'soc_at_connect_pct', s.soc_at_connect_pct
|
||||
), '{}'::jsonb)
|
||||
from ems.asset_ev_charger c
|
||||
join ems.asset_vehicle v
|
||||
on v.site_id = c.site_id and v.default_charger_id = c.id and v.active
|
||||
left join lateral (
|
||||
select es.id, es.soc_at_connect_pct
|
||||
from ems.ev_session es
|
||||
where es.charger_id = c.id and es.session_end is null
|
||||
order by es.id desc
|
||||
limit 1
|
||||
) s on true
|
||||
where c.site_id = p_site_id and c.code = p_charger_code;
|
||||
$fn$;
|
||||
|
||||
create or replace function ems.fn_vehicle_set_vin(
|
||||
p_vehicle_id int,
|
||||
p_vin text
|
||||
)
|
||||
returns void
|
||||
language sql
|
||||
as $fn$
|
||||
update ems.asset_vehicle
|
||||
set vin = p_vin
|
||||
where id = p_vehicle_id and (vin is null or vin = '');
|
||||
$fn$;
|
||||
|
||||
comment on function ems.fn_tesla_arrival_context is
|
||||
'Vozidlo + otevřená session pro charger (hook po příjezdu EV → Tesla SoC).';
|
||||
@@ -80,9 +80,19 @@ curl -s https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token \
|
||||
- Pozn.: vehicle_data budí auto (vampire drain) — volat jen při příjezdu
|
||||
a max 1× za session.
|
||||
|
||||
## Stav
|
||||
## Stav (2026-06-11 večer)
|
||||
|
||||
- [x] skript + callback + dokumentace v repu
|
||||
- [ ] uživatel: spustit setup na serveru + Caddy blok + reload
|
||||
- [ ] uživatel: developer portál (CLIENT_ID/SECRET) + partner registrace + OAuth
|
||||
- [ ] EMS: tesla_client.py + hook (čeká na credentials)
|
||||
- [x] uživatel: doména + Caddy + developer portál (CLIENT_ID/SECRET získány)
|
||||
- [x] EMS implementace: `services/tesla_client.py` (refresh s rotací do
|
||||
`ems.tesla_token`, vehicles → vehicle_data charge_state, 408=spící auto),
|
||||
hook `_patch_session_from_tesla` v telemetry_collector (po příjezdu, před
|
||||
replanem; selhání neblokuje plán), `fn_tesla_arrival_context`,
|
||||
`fn_ev_session_apply_patch` + soc_at_connect_pct, VIN auto-learn,
|
||||
V086 (vin, api_type='tesla' pro tesla-my, tabulka tesla_token)
|
||||
- [ ] uživatel: partner registrace (§3) + OAuth (§4) → `TESLA_CLIENT_ID`,
|
||||
`TESLA_CLIENT_SECRET`, `TESLA_REFRESH_TOKEN` do `/opt/ems-deploy/.env`
|
||||
a `docker compose -f /opt/ems-deploy/docker-compose.yml --env-file
|
||||
/opt/ems-deploy/.env up -d backend` (recreate kvůli env)
|
||||
- [ ] ověření: po příjezdu Tesly log `Tesla SoC -> session …` +
|
||||
`select soc_at_connect_pct, target_soc_pct from ems.ev_session order by id desc limit 1`
|
||||
|
||||
Reference in New Issue
Block a user