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:
+229
-34
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user