Files
tesla-roadtrip/server/lib/teslaClient.ts
T
tony d705669dda feat(tesla): real Fleet API integration — OAuth, vehicle state, send-to-nav
Server:
- teslaTokenStore: file-backed token store at /app/data/tesla-tokens.json
- teslaClient: OAuth (authorize/code-exchange/refresh), Fleet API GET/POST,
  listVehicles, getVehicleData, wake, sendNavigationRequest, getAppToken,
  registerPartnerAccount; auto-rotates refresh tokens 60s before expiry
- /api/tesla/status, /api/auth/tesla/start, /api/auth/tesla/callback,
  /api/tesla/state, /api/tesla/wake, /api/tesla/send-to-nav,
  /api/tesla/disconnect, /api/tesla/register-partner
- State includes battery, range (mi→km), charging power/eta, GPS,
  shift_state, model/trim auto-detected from vehicle_config

Client:
- useTesla hook: auto-fetches status, polls live state every 60s when connected
- Connect Tesla chip in TopBar; on connect shows battery% + range
- Per-stop "Send to Tesla nav" button (only when Tesla connected)
- "Use my location" button prefers vehicle GPS over browser geolocation
- Auto-detects model/trim from Tesla and updates the vehicle picker
- When in-car AND Tesla connected: auto-fills origin from car's GPS,
  hides the vehicle chip (we know the car), hides GPX export and Share
2026-05-31 22:23:38 +01:00

188 lines
7.0 KiB
TypeScript

import { env } from '../config/env.js';
import { createLogger } from './logger.js';
import { teslaTokenStore, TeslaTokens } from './teslaTokenStore.js';
const log = createLogger('tesla-client');
// ─── Endpoints ──────────────────────────────────────────────────────────────
export const TESLA_AUTH_BASE = 'https://auth.tesla.com';
export const TESLA_FLEET_BASE = env.tesla.region === 'eu'
? 'https://fleet-api.prd.eu.vn.cloud.tesla.com'
: 'https://fleet-api.prd.na.vn.cloud.tesla.com';
export const TESLA_SCOPES = [
'openid',
'offline_access',
'vehicle_device_data',
'vehicle_location',
'vehicle_cmds',
'vehicle_charging_cmds',
].join(' ');
// ─── OAuth ──────────────────────────────────────────────────────────────────
export function buildAuthorizeUrl(state: string): string {
const params = new URLSearchParams({
response_type: 'code',
client_id: env.tesla.clientId,
redirect_uri: env.tesla.redirectUri,
scope: TESLA_SCOPES,
state,
prompt: 'login',
locale: 'en-GB',
});
return `${TESLA_AUTH_BASE}/oauth2/v3/authorize?${params.toString()}`;
}
export interface TokenResponse {
access_token: string;
refresh_token: string;
id_token?: string;
expires_in: number; // seconds
token_type: string;
scope?: string;
}
async function tokenRequest(form: URLSearchParams): Promise<TokenResponse> {
const res = await fetch(`${TESLA_AUTH_BASE}/oauth2/v3/token`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
'Accept': 'application/json',
},
body: form,
});
if (!res.ok) {
const text = await res.text();
log.error({ status: res.status, body: text.slice(0, 400) }, 'Tesla token request failed');
throw new Error(`Tesla OAuth ${res.status}: ${text.slice(0, 200)}`);
}
return res.json() as Promise<TokenResponse>;
}
export async function exchangeCodeForTokens(code: string): Promise<TokenResponse> {
return tokenRequest(new URLSearchParams({
grant_type: 'authorization_code',
client_id: env.tesla.clientId,
client_secret: env.tesla.clientSecret,
code,
redirect_uri: env.tesla.redirectUri,
audience: TESLA_FLEET_BASE,
}));
}
export async function refreshTokens(refreshToken: string): Promise<TokenResponse> {
return tokenRequest(new URLSearchParams({
grant_type: 'refresh_token',
client_id: env.tesla.clientId,
refresh_token: refreshToken,
scope: TESLA_SCOPES,
}));
}
// ─── Token helper ───────────────────────────────────────────────────────────
// Returns a valid access token for the user, refreshing if it's within 60s of
// expiry. Saves the rotated tokens back to the store.
export async function getAccessToken(userId: string): Promise<TeslaTokens | null> {
const stored = await teslaTokenStore.get(userId);
if (!stored) return null;
if (stored.expiresAt - Date.now() > 60_000) return stored;
try {
const fresh = await refreshTokens(stored.refreshToken);
const updated: TeslaTokens = {
...stored,
accessToken: fresh.access_token,
refreshToken: fresh.refresh_token || stored.refreshToken,
expiresAt: Date.now() + fresh.expires_in * 1000,
scope: fresh.scope || stored.scope,
};
await teslaTokenStore.set(userId, updated);
return updated;
} catch (err) {
log.warn({ userId, err: String(err) }, 'Token refresh failed — user must reconnect');
return null;
}
}
// ─── Fleet API calls ────────────────────────────────────────────────────────
async function fleetGet(token: string, path: string): Promise<any> {
const res = await fetch(`${TESLA_FLEET_BASE}${path}`, {
headers: { 'Authorization': `Bearer ${token}`, 'Accept': 'application/json' },
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Fleet GET ${path}${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
async function fleetPost(token: string, path: string, body: unknown): Promise<any> {
const res = await fetch(`${TESLA_FLEET_BASE}${path}`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify(body || {}),
});
if (!res.ok) {
const text = await res.text();
throw new Error(`Fleet POST ${path}${res.status}: ${text.slice(0, 200)}`);
}
return res.json();
}
export async function listVehicles(token: string): Promise<any[]> {
const out = await fleetGet(token, '/api/1/vehicles');
return Array.isArray(out?.response) ? out.response : [];
}
export async function getVehicleData(token: string, vehicleId: string): Promise<any> {
// include all the endpoint groups we care about
const endpoints = ['charge_state', 'drive_state', 'vehicle_state', 'vehicle_config'].join(';');
const out = await fleetGet(token, `/api/1/vehicles/${vehicleId}/vehicle_data?endpoints=${endpoints}`);
return out?.response || null;
}
export async function wakeVehicle(token: string, vehicleId: string): Promise<any> {
return fleetPost(token, `/api/1/vehicles/${vehicleId}/wake_up`, {});
}
/** Send a navigation destination to the car's in-built nav. */
export async function sendNavigationRequest(
token: string,
vehicleId: string,
args: { lat: number; lng: number; name?: string },
): Promise<any> {
return fleetPost(token, `/api/1/vehicles/${vehicleId}/command/navigation_gps_request`, {
lat: args.lat,
lon: args.lng,
order: 1,
});
}
// ─── Partner key registration ───────────────────────────────────────────────
// One-shot setup — after partner approval, call this once to register our
// public key with Tesla so command signing works. Idempotent: safe to call.
export async function registerPartnerAccount(token: string, domain: string): Promise<any> {
return fleetPost(token, '/api/1/partner_accounts', { domain });
}
// ─── App-level access token (no user) ───────────────────────────────────────
// Some Fleet API setup calls (register partner account, fetch public key) use
// a token issued via client_credentials, not a user's auth code.
export async function getAppToken(): Promise<string> {
const res = await tokenRequest(new URLSearchParams({
grant_type: 'client_credentials',
client_id: env.tesla.clientId,
client_secret: env.tesla.clientSecret,
scope: 'openid offline_access vehicle_device_data vehicle_location vehicle_cmds vehicle_charging_cmds',
audience: TESLA_FLEET_BASE,
}));
return res.access_token;
}