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
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { createLogger } from './logger.js';
|
||||
|
||||
const log = createLogger('tesla-token-store');
|
||||
|
||||
export interface TeslaTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresAt: number; // ms epoch
|
||||
scope: string;
|
||||
// Optional cached vehicle metadata so we don't have to refetch on every call.
|
||||
vehicleId?: string; // numeric id_s used in Fleet API URLs
|
||||
vin?: string;
|
||||
carType?: string; // e.g. modely
|
||||
trimBadging?: string; // e.g. lrawd
|
||||
connectedAt: number;
|
||||
}
|
||||
|
||||
interface Store {
|
||||
get(userId: string): Promise<TeslaTokens | null>;
|
||||
set(userId: string, tokens: TeslaTokens): Promise<void>;
|
||||
remove(userId: string): Promise<void>;
|
||||
}
|
||||
|
||||
// File-backed store. Falls back to /app/data when running under Dokku (we
|
||||
// will mount a Dokku storage volume on /app/data), or to ./data in dev.
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const dataDir = process.env.TESLA_TOKEN_DIR
|
||||
|| (process.env.NODE_ENV === 'production' ? '/app/data' : path.resolve(__dirname, '../../data'));
|
||||
const tokenFile = path.join(dataDir, 'tesla-tokens.json');
|
||||
|
||||
let cache: Record<string, TeslaTokens> = {};
|
||||
let loaded = false;
|
||||
let writeLock: Promise<unknown> = Promise.resolve();
|
||||
|
||||
async function load(): Promise<void> {
|
||||
if (loaded) return;
|
||||
try {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
const raw = await fs.readFile(tokenFile, 'utf8');
|
||||
cache = JSON.parse(raw);
|
||||
log.info({ file: tokenFile, users: Object.keys(cache).length }, 'Loaded Tesla tokens');
|
||||
} catch (err: any) {
|
||||
if (err.code !== 'ENOENT') log.warn({ err: String(err) }, 'Failed to load Tesla tokens — starting empty');
|
||||
cache = {};
|
||||
}
|
||||
loaded = true;
|
||||
}
|
||||
|
||||
async function persist(): Promise<void> {
|
||||
// Serialise writes so concurrent set/remove calls don't race.
|
||||
writeLock = writeLock
|
||||
.then(async () => {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
await fs.writeFile(tokenFile, JSON.stringify(cache, null, 2));
|
||||
})
|
||||
.catch(err => log.error({ err: String(err) }, 'Failed to persist Tesla tokens'));
|
||||
await writeLock;
|
||||
}
|
||||
|
||||
export const teslaTokenStore: Store = {
|
||||
async get(userId) {
|
||||
await load();
|
||||
return cache[userId] || null;
|
||||
},
|
||||
async set(userId, tokens) {
|
||||
await load();
|
||||
cache[userId] = tokens;
|
||||
await persist();
|
||||
},
|
||||
async remove(userId) {
|
||||
await load();
|
||||
delete cache[userId];
|
||||
await persist();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user