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:
@@ -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 };
|
||||
}
|
||||
@@ -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 }:
|
||||
)}
|
||||
|
||||
<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
|
||||
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"
|
||||
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
|
||||
onClick={(e) => { e.stopPropagation(); onRemove(); }}
|
||||
@@ -1191,7 +1205,7 @@ function NightBlock({ lastStop, onOpenHotelOptions }: { lastStop: Stop; onOpenHo
|
||||
// ─── Stop card (icon-led) ────────────────────────────────────────────────────
|
||||
function StopCard({
|
||||
stop, active, hover, dragging, onSelect, onHover, onSwap, onRemove, onCustomise, onPickCrossing,
|
||||
onMoveUp, onMoveDown, canMoveUp, canMoveDown,
|
||||
onMoveUp, onMoveDown, canMoveUp, canMoveDown, onSendToNav, canSendToNav,
|
||||
onDragStart, onDragOver, onDrop, onDragEnd,
|
||||
}: {
|
||||
stop: Stop;
|
||||
@@ -1208,6 +1222,8 @@ function StopCard({
|
||||
onMoveDown: () => void;
|
||||
canMoveUp: boolean;
|
||||
canMoveDown: boolean;
|
||||
onSendToNav?: () => void;
|
||||
canSendToNav?: boolean;
|
||||
onDragStart: (e: React.DragEvent) => void;
|
||||
onDragOver: (e: React.DragEvent) => void;
|
||||
onDrop: (e: React.DragEvent) => void;
|
||||
@@ -1308,7 +1324,7 @@ function StopCard({
|
||||
</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>
|
||||
@@ -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<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 handleLocate = async () => {
|
||||
setLocating(true);
|
||||
@@ -1493,7 +1517,8 @@ function TopBar({
|
||||
</button>
|
||||
</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
|
||||
onClick={(e) => onOpenVehiclePanel(e.currentTarget.getBoundingClientRect())}
|
||||
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
|
||||
@@ -1509,6 +1534,7 @@ function TopBar({
|
||||
</div>
|
||||
<ChevronDown className="w-3 h-3" style={{ color: 'var(--gd-text-3)' }} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
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)' }} />
|
||||
</button>
|
||||
|
||||
<ChipButton onClick={() => onOpenGpx()}>
|
||||
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||
Export
|
||||
</ChipButton>
|
||||
<ChipButton onClick={() => toast('Shareable link copied')}>
|
||||
<Share2 className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||
Share
|
||||
</ChipButton>
|
||||
{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()}>
|
||||
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||
Export
|
||||
</ChipButton>
|
||||
)}
|
||||
{!inCar && (
|
||||
<ChipButton onClick={() => toast('Shareable link copied')}>
|
||||
<Share2 className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
||||
Share
|
||||
</ChipButton>
|
||||
)}
|
||||
{grokStatus.label && (
|
||||
<div className="text-[10px] px-2 py-1 rounded" style={{ background: 'var(--gd-panel-2)', color: 'var(--gd-text-3)' }}>
|
||||
{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';
|
||||
|
||||
Reference in New Issue
Block a user