381eb18cd3
- Server: include active_route_destination (lat/lng/eta) from drive_state. - When Tesla is connected, the Origin → Destination strip collapses to a single ConnectedTripStrip: "From car · Where to?". The origin is implicit (the car's GPS), the manual From input + crosshair button disappear. - If Tesla nav already has a destination, it auto-fills as the trip destination; if the user has typed something else, an inline "Use Tesla nav" button offers a one-tap swap. - Mocks: driving + charging scenarios include an activeRoute so the flow is testable end-to-end via ?mockTesla=driving / ?mockTesla=charging.
298 lines
12 KiB
TypeScript
298 lines
12 KiB
TypeScript
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 { ownerIdFromRequest, requireOwner } from '../lib/ownerAuth.js';
|
|
import {
|
|
buildAuthorizeUrl,
|
|
exchangeCodeForTokens,
|
|
getAccessToken,
|
|
getVehicleData,
|
|
listVehicles,
|
|
sendNavigationRequest,
|
|
wakeVehicle,
|
|
registerPartnerAccount,
|
|
getAppToken,
|
|
} from '../lib/teslaClient.js';
|
|
|
|
const log = createLogger('tesla');
|
|
const router = Router();
|
|
|
|
const TESLA_STATE_COOKIE = 'tesla_oauth_state';
|
|
const STATE_TTL_S = 10 * 60;
|
|
|
|
// ─── Domain verification (PUBLIC — Tesla fetches anonymously) ───────────────
|
|
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');
|
|
res.status(404).type('text/plain').send('Public key not configured');
|
|
return;
|
|
}
|
|
res.type('application/x-pem-file');
|
|
res.set('Cache-Control', 'public, max-age=300');
|
|
res.send(env.tesla.publicKey);
|
|
});
|
|
|
|
// ─── Connect status (PUBLIC — used by client to decide UI state) ────────────
|
|
// Reveals only whether the integration is configured / connected; never
|
|
// emits any credentials or vehicle data.
|
|
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 ownerId = ownerIdFromRequest(req);
|
|
const tokens = ownerId ? await teslaTokenStore.get(ownerId) : null;
|
|
res.json({
|
|
available: true,
|
|
connected: !!tokens,
|
|
ownerAuthenticated: !!ownerId,
|
|
connectedAt: tokens?.connectedAt ?? null,
|
|
vehicleId: tokens?.vehicleId ?? null,
|
|
vin: tokens?.vin ? `…${tokens.vin.slice(-4)}` : null, // masked
|
|
carType: tokens?.carType ?? null,
|
|
trimBadging: tokens?.trimBadging ?? null,
|
|
});
|
|
});
|
|
|
|
// ─── Everything below requires owner auth ──────────────────────────────────
|
|
|
|
// ─── Start the OAuth dance ──────────────────────────────────────────────────
|
|
router.get('/api/auth/tesla/start', requireOwner, (req, res) => {
|
|
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
|
res.status(503).json({ error: 'tesla_not_configured' });
|
|
return;
|
|
}
|
|
// Random state — bound to the browser via an httpOnly cookie so a CSRF
|
|
// attacker can't pre-seed a state that lands tokens in our account.
|
|
const state = crypto.randomBytes(16).toString('hex');
|
|
res.cookie(TESLA_STATE_COOKIE, state, {
|
|
httpOnly: true,
|
|
secure: env.nodeEnv === 'production',
|
|
sameSite: 'lax',
|
|
maxAge: STATE_TTL_S * 1000,
|
|
path: '/',
|
|
});
|
|
const url = buildAuthorizeUrl(state);
|
|
res.json({ authorizeUrl: url });
|
|
});
|
|
|
|
// ─── OAuth callback ─────────────────────────────────────────────────────────
|
|
// Tesla redirects the browser here after the user authorises. The owner cookie
|
|
// from when /start was called must still be present (SameSite=Lax allows it
|
|
// through on the top-level navigation back from Tesla), and the state in the
|
|
// cookie must equal the state Tesla bounced back.
|
|
router.get('/api/auth/tesla/callback', async (req, res) => {
|
|
const { code, state, error } = req.query as Record<string, string | undefined>;
|
|
const cookieState = (req as any).cookies?.[TESLA_STATE_COOKIE];
|
|
|
|
// Always clear the state cookie — single-use.
|
|
res.clearCookie(TESLA_STATE_COOKIE, { path: '/' });
|
|
|
|
if (error) {
|
|
log.warn({ error }, 'Tesla OAuth error');
|
|
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
|
|
return;
|
|
}
|
|
if (!code || !state) {
|
|
res.status(400).type('text/plain').send('Missing code or state');
|
|
return;
|
|
}
|
|
if (!cookieState || typeof cookieState !== 'string' || cookieState.length !== state.length
|
|
|| !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(state))) {
|
|
log.warn({ haveCookie: !!cookieState }, 'OAuth state cookie missing or mismatched — rejecting');
|
|
res.status(400).type('text/plain').send('OAuth state mismatch — restart from the planner');
|
|
return;
|
|
}
|
|
|
|
const ownerId = ownerIdFromRequest(req);
|
|
if (!ownerId) {
|
|
// Owner cookie expired or the user opened the callback in a different
|
|
// browser. Refuse rather than fall back to a global identity.
|
|
res.status(401).type('text/plain').send('Owner session expired — log in and reconnect');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const tokens = await exchangeCodeForTokens(code);
|
|
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) {
|
|
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');
|
|
}
|
|
|
|
await teslaTokenStore.set(ownerId, {
|
|
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({ ownerId, vehicleId, vin: vin?.slice(-4) }, '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')}`);
|
|
}
|
|
});
|
|
|
|
// ─── Disconnect ─────────────────────────────────────────────────────────────
|
|
router.post('/api/tesla/disconnect', requireOwner, async (req, res) => {
|
|
const ownerId = ownerIdFromRequest(req)!;
|
|
await teslaTokenStore.remove(ownerId);
|
|
res.json({ ok: true });
|
|
});
|
|
|
|
// ─── Vehicle state (battery, range, location) ───────────────────────────────
|
|
router.get('/api/tesla/state', requireOwner, async (req, res) => {
|
|
if (!env.tesla.clientId) {
|
|
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
|
|
return;
|
|
}
|
|
const ownerId = ownerIdFromRequest(req)!;
|
|
const tokens = await getAccessToken(ownerId);
|
|
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,
|
|
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,
|
|
chargerPowerKw: typeof cs.charger_power === 'number' ? cs.charger_power : null,
|
|
timeToFullCharge: typeof cs.time_to_full_charge === 'number' ? cs.time_to_full_charge : null,
|
|
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,
|
|
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 ? `…${tokens.vin.slice(-4)}` : null,
|
|
vehicleName: vs.vehicle_name ?? null,
|
|
softwareVersion: vs.car_version ?? null,
|
|
// Active in-car nav destination (set in Tesla nav by the driver, or by
|
|
// our own send-to-nav). Useful as a default trip destination.
|
|
activeRoute: (typeof ds.active_route_destination === 'string' && ds.active_route_destination)
|
|
? {
|
|
destination: ds.active_route_destination,
|
|
lat: typeof ds.active_route_latitude === 'number' ? ds.active_route_latitude : null,
|
|
lng: typeof ds.active_route_longitude === 'number' ? ds.active_route_longitude : null,
|
|
kmToArrival: typeof ds.active_route_miles_to_arrival === 'number'
|
|
? Math.round(ds.active_route_miles_to_arrival * 1.60934) : null,
|
|
minutesToArrival: typeof ds.active_route_minutes_to_arrival === 'number'
|
|
? Math.round(ds.active_route_minutes_to_arrival) : null,
|
|
trafficMinutesDelay: typeof ds.active_route_traffic_minutes_delay === 'number'
|
|
? Math.round(ds.active_route_traffic_minutes_delay) : 0,
|
|
}
|
|
: null,
|
|
fetchedAt: Date.now(),
|
|
});
|
|
} catch (err) {
|
|
const msg = String(err);
|
|
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' });
|
|
}
|
|
});
|
|
|
|
router.post('/api/tesla/wake', requireOwner, async (req, res) => {
|
|
const ownerId = ownerIdFromRequest(req)!;
|
|
const tokens = await getAccessToken(ownerId);
|
|
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' });
|
|
}
|
|
});
|
|
|
|
router.post('/api/tesla/send-to-nav', requireOwner, async (req: Request, res: Response) => {
|
|
const ownerId = ownerIdFromRequest(req)!;
|
|
const tokens = await getAccessToken(ownerId);
|
|
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'
|
|
|| lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
|
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' });
|
|
}
|
|
});
|
|
|
|
// ─── Partner-account registration ─────────────────────────────────────────
|
|
// One-shot. Owner-gated. Does NOT echo Tesla's response body to the client.
|
|
router.post('/api/tesla/register-partner', requireOwner, 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;
|
|
await registerPartnerAccount(appToken, domain);
|
|
res.json({ ok: true });
|
|
} catch (err) {
|
|
log.error({ err: String(err) }, 'partner registration failed');
|
|
res.status(502).json({ ok: false, reason: 'partner_register_failed' });
|
|
}
|
|
});
|
|
|
|
export default router;
|