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:
2026-05-31 22:23:38 +01:00
parent d27381cae3
commit d705669dda
5 changed files with 782 additions and 52 deletions
+187
View File
@@ -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;
}
+78
View File
@@ -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();
},
};
+229 -34
View File
@@ -1,18 +1,31 @@
import { Router } from 'express';
import { Router, Request, Response } from 'express';
import crypto from 'node:crypto';
import { env } from '../config/env.js';
import { createLogger } from '../lib/logger.js';
import { teslaTokenStore } from '../lib/teslaTokenStore.js';
import {
buildAuthorizeUrl,
exchangeCodeForTokens,
getAccessToken,
getVehicleData,
listVehicles,
sendNavigationRequest,
wakeVehicle,
registerPartnerAccount,
getAppToken,
} from '../lib/teslaClient.js';
const log = createLogger('tesla');
const router = Router();
// Until auth.tony.codes is wired in, fall back to a single anonymous "owner"
// identity so the integration works for the deploying user. Replace with
// req.auth!.userId once the auth middleware is mounted in front of this.
function userIdFor(req: Request): string {
return (req as any).auth?.userId || 'owner';
}
// ─── Domain verification ────────────────────────────────────────────────────
// Tesla fetches this path to confirm you own the domain registered with the
// Fleet API partner account. The body must be the EXACT PEM the partner key
// is registered with (the EC public key from your prime256v1 keypair).
//
// Set TESLA_FLEET_PUBLIC_KEY in Dokku config to the full PEM contents —
// including the BEGIN/END lines. Multi-line env vars work fine with Dokku
// when set via `dokku config:set roadtrip TESLA_FLEET_PUBLIC_KEY="$(cat key.pem)"`.
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
if (!env.tesla.publicKey) {
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
@@ -24,49 +37,231 @@ router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) =
res.send(env.tesla.publicKey);
});
// ─── OAuth callback (stub) ──────────────────────────────────────────────────
// Tesla redirects here with ?code=… after the user grants access. We exchange
// the code for a refresh token and store it against the logged-in user.
// Full implementation lands once partner approval is granted.
// ─── Connect status ─────────────────────────────────────────────────────────
router.get('/api/tesla/status', async (req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.json({ available: false, reason: 'pending_partner_approval' });
return;
}
const tokens = await teslaTokenStore.get(userIdFor(req));
res.json({
available: true,
connected: !!tokens,
connectedAt: tokens?.connectedAt ?? null,
vehicleId: tokens?.vehicleId ?? null,
vin: tokens?.vin ?? null,
carType: tokens?.carType ?? null,
trimBadging: tokens?.trimBadging ?? null,
});
});
// ─── Start the OAuth dance ──────────────────────────────────────────────────
const pendingStates = new Map<string, { userId: string; createdAt: number }>();
const STATE_TTL = 10 * 60 * 1000;
router.get('/api/auth/tesla/start', (req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.status(503).json({ error: 'tesla_not_configured' });
return;
}
// Clean expired states.
const now = Date.now();
for (const [k, v] of pendingStates) {
if (now - v.createdAt > STATE_TTL) pendingStates.delete(k);
}
const state = crypto.randomBytes(16).toString('hex');
pendingStates.set(state, { userId: userIdFor(req), createdAt: now });
const url = buildAuthorizeUrl(state);
res.json({ authorizeUrl: url, state });
});
// ─── OAuth callback ─────────────────────────────────────────────────────────
router.get('/api/auth/tesla/callback', async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>;
if (error) {
log.warn({ error, state }, 'Tesla OAuth error returned to callback');
log.warn({ error, state }, 'Tesla OAuth error');
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
return;
}
if (!code) {
res.status(400).type('text/plain').send('Missing ?code from Tesla');
if (!code || !state) {
res.status(400).type('text/plain').send('Missing code or state');
return;
}
if (!env.tesla.clientId || !env.tesla.clientSecret) {
log.warn('Tesla OAuth callback hit but client credentials not configured');
res.status(503).type('text/plain').send('Tesla integration not yet configured');
const pending = pendingStates.get(state);
if (!pending) {
res.status(400).type('text/plain').send('Unknown or expired OAuth state — restart from /api/auth/tesla/start');
return;
}
pendingStates.delete(state);
// TODO once Tesla approves partner registration:
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code
// 2. Decode the id_token, persist refresh_token against req.auth.userId
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key
// 4. Redirect to / with a success flag
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)');
res.redirect('/?tesla_connected=pending');
try {
const tokens = await exchangeCodeForTokens(code);
// Discover the user's vehicle so we can stash its id_s and config.
let vehicleId: string | undefined;
let vin: string | undefined;
let carType: string | undefined;
let trimBadging: string | undefined;
try {
const vehicles = await listVehicles(tokens.access_token);
const first = vehicles[0];
if (first) {
vehicleId = String(first.id_s ?? first.id ?? '');
vin = first.vin;
}
if (vehicleId) {
// vehicle_config can fail if the car is asleep — that's fine, we'll
// fill it in next time the user fetches /api/tesla/state.
try {
const data = await getVehicleData(tokens.access_token, vehicleId);
carType = data?.vehicle_config?.car_type;
trimBadging = data?.vehicle_config?.trim_badging;
} catch (e) {
log.info({ err: String(e) }, 'Vehicle asleep at connect — config will populate later');
}
}
} catch (e) {
log.warn({ err: String(e) }, 'listVehicles failed at connect — continuing without vehicle id');
}
await teslaTokenStore.set(pending.userId, {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: Date.now() + tokens.expires_in * 1000,
scope: tokens.scope || '',
vehicleId,
vin,
carType,
trimBadging,
connectedAt: Date.now(),
});
log.info({ userId: pending.userId, vehicleId, vin: vin?.slice(-6) }, 'Tesla connected');
res.redirect('/?tesla_connected=1');
} catch (err) {
log.error({ err: String(err) }, 'Tesla OAuth callback failed');
res.redirect(`/?tesla_error=${encodeURIComponent('token_exchange_failed')}`);
}
});
// ─── Vehicle state (stub) ───────────────────────────────────────────────────
// Returns the current battery %, range and location for the connected vehicle.
// Until partner approval, returns 503 so the client can hide the integration UI.
router.get('/api/tesla/state', async (_req, res) => {
// ─── Disconnect ─────────────────────────────────────────────────────────────
router.post('/api/tesla/disconnect', async (req, res) => {
await teslaTokenStore.remove(userIdFor(req));
res.json({ ok: true });
});
// ─── Vehicle state (battery, range, location) ───────────────────────────────
router.get('/api/tesla/state', async (req, res) => {
if (!env.tesla.clientId) {
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
return;
}
// TODO: look up the user's stored refresh token, exchange for access token,
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data,
// and shape the response into { battery, range, lat, lng, state, etc. }.
res.status(501).json({ connected: false, reason: 'not_implemented' });
const tokens = await getAccessToken(userIdFor(req));
if (!tokens) {
res.status(401).json({ connected: false, reason: 'not_connected' });
return;
}
if (!tokens.vehicleId) {
res.status(404).json({ connected: true, reason: 'no_vehicles' });
return;
}
try {
const data = await getVehicleData(tokens.accessToken, tokens.vehicleId);
const cs = data?.charge_state || {};
const ds = data?.drive_state || {};
const vs = data?.vehicle_state || {};
const vc = data?.vehicle_config || {};
res.json({
connected: true,
asleep: false,
battery: typeof cs.battery_level === 'number' ? cs.battery_level : null,
// Convert miles → km. Tesla returns rated range in miles regardless of region settings.
rangeKm: typeof cs.battery_range === 'number' ? Math.round(cs.battery_range * 1.60934) : null,
idealRangeKm: typeof cs.ideal_battery_range === 'number' ? Math.round(cs.ideal_battery_range * 1.60934) : null,
chargingState: cs.charging_state ?? null, // Charging | Disconnected | Stopped | Complete
chargerPowerKw: typeof cs.charger_power === 'number' ? cs.charger_power : null,
timeToFullCharge: typeof cs.time_to_full_charge === 'number' ? cs.time_to_full_charge : null, // hours
lat: typeof ds.latitude === 'number' ? ds.latitude : null,
lng: typeof ds.longitude === 'number' ? ds.longitude : null,
heading: typeof ds.heading === 'number' ? ds.heading : null,
speedKmh: typeof ds.speed === 'number' ? Math.round(ds.speed * 1.60934) : null,
shiftState: ds.shift_state ?? null, // P | R | N | D
odometerKm: typeof vs.odometer === 'number' ? Math.round(vs.odometer * 1.60934) : null,
carType: vc.car_type ?? tokens.carType ?? null,
trimBadging: vc.trim_badging ?? tokens.trimBadging ?? null,
vin: tokens.vin ?? null,
vehicleName: vs.vehicle_name ?? null,
softwareVersion: vs.car_version ?? null,
fetchedAt: Date.now(),
});
} catch (err) {
const msg = String(err);
// 408 from Tesla typically means the car is asleep — caller can prompt
// for a wake.
if (/408/.test(msg) || /asleep/i.test(msg)) {
res.status(202).json({ connected: true, asleep: true, vehicleId: tokens.vehicleId });
return;
}
log.error({ err: msg }, 'Failed to fetch vehicle state');
res.status(502).json({ connected: true, error: 'fleet_api_error' });
}
});
// ─── Wake the vehicle ───────────────────────────────────────────────────────
router.post('/api/tesla/wake', async (req, res) => {
const tokens = await getAccessToken(userIdFor(req));
if (!tokens || !tokens.vehicleId) {
res.status(401).json({ ok: false, reason: 'not_connected' });
return;
}
try {
await wakeVehicle(tokens.accessToken, tokens.vehicleId);
res.json({ ok: true });
} catch (err) {
log.error({ err: String(err) }, 'Wake failed');
res.status(502).json({ ok: false, reason: 'wake_failed' });
}
});
// ─── Send navigation destination to the car ─────────────────────────────────
router.post('/api/tesla/send-to-nav', async (req: Request, res: Response) => {
const tokens = await getAccessToken(userIdFor(req));
if (!tokens || !tokens.vehicleId) {
res.status(401).json({ ok: false, reason: 'not_connected' });
return;
}
const { lat, lng, name } = req.body || {};
if (typeof lat !== 'number' || typeof lng !== 'number') {
res.status(400).json({ ok: false, reason: 'bad_coords' });
return;
}
try {
await sendNavigationRequest(tokens.accessToken, tokens.vehicleId, { lat, lng, name });
res.json({ ok: true });
} catch (err) {
log.error({ err: String(err) }, 'send-to-nav failed');
res.status(502).json({ ok: false, reason: 'fleet_api_error' });
}
});
// ─── One-shot partner-account registration ─────────────────────────────────
// Visit this once after partner approval to register our domain + public key
// with Tesla so command signing works. Subsequent calls are no-ops.
router.post('/api/tesla/register-partner', async (_req, res) => {
if (!env.tesla.clientId || !env.tesla.clientSecret) {
res.status(503).json({ ok: false, reason: 'tesla_not_configured' });
return;
}
try {
const appToken = await getAppToken();
const domain = new URL(env.tesla.redirectUri).hostname;
const out = await registerPartnerAccount(appToken, domain);
res.json({ ok: true, response: out });
} catch (err) {
log.error({ err: String(err) }, 'partner registration failed');
res.status(502).json({ ok: false, reason: 'partner_register_failed', detail: String(err).slice(0, 200) });
}
});
export default router;