From d705669dda6e27a3a5f2a4cb0a7b86b1d031c0ce Mon Sep 17 00:00:00 2001 From: Tony James Date: Sun, 31 May 2026 22:23:38 +0100 Subject: [PATCH] =?UTF-8?q?feat(tesla):=20real=20Fleet=20API=20integration?= =?UTF-8?q?=20=E2=80=94=20OAuth,=20vehicle=20state,=20send-to-nav?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- client/src/lib/tesla.ts | 116 ++++++++++++ client/src/pages/TeslaTripPlanner.tsx | 190 +++++++++++++++++-- server/lib/teslaClient.ts | 187 ++++++++++++++++++ server/lib/teslaTokenStore.ts | 78 ++++++++ server/routes/tesla.ts | 263 ++++++++++++++++++++++---- 5 files changed, 782 insertions(+), 52 deletions(-) create mode 100644 client/src/lib/tesla.ts create mode 100644 server/lib/teslaClient.ts create mode 100644 server/lib/teslaTokenStore.ts diff --git a/client/src/lib/tesla.ts b/client/src/lib/tesla.ts new file mode 100644 index 0000000..3ba20f0 --- /dev/null +++ b/client/src/lib/tesla.ts @@ -0,0 +1,116 @@ +import { useEffect, useRef, useState } from 'react'; + +export interface TeslaStatus { + available: boolean; + connected: boolean; + connectedAt?: number | null; + vehicleId?: string | null; + vin?: string | null; + carType?: string | null; + trimBadging?: string | null; + reason?: string; +} + +export interface TeslaState { + connected: boolean; + asleep?: boolean; + battery: number | null; + rangeKm: number | null; + idealRangeKm?: number | null; + chargingState: string | null; + chargerPowerKw: number | null; + timeToFullCharge: number | null; + lat: number | null; + lng: number | null; + heading: number | null; + speedKmh: number | null; + shiftState: 'P' | 'R' | 'N' | 'D' | null; + odometerKm?: number | null; + carType?: string | null; + trimBadging?: string | null; + vin?: string | null; + vehicleName?: string | null; + softwareVersion?: string | null; + fetchedAt: number; +} + +export async function fetchTeslaStatus(): Promise { + const res = await fetch('/api/tesla/status'); + if (!res.ok) return { available: false, connected: false }; + return res.json(); +} + +export async function fetchTeslaState(): Promise { + const res = await fetch('/api/tesla/state'); + if (res.status === 202) return null; // asleep + if (!res.ok) return null; + return res.json(); +} + +export async function wakeTesla(): Promise { + const res = await fetch('/api/tesla/wake', { method: 'POST' }); + return res.ok; +} + +export async function startTeslaConnect(): Promise { + const res = await fetch('/api/auth/tesla/start'); + if (!res.ok) throw new Error('Could not start Tesla OAuth'); + const data = await res.json(); + window.location.href = data.authorizeUrl; +} + +export async function disconnectTesla(): Promise { + await fetch('/api/tesla/disconnect', { method: 'POST' }); +} + +export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise { + const res = await fetch('/api/tesla/send-to-nav', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(args), + }); + return res.ok; +} + +/** Hook: subscribe to Tesla status + live state. Polls every 60s while connected. */ +export function useTesla() { + const [status, setStatus] = useState(null); + const [state, setState] = useState(null); + const [stateLoading, setStateLoading] = useState(false); + const pollRef = useRef | null>(null); + + // Initial status fetch + react to ?tesla_connected=1 on return from OAuth. + useEffect(() => { + let cancelled = false; + (async () => { + const s = await fetchTeslaStatus(); + if (!cancelled) setStatus(s); + })(); + return () => { cancelled = true; }; + }, []); + + // Fetch live state whenever we become connected, then poll. + useEffect(() => { + if (!status?.connected) { + if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } + setState(null); + return; + } + const tick = async () => { + setStateLoading(true); + try { + const s = await fetchTeslaState(); + setState(s); + } finally { + setStateLoading(false); + } + }; + tick(); + pollRef.current = setInterval(tick, 60_000); + return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } }; + }, [status?.connected]); + + const refreshStatus = async () => setStatus(await fetchTeslaStatus()); + + return { status, state, stateLoading, refreshStatus }; +} diff --git a/client/src/pages/TeslaTripPlanner.tsx b/client/src/pages/TeslaTripPlanner.tsx index 530b941..b1cf367 100644 --- a/client/src/pages/TeslaTripPlanner.tsx +++ b/client/src/pages/TeslaTripPlanner.tsx @@ -1,6 +1,8 @@ import React, { useState } from 'react'; import { toast } from 'sonner'; import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; +import { useTesla, startTeslaConnect, disconnectTesla, sendToTeslaNav, wakeTesla } from '../lib/tesla'; +import { detectInCar } from '../lib/incar'; import L from 'leaflet'; import { Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X, @@ -8,6 +10,7 @@ import { Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro, CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown, + Car, Battery, Navigation, } from 'lucide-react'; // Fix Leaflet default icons (we still need pins for non-active stops) @@ -983,12 +986,14 @@ function ChargerSwapBlock({ options, onPick }: { options: ChargerOption[]; onPic ); } -function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing }: { +function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing, onSendToNav, canSendToNav }: { stop: Stop; onSwap: (alt: AlternativeStop) => void; onRemove: () => void; onCustomise: () => void; onPickCrossing?: (c: CrossingOption) => void; + onSendToNav?: () => void; + canSendToNav?: boolean; }) { const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; const isCrossing = stop.type === 'crossing' || stop.type === 'tunnel' || stop.type === 'ferry'; @@ -1115,12 +1120,21 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing }: )}
+ {canSendToNav && ( + + )}
)} - {active && } + {active && } @@ -1358,6 +1374,7 @@ function TopBar({ chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx, travelDates, onOpenDates, onUseMyLocation, + teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, inCar, }: { origin: string; destination: string; onOriginChange: (v: string) => void; @@ -1372,7 +1389,14 @@ function TopBar({ travelDates: TravelDates; onOpenDates: (rect: DOMRect) => void; onUseMyLocation: () => void; + teslaStatus: ReturnType['status']; + teslaState: ReturnType['state']; + onConnectTesla: () => void; + onDisconnectTesla: () => void; + inCar: boolean; }) { + const hideVehicleChip = inCar && !!teslaStatus?.connected; + const hideGpxChip = inCar; const [locating, setLocating] = React.useState(false); const handleLocate = async () => { setLocating(true); @@ -1493,7 +1517,8 @@ function TopBar({ - {/* Vehicle chip — opens trim panel */} + {/* Vehicle chip — opens trim panel. Hidden when in-car with Tesla connected (we already know the car). */} + {!hideVehicleChip && ( + )} - onOpenGpx()}> - - Export - - toast('Shareable link copied')}> - - Share - + {teslaStatus?.available && ( + teslaStatus.connected ? ( + + ) : ( + + ) + )} + + {!hideGpxChip && ( + onOpenGpx()}> + + Export + + )} + {!inCar && ( + toast('Shareable link copied')}> + + Share + + )} {grokStatus.label && (
{grokStatus.label} @@ -1543,6 +1603,69 @@ function TopBar({ // ─── Main planner ──────────────────────────────────────────────────────────── export default function TeslaTripPlanner() { + const tesla = useTesla(); + const inCar = React.useMemo(() => detectInCar().isInCar, []); + const teslaConnected = !!tesla.status?.connected; + const teslaInCar = inCar && teslaConnected; + + // When Tesla is connected, auto-detect the vehicle model + trim from the + // Fleet API response so the planner uses the real range / kW instead of + // the default. + React.useEffect(() => { + if (!tesla.state) return; + const carType = tesla.state.carType || tesla.status?.carType; + const trimBadging = tesla.state.trimBadging || tesla.status?.trimBadging; + if (!carType) return; + const modelId = carType === 'modely' ? 'model-y' + : carType === 'model3' ? 'model-3' + : carType === 'models' ? 'model-s' + : carType === 'modelx' ? 'model-x' + : carType === 'cybertruck' ? 'cybertruck' + : null; + if (!modelId) return; + const model = TESLA_MODELS.find(m => m.id === modelId); + if (!model) return; + // Match trim badging (e.g. "lrawd" → Long Range AWD) + const trim = model.trims.find(t => { + const n = (trimBadging || '').toLowerCase(); + const id = t.id.toLowerCase().replace(/-/g, ''); + return n === id || n.includes(id) || id.includes(n); + }) || model.trims[0]; + if (!trim) return; + setVehicle(prev => prev.modelId === model.id && prev.trimId === trim.id ? prev : { + modelId: model.id, trimId: trim.id, name: model.name, trim: trim.name, + rangeKm: trim.rangeKm, kw: trim.kw, sec0to60: trim.sec0to60, topKmh: trim.topKmh, badge: trim.badge, + }); + }, [tesla.state?.carType, tesla.state?.trimBadging, tesla.status?.carType, tesla.status?.trimBadging]); + + // When in-car AND Tesla connected: auto-fill the origin from the car's GPS + // once on mount, so the user doesn't have to type their starting point. + const autoOriginRef = React.useRef(false); + React.useEffect(() => { + if (!teslaInCar || autoOriginRef.current) return; + if (!tesla.state?.lat || !tesla.state?.lng) return; + autoOriginRef.current = true; + reverseGeocode(tesla.state.lat, tesla.state.lng).then(name => { + if (name) setOrigin(name); + }); + }, [teslaInCar, tesla.state?.lat, tesla.state?.lng]); + + // Surface a toast once after the OAuth round trip lands us back at /?tesla_connected=1 + React.useEffect(() => { + const params = new URLSearchParams(window.location.search); + if (params.get('tesla_connected') === '1') { + toast.success('Tesla connected', { description: 'Live battery + GPS + send-to-nav enabled.' }); + params.delete('tesla_connected'); + const q = params.toString(); + window.history.replaceState({}, '', window.location.pathname + (q ? `?${q}` : '')); + } else if (params.get('tesla_error')) { + toast.error('Tesla connect failed', { description: params.get('tesla_error') || undefined }); + params.delete('tesla_error'); + const q = params.toString(); + window.history.replaceState({}, '', window.location.pathname + (q ? `?${q}` : '')); + } + }, []); + const [messages, setMessages] = useState<{ id: number; role: 'user' | 'assistant'; content: string }[]>([ { id: 1, role: 'assistant', content: "Hello! I'm Grok Drive. Tell me where you want to go." }, ]); @@ -1911,17 +2034,30 @@ export default function TeslaTripPlanner() { const useMyLocation = async () => { const t = toast.loading('Locating your car…'); - const coords = await getBrowserLocation(); - if (!coords) { + let lat: number | null = null; + let lng: number | null = null; + + // Prefer Tesla Fleet API location when connected — it's the actual vehicle GPS. + if (tesla.state?.lat != null && tesla.state?.lng != null) { + lat = tesla.state.lat; + lng = tesla.state.lng; + } else { + const coords = await getBrowserLocation(); + if (coords) { lat = coords.latitude; lng = coords.longitude; } + } + + if (lat == null || lng == null) { toast.error('Could not get your location', { id: t, - description: "Tesla's browser may not expose GPS — type your postcode instead.", + description: tesla.status?.connected + ? 'Your Tesla may be asleep — try waking it or type a postcode.' + : "Connect your Tesla account for vehicle GPS, or type a postcode.", }); return; } - const name = await reverseGeocode(coords.latitude, coords.longitude); + const name = await reverseGeocode(lat, lng); if (!name) { - const fallback = `${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)}`; + const fallback = `${lat.toFixed(4)}, ${lng.toFixed(4)}`; setOrigin(fallback); toast.success('Got your location', { id: t, description: fallback }); return; @@ -2050,6 +2186,18 @@ export default function TeslaTripPlanner() { travelDates={travelDates} onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }} onUseMyLocation={useMyLocation} + teslaStatus={tesla.status} + teslaState={tesla.state} + onConnectTesla={async () => { + try { await startTeslaConnect(); } + catch { toast.error('Could not start Tesla OAuth'); } + }} + onDisconnectTesla={async () => { + await disconnectTesla(); + await tesla.refreshStatus(); + toast.success('Tesla disconnected'); + }} + inCar={inCar} /> {variants.length > 0 && ( @@ -2355,6 +2503,12 @@ export default function TeslaTripPlanner() { onMoveDown={() => moveStop(stop.id, 1)} canMoveUp={allStops[0]?.id !== stop.id} canMoveDown={allStops[allStops.length - 1]?.id !== stop.id} + onSendToNav={async () => { + const ok = await sendToTeslaNav({ lat: stop.lat, lng: stop.lng, name: stop.name }); + if (ok) toast.success(`Sent to Tesla nav`, { description: stop.name }); + else toast.error('Could not send to nav', { description: 'Car may be asleep — try wake.' }); + }} + canSendToNav={!!tesla.status?.connected} onDragStart={(e) => { e.dataTransfer.setData('text/plain', stop.id); e.dataTransfer.effectAllowed = 'move'; diff --git a/server/lib/teslaClient.ts b/server/lib/teslaClient.ts new file mode 100644 index 0000000..82a1dc8 --- /dev/null +++ b/server/lib/teslaClient.ts @@ -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 { + 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; +} + +export async function exchangeCodeForTokens(code: string): Promise { + 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 { + 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 { + 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 { + 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 { + 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 { + const out = await fleetGet(token, '/api/1/vehicles'); + return Array.isArray(out?.response) ? out.response : []; +} + +export async function getVehicleData(token: string, vehicleId: string): Promise { + // 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 { + 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 { + 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 { + 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 { + 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; +} diff --git a/server/lib/teslaTokenStore.ts b/server/lib/teslaTokenStore.ts new file mode 100644 index 0000000..ab3a000 --- /dev/null +++ b/server/lib/teslaTokenStore.ts @@ -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; + set(userId: string, tokens: TeslaTokens): Promise; + remove(userId: string): Promise; +} + +// 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 = {}; +let loaded = false; +let writeLock: Promise = Promise.resolve(); + +async function load(): Promise { + 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 { + // 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(); + }, +}; diff --git a/server/routes/tesla.ts b/server/routes/tesla.ts index fc7536d..f23229f 100644 --- a/server/routes/tesla.ts +++ b/server/routes/tesla.ts @@ -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(); +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 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;