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 ──────────────────────────────────────────────────── 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 ───────────────────────────────────────────────────────── 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(); 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; if (error) { log.warn({ error, state }, '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; } 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); 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')}`); } }); // ─── 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; } 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;