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 { 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; } export async function exchangeCodeForTokens(code: string): Promise { 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 { 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 { 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 { 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 { 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 { const out = await fleetGet(token, '/api/1/vehicles'); return Array.isArray(out?.response) ? out.response : []; } export async function getVehicleData(token: string, vehicleId: string): Promise { // 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 { 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 { 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 { 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 { 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; }