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 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>
|
||||||
|
|
||||||
<ChipButton onClick={() => onOpenGpx()}>
|
{teslaStatus?.available && (
|
||||||
<Download className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
teslaStatus.connected ? (
|
||||||
Export
|
<button
|
||||||
</ChipButton>
|
onClick={onDisconnectTesla}
|
||||||
<ChipButton onClick={() => toast('Shareable link copied')}>
|
title="Disconnect Tesla"
|
||||||
<Share2 className="w-3.5 h-3.5" style={{ color: 'var(--gd-text-2)' }} />
|
className="h-[38px] px-3 inline-flex items-center gap-2 rounded-[10px] cursor-pointer"
|
||||||
Share
|
style={{ background: 'var(--gd-panel)', border: '1px solid var(--gd-border)' }}
|
||||||
</ChipButton>
|
>
|
||||||
|
<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 && (
|
{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…');
|
||||||
const coords = await getBrowserLocation();
|
let lat: number | null = null;
|
||||||
if (!coords) {
|
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', {
|
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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
@@ -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);
|
||||||
|
|
||||||
// TODO once Tesla approves partner registration:
|
try {
|
||||||
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code
|
const tokens = await exchangeCodeForTokens(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
|
// Discover the user's vehicle so we can stash its id_s and config.
|
||||||
// 4. Redirect to / with a success flag
|
let vehicleId: string | undefined;
|
||||||
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)');
|
let vin: string | undefined;
|
||||||
res.redirect('/?tesla_connected=pending');
|
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) ───────────────────────────────────────────────────
|
// ─── 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;
|
||||||
|
|||||||
Reference in New Issue
Block a user