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
+116
View File
@@ -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<TeslaStatus> {
const res = await fetch('/api/tesla/status');
if (!res.ok) return { available: false, connected: false };
return res.json();
}
export async function fetchTeslaState(): Promise<TeslaState | null> {
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<boolean> {
const res = await fetch('/api/tesla/wake', { method: 'POST' });
return res.ok;
}
export async function startTeslaConnect(): Promise<void> {
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<void> {
await fetch('/api/tesla/disconnect', { method: 'POST' });
}
export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise<boolean> {
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<TeslaStatus | null>(null);
const [state, setState] = useState<TeslaState | null>(null);
const [stateLoading, setStateLoading] = useState(false);
const pollRef = useRef<ReturnType<typeof setInterval> | 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 };
}
+163 -9
View File
@@ -1,6 +1,8 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { MapContainer, TileLayer, Marker, Polyline, Popup, useMap } from 'react-leaflet'; 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 L from 'leaflet';
import { import {
Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X, Send, Sparkles, Download, Share2, ChevronDown, ChevronUp, X,
@@ -8,6 +10,7 @@ import {
Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera, Zap, Bed, Flag, Home as HomeIcon, Coffee, Utensils, Camera,
ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro, ShoppingBag, Footprints, Wifi, MapPin, Route, Clock, TreePine, Euro,
CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown, CalendarDays, Ship, Users, ExternalLink, Crosshair, ArrowUp, ArrowDown,
Car, Battery, Navigation,
} from 'lucide-react'; } from 'lucide-react';
// Fix Leaflet default icons (we still need pins for non-active stops) // 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; stop: Stop;
onSwap: (alt: AlternativeStop) => void; onSwap: (alt: AlternativeStop) => void;
onRemove: () => void; onRemove: () => void;
onCustomise: () => void; onCustomise: () => void;
onPickCrossing?: (c: CrossingOption) => void; onPickCrossing?: (c: CrossingOption) => void;
onSendToNav?: () => void;
canSendToNav?: boolean;
}) { }) {
const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger'; const isCharge = stop.type === 'supercharger' || stop.type === 'destination-charger';
const isCrossing = stop.type === 'crossing' || stop.type === 'tunnel' || stop.type === 'ferry'; const isCrossing = stop.type === 'crossing' || stop.type === 'tunnel' || stop.type === 'ferry';
@@ -1115,12 +1120,21 @@ function StopExpansion({ stop, onSwap, onRemove, onCustomise, onPickCrossing }:
)} )}
<div className="flex gap-1.5 mt-3"> <div className="flex gap-1.5 mt-3">
{canSendToNav && (
<button
onClick={(e) => { e.stopPropagation(); onSendToNav?.(); }}
className="flex-1 h-8 inline-flex items-center justify-center gap-1.5 rounded-lg text-[11.5px] transition"
style={{ background: 'var(--gd-red)', color: '#fff', border: '1px solid var(--gd-red)' }}
>
<Navigation className="w-3 h-3" /> Send to Tesla nav
</button>
)}
<button <button
onClick={(e) => { e.stopPropagation(); onCustomise(); }} onClick={(e) => { e.stopPropagation(); onCustomise(); }}
className="flex-1 h-8 inline-flex items-center justify-center gap-1.5 rounded-lg text-[11.5px] border transition" className="flex-1 h-8 inline-flex items-center justify-center gap-1.5 rounded-lg text-[11.5px] border transition"
style={{ background: 'var(--gd-panel-2)', borderColor: 'var(--gd-border)', color: 'var(--gd-text)' }} style={{ background: 'var(--gd-panel-2)', borderColor: 'var(--gd-border)', color: 'var(--gd-text)' }}
> >
<Settings2 className="w-3 h-3" /> Customise stop <Settings2 className="w-3 h-3" /> Customise
</button> </button>
<button <button
onClick={(e) => { e.stopPropagation(); onRemove(); }} onClick={(e) => { e.stopPropagation(); onRemove(); }}
@@ -1191,7 +1205,7 @@ function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHo
// ─── Stop card (icon-led) ──────────────────────────────────────────────────── // ─── Stop card (icon-led) ────────────────────────────────────────────────────
function StopCard({ function StopCard({
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, onPickCrossing, stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, onPickCrossing,
onMoveUp, onMoveDown, canMoveUp, canMoveDown, onMoveUp, onMoveDown, canMoveUp, canMoveDown, onSendToNav, canSendToNav,
onDragStart, onDragOver, onDrop, onDragEnd, onDragStart, onDragOver, onDrop, onDragEnd,
}: { }: {
stop: Stop; stop: Stop;
@@ -1208,6 +1222,8 @@ function StopCard({
onMoveDown: () => void; onMoveDown: () => void;
canMoveUp: boolean; canMoveUp: boolean;
canMoveDown: boolean; canMoveDown: boolean;
onSendToNav?: () => void;
canSendToNav?: boolean;
onDragStart: (e: React.DragEvent) => void; onDragStart: (e: React.DragEvent) => void;
onDragOver: (e: React.DragEvent) => void; onDragOver: (e: React.DragEvent) => void;
onDrop: (e: React.DragEvent) => void; onDrop: (e: React.DragEvent) => void;
@@ -1308,7 +1324,7 @@ function StopCard({
</div> </div>
)} )}
{active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} onPickCrossing={onPickCrossing} />} {active && <StopExpansion stop={stop} onSwap={onSwap} onRemove={onRemove} onCustomise={onCustomise} onPickCrossing={onPickCrossing} onSendToNav={onSendToNav} canSendToNav={canSendToNav} />}
</div> </div>
</div> </div>
</div> </div>
@@ -1358,6 +1374,7 @@ function TopBar({
chatInput, setChatInput, onChatSubmit, chips, onRemoveChip, chatInput, setChatInput, onChatSubmit, chips, onRemoveChip,
vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx, vehicle, onOpenVehiclePanel, grokStatus, onOpenGpx,
travelDates, onOpenDates, onUseMyLocation, travelDates, onOpenDates, onUseMyLocation,
teslaStatus, teslaState, onConnectTesla, onDisconnectTesla, inCar,
}: { }: {
origin: string; destination: string; origin: string; destination: string;
onOriginChange: (v: string) => void; onOriginChange: (v: string) => void;
@@ -1372,7 +1389,14 @@ function TopBar({
travelDates: TravelDates; travelDates: TravelDates;
onOpenDates: (rect: DOMRect) => void; onOpenDates: (rect: DOMRect) => void;
onUseMyLocation: () => void; onUseMyLocation: () => void;
teslaStatus: ReturnType<typeof useTesla>['status'];
teslaState: ReturnType<typeof useTesla>['state'];
onConnectTesla: () => void;
onDisconnectTesla: () => void;
inCar: boolean;
}) { }) {
const hideVehicleChip = inCar && !!teslaStatus?.connected;
const hideGpxChip = inCar;
const [locating, setLocating] = React.useState(false); const [locating, setLocating] = React.useState(false);
const handleLocate = async () => { const handleLocate = async () => {
setLocating(true); setLocating(true);
@@ -1493,7 +1517,8 @@ function TopBar({
</button> </button>
</div> </div>
{/* Vehicle chip — opens trim panel */} {/* Vehicle chip — opens trim panel. Hidden when in-car with Tesla connected (we already know the car). */}
{!hideVehicleChip && (
<button <button
onClick={(e) => onOpenVehiclePanel(e.currentTarget.getBoundingClientRect())} onClick={(e) => onOpenVehiclePanel(e.currentTarget.getBoundingClientRect())}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer" className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
@@ -1509,6 +1534,7 @@ function TopBar({
</div> </div>
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} /> <ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
</button> </button>
)}
<button <button
onClick={(e) => onOpenDates(e.currentTarget.getBoundingClientRect())} onClick={(e) => onOpenDates(e.currentTarget.getBoundingClientRect())}
@@ -1524,14 +1550,48 @@ function TopBar({
<ChevronDown className="w-3 h-3" style={{ color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text-3)' }} /> <ChevronDown className="w-3 h-3" style={{ color: datesEmpty ? 'var(--gd-red)' : 'var(--gd-text-3)' }} />
</button> </button>
{teslaStatus?.available && (
teslaStatus.connected ? (
<button
onClick={onDisconnectTesla}
title="Disconnect Tesla"
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
>
<Car className="w-3.5 h-3.5" style={{ color: 'var(--gd-red)' }} />
<div className="flex flex-col items-start leading-[1.15]">
<div className="text-[11.5px] font-medium">
{teslaState?.battery != null ? `${teslaState.battery}%` : 'Connected'}
</div>
<div className="text-[9.5px] num" style={{ color: 'var(--gd-text-3)' }}>
{teslaState?.rangeKm != null ? `${teslaState.rangeKm} km` : (teslaState?.asleep ? 'asleep' : '—')}
</div>
</div>
</button>
) : (
<button
onClick={onConnectTesla}
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
style={{ background: 'var(--gd-red-soft)', border: '1px solid var(--gd-red-line)', color: 'var(--gd-red)' }}
>
<Car className="w-3.5 h-3.5" />
<div className="text-[12px] font-medium">Connect Tesla</div>
</button>
)
)}
{!hideGpxChip && (
<ChipButton onClick={() => onOpenGpx()}> <ChipButton onClick={() => onOpenGpx()}>
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} /> <Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
Export Export
</ChipButton> </ChipButton>
)}
{!inCar && (
<ChipButton onClick={() => toast('Shareable link copied')}> <ChipButton onClick={() => toast('Shareable link copied')}>
<Share2 className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} /> <Share2 className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
Share Share
</ChipButton> </ChipButton>
)}
{grokStatus.label && ( {grokStatus.label && (
<div className="text-[10px] px-2 py-1 rounded" style={{ background: 'var(--gd-panel-2)', color: 'var(--gd-text-3)' }}> <div className="text-[10px] px-2 py-1 rounded" style={{ background: 'var(--gd-panel-2)', color: 'var(--gd-text-3)' }}>
{grokStatus.label} {grokStatus.label}
@@ -1543,6 +1603,69 @@ function TopBar({
// ─── Main planner ──────────────────────────────────────────────────────────── // ─── Main planner ────────────────────────────────────────────────────────────
export default function TeslaTripPlanner() { 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 }[]>([ 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." }, { 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 useMyLocation = async () => {
const t = toast.loading('Locating your car…'); const t = toast.loading('Locating your car…');
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(); const coords = await getBrowserLocation();
if (!coords) { if (coords) { lat = coords.latitude; lng = coords.longitude; }
}
if (lat == null || lng == null) {
toast.error('Could not get your location', { toast.error('Could not get your location', {
id: t, 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; return;
} }
const name = await reverseGeocode(coords.latitude, coords.longitude); const name = await reverseGeocode(lat, lng);
if (!name) { if (!name) {
const fallback = `${coords.latitude.toFixed(4)}, ${coords.longitude.toFixed(4)}`; const fallback = `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
setOrigin(fallback); setOrigin(fallback);
toast.success('Got your location', { id: t, description: fallback }); toast.success('Got your location', { id: t, description: fallback });
return; return;
@@ -2050,6 +2186,18 @@ export default function TeslaTripPlanner() {
travelDates={travelDates} travelDates={travelDates}
onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }} onOpenDates={(rect) => { setDateAnchor(rect); setDatePickerOpen(true); }}
onUseMyLocation={useMyLocation} 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 && ( {variants.length > 0 && (
@@ -2355,6 +2503,12 @@ export default function TeslaTripPlanner() {
onMoveDown={() => moveStop(stop.id, 1)} onMoveDown={() => moveStop(stop.id, 1)}
canMoveUp={allStops[0]?.id !== stop.id} canMoveUp={allStops[0]?.id !== stop.id}
canMoveDown={allStops[allStops.length - 1]?.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) => { onDragStart={(e) => {
e.dataTransfer.setData('text/plain', stop.id); e.dataTransfer.setData('text/plain', stop.id);
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
+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 { env } from '../config/env.js';
import { createLogger } from '../lib/logger.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 log = createLogger('tesla');
const router = Router(); 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 ──────────────────────────────────────────────────── // ─── 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) => { router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
if (!env.tesla.publicKey) { if (!env.tesla.publicKey) {
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty'); 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); res.send(env.tesla.publicKey);
}); });
// ─── OAuth callback (stub) ────────────────────────────────────────────────── // ─── Connect status ─────────────────────────────────────────────────────────
// Tesla redirects here with ?code=… after the user grants access. We exchange router.get('/api/tesla/status', async (req, res) => {
// the code for a refresh token and store it against the logged-in user. if (!env.tesla.clientId || !env.tesla.clientSecret) {
// Full implementation lands once partner approval is granted. 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) => { router.get('/api/auth/tesla/callback', async (req, res) => {
const { code, state, error } = req.query as Record<string, string | undefined>; const { code, state, error } = req.query as Record<string, string | undefined>;
if (error) { 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)}`); res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
return; return;
} }
if (!code) { if (!code || !state) {
res.status(400).type('text/plain').send('Missing ?code from Tesla'); res.status(400).type('text/plain').send('Missing code or state');
return; return;
} }
if (!env.tesla.clientId || !env.tesla.clientSecret) { const pending = pendingStates.get(state);
log.warn('Tesla OAuth callback hit but client credentials not configured'); if (!pending) {
res.status(503).type('text/plain').send('Tesla integration not yet configured'); res.status(400).type('text/plain').send('Unknown or expired OAuth state — restart from /api/auth/tesla/start');
return; 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');
}
// TODO once Tesla approves partner registration: await teslaTokenStore.set(pending.userId, {
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code accessToken: tokens.access_token,
// 2. Decode the id_token, persist refresh_token against req.auth.userId refreshToken: tokens.refresh_token,
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key expiresAt: Date.now() + tokens.expires_in * 1000,
// 4. Redirect to / with a success flag scope: tokens.scope || '',
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)'); vehicleId,
res.redirect('/?tesla_connected=pending'); 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) ─────────────────────────────────────────────────── // ─── Disconnect ─────────────────────────────────────────────────────────────
// Returns the current battery %, range and location for the connected vehicle. router.post('/api/tesla/disconnect', async (req, res) => {
// Until partner approval, returns 503 so the client can hide the integration UI. await teslaTokenStore.remove(userIdFor(req));
router.get('/api/tesla/state', async (_req, res) => { res.json({ ok: true });
});
// ─── Vehicle state (battery, range, location) ───────────────────────────────
router.get('/api/tesla/state', async (req, res) => {
if (!env.tesla.clientId) { if (!env.tesla.clientId) {
res.status(503).json({ connected: false, reason: 'pending_partner_approval' }); res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
return; return;
} }
// TODO: look up the user's stored refresh token, exchange for access token, const tokens = await getAccessToken(userIdFor(req));
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data, if (!tokens) {
// and shape the response into { battery, range, lat, lng, state, etc. }. res.status(401).json({ connected: false, reason: 'not_connected' });
res.status(501).json({ connected: false, reason: 'not_implemented' }); 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; export default router;