Compare commits
17 Commits
cff52b4b9e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| ff39601303 | |||
| 9f489d1fb6 | |||
| 36118b4a4f | |||
| 90a4459e63 | |||
| 10235aaa32 | |||
| b125f9a723 | |||
| a90c8a9354 | |||
| 381eb18cd3 | |||
| 7265103573 | |||
| 25d2779c39 | |||
| 5b97f5b873 | |||
| f793b526aa | |||
| d705669dda | |||
| d27381cae3 | |||
| 58074a0afb | |||
| 8913d2f341 | |||
| fbea75fac6 |
Binary file not shown.
+3
-3
@@ -9,11 +9,11 @@ WORKDIR /app
|
||||
|
||||
# Root deps
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund
|
||||
RUN npm ci --no-audit --no-fund --ignore-scripts
|
||||
|
||||
# Client deps
|
||||
COPY client/package.json client/package-lock.json* ./client/
|
||||
RUN --mount=type=cache,target=/root/.npm npm --prefix client ci --no-audit --no-fund
|
||||
RUN npm --prefix client ci --no-audit --no-fund --ignore-scripts
|
||||
|
||||
# Source
|
||||
COPY tsconfig.json ./
|
||||
@@ -33,7 +33,7 @@ WORKDIR /app
|
||||
|
||||
# Only ship the prod deps + built artefacts
|
||||
COPY package.json package-lock.json* ./
|
||||
RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev --no-audit --no-fund \
|
||||
RUN npm ci --omit=dev --no-audit --no-fund --ignore-scripts \
|
||||
&& npm cache clean --force
|
||||
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
+3
-1
@@ -2,7 +2,9 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/tesla-icon.svg" />
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="apple-touch-icon" href="/favicon.svg" />
|
||||
<meta name="theme-color" content="#0a0a0c" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Tesla Trip Planner • Grok</title>
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
|
||||
Generated
+4334
-89
File diff suppressed because it is too large
Load Diff
+5
-3
@@ -11,6 +11,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@tonycodes/auth-react": "^1.4.0",
|
||||
"dompurify": "^3.2.4",
|
||||
"framer-motion": "^11.0.0",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^0.303.0",
|
||||
@@ -20,10 +21,10 @@
|
||||
"react-resizable-panels": "^2.1.7",
|
||||
"react-router-dom": "^6.21.1",
|
||||
"sonner": "^1.4.0",
|
||||
"tailwind-merge": "^2.2.0",
|
||||
"dompurify": "^3.2.4"
|
||||
"tailwind-merge": "^2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/dompurify": "^3.0.5",
|
||||
"@types/leaflet": "^1.9.14",
|
||||
"@types/node": "^20.10.0",
|
||||
"@types/react": "^18.2.47",
|
||||
@@ -34,6 +35,7 @@
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.3.2",
|
||||
"vite": "^5.0.8",
|
||||
"@types/dompurify": "^3.0.5"
|
||||
"vite-plugin-pwa": "^1.3.0",
|
||||
"workbox-window": "^7.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#e31937"/>
|
||||
<path d="M9 21l5.5-10h3L23 21l-2.7-1.5L17 14l-3.5 5.5L9 21z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 200 B |
@@ -0,0 +1,103 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { registerSW } from 'virtual:pwa-register';
|
||||
import { detectInCar } from '../lib/incar';
|
||||
|
||||
type Kind = 'offline-ready' | 'needs-refresh';
|
||||
|
||||
export function SwNotifier() {
|
||||
const [event, setEvent] = useState<Kind | null>(null);
|
||||
const [updateSW, setUpdateSW] = useState<((reload?: boolean) => void) | null>(null);
|
||||
const inCar = detectInCar().isInCar;
|
||||
|
||||
useEffect(() => {
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
const fn = registerSW({
|
||||
immediate: true,
|
||||
onNeedRefresh() { setEvent('needs-refresh'); },
|
||||
onOfflineReady() { setEvent('offline-ready'); },
|
||||
});
|
||||
setUpdateSW(() => fn);
|
||||
}, []);
|
||||
|
||||
// Desktop / small-screen UX: use a normal toast.
|
||||
useEffect(() => {
|
||||
if (!event || inCar) return;
|
||||
if (event === 'offline-ready') {
|
||||
toast.success('Ready to use offline', {
|
||||
description: 'Your planned trip + map tiles will be available without signal.',
|
||||
});
|
||||
setEvent(null);
|
||||
} else if (event === 'needs-refresh') {
|
||||
toast('New version available', {
|
||||
action: { label: 'Reload', onClick: () => updateSW?.(true) },
|
||||
duration: Infinity,
|
||||
});
|
||||
setEvent(null);
|
||||
}
|
||||
}, [event, inCar, updateSW]);
|
||||
|
||||
if (!inCar || !event) return null;
|
||||
|
||||
// In-car: render a big full-screen acknowledgement with finger-friendly button.
|
||||
const isUpdate = event === 'needs-refresh';
|
||||
const dismiss = () => setEvent(null);
|
||||
const action = () => {
|
||||
if (isUpdate) updateSW?.(true);
|
||||
else dismiss();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={dismiss}
|
||||
className="fixed inset-0 grid place-items-center p-8"
|
||||
style={{ zIndex: 9999, background: 'rgba(5,5,8,0.82)' }}
|
||||
>
|
||||
<div
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="w-full max-w-[560px] overflow-hidden"
|
||||
style={{
|
||||
background: 'var(--gd-bg-2)',
|
||||
border: '1px solid var(--gd-border-2)',
|
||||
borderRadius: 20,
|
||||
boxShadow: '0 24px 60px rgba(0,0,0,0.55)',
|
||||
}}
|
||||
>
|
||||
<div className="px-8 py-7" style={{ borderBottom: '1px solid var(--gd-border)' }}>
|
||||
<div className="text-[12px] uppercase tracking-[0.2em] mb-3"
|
||||
style={{ color: isUpdate ? 'var(--gd-amber)' : 'var(--gd-green)' }}>
|
||||
{isUpdate ? 'New version available' : 'Ready to use offline'}
|
||||
</div>
|
||||
<div className="text-[22px] font-semibold leading-[1.25]">
|
||||
{isUpdate
|
||||
? 'Reload to get the latest planner update'
|
||||
: 'Your trip will be available without signal'}
|
||||
</div>
|
||||
<div className="text-[14px] mt-3" style={{ color: 'var(--gd-text-2)' }}>
|
||||
{isUpdate
|
||||
? 'Your current plan stays the same. We just refresh the app.'
|
||||
: 'Planned route, map tiles, and last-known itinerary are saved on this device. You can keep using the planner if LTE drops.'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="px-8 py-5 flex items-center gap-3" style={{ borderTop: '1px solid var(--gd-border)' }}>
|
||||
{isUpdate && (
|
||||
<button
|
||||
onClick={dismiss}
|
||||
className="flex-1 h-14 text-[16px] rounded-2xl"
|
||||
style={{ background: 'var(--gd-panel-2)', border: '1px solid var(--gd-border)', color: 'var(--gd-text-2)' }}
|
||||
>
|
||||
Later
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={action}
|
||||
className="flex-[2] h-14 text-[16px] rounded-2xl font-semibold"
|
||||
style={{ background: 'var(--gd-red)', color: '#fff', boxShadow: '0 6px 24px rgba(227,25,55,0.35)' }}
|
||||
>
|
||||
{isUpdate ? 'Reload' : 'Got it'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
// Simple charge-aware battery projection through an itinerary.
|
||||
//
|
||||
// Inputs:
|
||||
// - startSoC starting battery % (Tesla live SoC if connected, else 80)
|
||||
// - effectiveRangeKm the realistic full-charge range (~85% of rated)
|
||||
// - stops + legs in planned order
|
||||
//
|
||||
// Output: per-stop { arriveSoC, leaveSoC, risk }, plus a flag if any leg
|
||||
// would arrive below the danger threshold.
|
||||
|
||||
// Structural shapes — we only need the fields we use, so we don't drag the
|
||||
// full Stop/Leg types out of TeslaTripPlanner.
|
||||
interface BPStop {
|
||||
id: string;
|
||||
type: string;
|
||||
chargeMinutes?: number;
|
||||
}
|
||||
interface BPLeg {
|
||||
fromId: string;
|
||||
distanceKm: number | null;
|
||||
}
|
||||
|
||||
export type BatteryRisk = 'ok' | 'warn' | 'danger';
|
||||
|
||||
export interface BatteryAtStop {
|
||||
stopId: string;
|
||||
arriveSoC: number; // % on arrival (can dip negative if range exhausted)
|
||||
leaveSoC: number; // % after planned charging
|
||||
risk: BatteryRisk;
|
||||
}
|
||||
|
||||
export interface BatteryPlan {
|
||||
perStop: BatteryAtStop[];
|
||||
hasDanger: boolean; // any stop with arriveSoC < DANGER_THRESHOLD
|
||||
hasWarning: boolean; // any stop with arriveSoC < WARN_THRESHOLD
|
||||
}
|
||||
|
||||
const WARN_THRESHOLD = 15;
|
||||
const DANGER_THRESHOLD = 5;
|
||||
|
||||
// Rough charge rates per stop type. Realistic, conservative.
|
||||
function chargeRatePctPerMin(type: string): number {
|
||||
switch (type) {
|
||||
case 'supercharger': return 1.5; // ~250 kW peak, average ~1.5%/min
|
||||
case 'destination-charger': return 0.4; // 11-22 kW
|
||||
case 'hotel': return 0.4; // assume destination charging
|
||||
default: return 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function computeBatteryPlan(
|
||||
stops: BPStop[],
|
||||
legs: BPLeg[],
|
||||
startSoC: number,
|
||||
effectiveRangeKm: number,
|
||||
): BatteryPlan {
|
||||
const legByFromId = new Map<string, BPLeg>();
|
||||
for (const l of legs) legByFromId.set(l.fromId, l);
|
||||
|
||||
const perStop: BatteryAtStop[] = [];
|
||||
let current = startSoC;
|
||||
let hasDanger = false;
|
||||
let hasWarning = false;
|
||||
|
||||
for (let i = 0; i < stops.length; i++) {
|
||||
const stop = stops[i]!;
|
||||
let arrive = current;
|
||||
if (i > 0) {
|
||||
const prev = stops[i - 1]!;
|
||||
const leg = legByFromId.get(prev.id);
|
||||
const km = leg?.distanceKm ?? 0;
|
||||
const drop = effectiveRangeKm > 0 ? (km / effectiveRangeKm) * 100 : 0;
|
||||
arrive = current - drop;
|
||||
}
|
||||
|
||||
const risk: BatteryRisk = arrive < DANGER_THRESHOLD ? 'danger'
|
||||
: arrive < WARN_THRESHOLD ? 'warn'
|
||||
: 'ok';
|
||||
if (risk === 'danger') hasDanger = true;
|
||||
if (risk !== 'ok') hasWarning = true;
|
||||
|
||||
// Apply planned charging at this stop. Cap at 100, ignore if no charge planned.
|
||||
const chargeMin = stop.chargeMinutes ?? 0;
|
||||
const rate = chargeRatePctPerMin(stop.type);
|
||||
const leave = Math.min(100, Math.max(arrive, arrive + chargeMin * rate));
|
||||
|
||||
perStop.push({ stopId: stop.id, arriveSoC: arrive, leaveSoC: leave, risk });
|
||||
current = leave;
|
||||
}
|
||||
|
||||
return { perStop, hasDanger, hasWarning };
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { isMockEnabled, getMockStatus, getMockState } from './teslaMock';
|
||||
|
||||
export interface TeslaStatus {
|
||||
available: boolean;
|
||||
connected: boolean;
|
||||
ownerAuthenticated?: boolean;
|
||||
connectedAt?: number | null;
|
||||
vehicleId?: string | null;
|
||||
vin?: string | null;
|
||||
carType?: string | null;
|
||||
trimBadging?: string | null;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface OwnerStatus {
|
||||
authenticated: boolean;
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
export async function fetchOwnerStatus(): Promise<OwnerStatus> {
|
||||
if (isMockEnabled()) return { authenticated: true, required: true };
|
||||
const res = await fetch('/api/auth/owner/status');
|
||||
if (!res.ok) return { authenticated: false, required: false };
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function loginOwner(secret: string): Promise<boolean> {
|
||||
const res = await fetch('/api/auth/owner', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ secret }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function logoutOwner(): Promise<void> {
|
||||
await fetch('/api/auth/owner/logout', { method: 'POST' });
|
||||
}
|
||||
|
||||
export interface OwnerPairCode {
|
||||
code: string;
|
||||
expiresInS: number;
|
||||
}
|
||||
|
||||
export async function mintOwnerPairCode(): Promise<OwnerPairCode | null> {
|
||||
const res = await fetch('/api/auth/owner/pair-code', { method: 'POST' });
|
||||
if (!res.ok) return null;
|
||||
const data = await res.json();
|
||||
if (!data?.code) return null;
|
||||
return { code: data.code, expiresInS: data.expiresInS ?? 300 };
|
||||
}
|
||||
|
||||
export async function redeemOwnerPairCode(code: string): Promise<boolean> {
|
||||
const res = await fetch('/api/auth/owner/pair-redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
});
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export interface TeslaActiveRoute {
|
||||
destination: string;
|
||||
lat: number | null;
|
||||
lng: number | null;
|
||||
kmToArrival: number | null;
|
||||
minutesToArrival: number | null;
|
||||
trafficMinutesDelay: number;
|
||||
}
|
||||
|
||||
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;
|
||||
activeRoute?: TeslaActiveRoute | null;
|
||||
fetchedAt: number;
|
||||
}
|
||||
|
||||
export async function fetchTeslaStatus(): Promise<TeslaStatus> {
|
||||
if (isMockEnabled()) return getMockStatus();
|
||||
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> {
|
||||
if (isMockEnabled()) return getMockState();
|
||||
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> {
|
||||
if (isMockEnabled()) return true;
|
||||
const res = await fetch('/api/tesla/wake', { method: 'POST' });
|
||||
return res.ok;
|
||||
}
|
||||
|
||||
export async function startTeslaConnect(): Promise<void> {
|
||||
if (isMockEnabled()) {
|
||||
window.location.href = `${window.location.pathname}?tesla_connected=1`;
|
||||
return;
|
||||
}
|
||||
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> {
|
||||
if (isMockEnabled()) return;
|
||||
await fetch('/api/tesla/disconnect', { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function sendToTeslaNav(args: { lat: number; lng: number; name?: string }): Promise<boolean> {
|
||||
if (isMockEnabled()) {
|
||||
console.log('[mock] sendToTeslaNav', args);
|
||||
return true;
|
||||
}
|
||||
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 [owner, setOwner] = useState<OwnerStatus | null>(null);
|
||||
const [state, setState] = useState<TeslaState | null>(null);
|
||||
const [stateLoading, setStateLoading] = useState(false);
|
||||
const pollRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
|
||||
if (!cancelled) { setStatus(s); setOwner(o); }
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
// Live state polling. Only runs when the user is owner-authenticated AND
|
||||
// the Tesla account is connected — anything else returns 401 and the poll
|
||||
// would spam the log.
|
||||
useEffect(() => {
|
||||
if (!status?.connected || !owner?.authenticated) {
|
||||
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();
|
||||
// Poll faster when mocking so the driving scenario animates visibly.
|
||||
pollRef.current = setInterval(tick, isMockEnabled() ? 3_000 : 60_000);
|
||||
return () => { if (pollRef.current) { clearInterval(pollRef.current); pollRef.current = null; } };
|
||||
}, [status?.connected, owner?.authenticated]);
|
||||
|
||||
const refreshStatus = async () => {
|
||||
const [s, o] = await Promise.all([fetchTeslaStatus(), fetchOwnerStatus()]);
|
||||
setStatus(s); setOwner(o);
|
||||
};
|
||||
|
||||
return { status, state, stateLoading, owner, refreshStatus };
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Client-side mock for the Tesla integration so we can iterate on UI without
|
||||
// burning Fleet API calls (or sitting in the car).
|
||||
//
|
||||
// Activate via URL:
|
||||
// ?mockTesla=parked parked, 65% battery, in Milton Keynes
|
||||
// ?mockTesla=driving moving at 110 km/h, battery slowly draining
|
||||
// ?mockTesla=charging plugged in at a Supercharger, 132 kW
|
||||
// ?mockTesla=asleep connected but car asleep (state returns null)
|
||||
// ?mockTesla=off disable mock (delete the stored flag)
|
||||
//
|
||||
// The flag persists in localStorage so reloads keep the scenario.
|
||||
// Dev-only: import.meta.env.PROD short-circuits this off in the bundled build,
|
||||
// so production users can't enable mock mode.
|
||||
|
||||
import type { TeslaStatus, TeslaState } from './tesla';
|
||||
|
||||
export type MockScenario = 'parked' | 'driving' | 'charging' | 'asleep';
|
||||
|
||||
const STORAGE_KEY = 'mockTesla';
|
||||
|
||||
export function isMockEnabled(): boolean {
|
||||
return !!getMockScenario();
|
||||
}
|
||||
|
||||
export function getMockScenario(): MockScenario | null {
|
||||
if (typeof window === 'undefined') return null;
|
||||
// Mock is allowed in production too: it only paints the UI with fake data,
|
||||
// it never reaches the (owner-gated) Fleet API. The visible MOCK chip in
|
||||
// the top bar makes the state obvious.
|
||||
|
||||
const urlParam = new URLSearchParams(window.location.search).get('mockTesla');
|
||||
if (urlParam === 'off') {
|
||||
window.localStorage.removeItem(STORAGE_KEY);
|
||||
return null;
|
||||
}
|
||||
if (urlParam && isValidScenario(urlParam)) {
|
||||
window.localStorage.setItem(STORAGE_KEY, urlParam);
|
||||
return urlParam;
|
||||
}
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored && isValidScenario(stored) ? stored : null;
|
||||
}
|
||||
|
||||
function isValidScenario(s: string): s is MockScenario {
|
||||
return s === 'parked' || s === 'driving' || s === 'charging' || s === 'asleep';
|
||||
}
|
||||
|
||||
export function setMockScenario(s: MockScenario | null): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (s == null) window.localStorage.removeItem(STORAGE_KEY);
|
||||
else window.localStorage.setItem(STORAGE_KEY, s);
|
||||
}
|
||||
|
||||
// ─── Canned vehicle identity ─────────────────────────────────────────────────
|
||||
const MOCK_VEHICLE = {
|
||||
vin: '5YJ3E1EA1KF0MOCKED',
|
||||
carType: 'modely',
|
||||
trimBadging: 'lrawd',
|
||||
vehicleName: 'Mocky',
|
||||
softwareVersion: '2026.5.14',
|
||||
};
|
||||
|
||||
// Base location: Milton Keynes, MK7 8PJ
|
||||
const BASE_LAT = 52.0186;
|
||||
const BASE_LNG = -0.7064;
|
||||
|
||||
// Drift state for the "driving" scenario — battery slowly drains, position
|
||||
// shuffles a bit so map markers move.
|
||||
let driveBattery = 64;
|
||||
let driveTickCount = 0;
|
||||
|
||||
export function getMockStatus(): TeslaStatus {
|
||||
const s = getMockScenario();
|
||||
if (!s) return { available: true, connected: false, ownerAuthenticated: true };
|
||||
return {
|
||||
available: true,
|
||||
connected: true,
|
||||
ownerAuthenticated: true,
|
||||
connectedAt: Date.now() - 60 * 60 * 1000,
|
||||
vehicleId: 'mock-1',
|
||||
vin: `…${MOCK_VEHICLE.vin.slice(-4)}`,
|
||||
carType: MOCK_VEHICLE.carType,
|
||||
trimBadging: MOCK_VEHICLE.trimBadging,
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockState(): TeslaState | null {
|
||||
const s = getMockScenario();
|
||||
if (!s || s === 'asleep') return null;
|
||||
|
||||
const common = {
|
||||
connected: true,
|
||||
asleep: false,
|
||||
carType: MOCK_VEHICLE.carType,
|
||||
trimBadging: MOCK_VEHICLE.trimBadging,
|
||||
vin: `…${MOCK_VEHICLE.vin.slice(-4)}`,
|
||||
vehicleName: MOCK_VEHICLE.vehicleName,
|
||||
softwareVersion: MOCK_VEHICLE.softwareVersion,
|
||||
odometerKm: 24_500,
|
||||
heading: s === 'driving' ? 92 : 0,
|
||||
fetchedAt: Date.now(),
|
||||
};
|
||||
|
||||
if (s === 'parked') {
|
||||
return {
|
||||
...common,
|
||||
battery: 65,
|
||||
rangeKm: 320,
|
||||
idealRangeKm: 330,
|
||||
chargingState: 'Disconnected',
|
||||
chargerPowerKw: null,
|
||||
timeToFullCharge: null,
|
||||
lat: BASE_LAT,
|
||||
lng: BASE_LNG,
|
||||
speedKmh: 0,
|
||||
shiftState: 'P' as const,
|
||||
};
|
||||
}
|
||||
|
||||
if (s === 'charging') {
|
||||
return {
|
||||
...common,
|
||||
battery: 47,
|
||||
rangeKm: 240,
|
||||
idealRangeKm: 250,
|
||||
chargingState: 'Charging',
|
||||
chargerPowerKw: 132,
|
||||
timeToFullCharge: 0.55, // ~33 min
|
||||
lat: 52.0461, // Baldock services
|
||||
lng: -0.2024,
|
||||
speedKmh: 0,
|
||||
shiftState: 'P' as const,
|
||||
activeRoute: {
|
||||
destination: 'Eurotunnel Folkestone',
|
||||
lat: 51.0901,
|
||||
lng: 1.1340,
|
||||
kmToArrival: 178,
|
||||
minutesToArrival: 132,
|
||||
trafficMinutesDelay: 8,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// driving — animate battery + position with each tick
|
||||
driveTickCount++;
|
||||
driveBattery = Math.max(8, driveBattery - 0.18);
|
||||
return {
|
||||
...common,
|
||||
battery: Math.round(driveBattery),
|
||||
rangeKm: Math.round(driveBattery * 4.9),
|
||||
idealRangeKm: Math.round(driveBattery * 5.1),
|
||||
chargingState: 'Disconnected',
|
||||
chargerPowerKw: null,
|
||||
timeToFullCharge: null,
|
||||
// Crude eastbound drift across the East Anglian flatlands.
|
||||
lat: BASE_LAT - 0.002 * driveTickCount,
|
||||
lng: BASE_LNG + 0.008 * driveTickCount,
|
||||
speedKmh: 112,
|
||||
shiftState: 'D' as const,
|
||||
activeRoute: {
|
||||
destination: 'Cambridge Services M11',
|
||||
lat: 52.1389,
|
||||
lng: 0.1832,
|
||||
kmToArrival: Math.max(0, 78 - driveTickCount * 0.6),
|
||||
minutesToArrival: Math.max(0, Math.round((78 - driveTickCount * 0.6) / 110 * 60)),
|
||||
trafficMinutesDelay: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Reset the running driving simulation back to start. */
|
||||
export function resetMockDrive(): void {
|
||||
driveBattery = 64;
|
||||
driveTickCount = 0;
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { Toaster } from 'sonner';
|
||||
import App from './App';
|
||||
import './styles/globals.css';
|
||||
import { detectInCar, applyInCarClass } from './lib/incar';
|
||||
import { SwNotifier } from './components/SwNotifier';
|
||||
|
||||
applyInCarClass(detectInCar());
|
||||
|
||||
@@ -13,6 +14,7 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
<Toaster position="top-center" richColors closeButton />
|
||||
<SwNotifier />
|
||||
</BrowserRouter>
|
||||
</React.StrictMode>
|
||||
);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
+70
-1
@@ -1,9 +1,78 @@
|
||||
import { defineConfig } from 'vite';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { resolve } from 'path';
|
||||
import { VitePWA } from 'vite-plugin-pwa';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
VitePWA({
|
||||
registerType: 'autoUpdate',
|
||||
includeAssets: ['favicon.ico', 'apple-touch-icon.png'],
|
||||
manifest: {
|
||||
name: 'Grok Drive',
|
||||
short_name: 'GrokDrive',
|
||||
description: 'AI-assisted Tesla road trip planner',
|
||||
theme_color: '#0a0a0c',
|
||||
background_color: '#0a0a0c',
|
||||
display: 'standalone',
|
||||
orientation: 'any',
|
||||
start_url: '/',
|
||||
scope: '/',
|
||||
icons: [
|
||||
{ src: '/favicon.svg', sizes: 'any', type: 'image/svg+xml' },
|
||||
],
|
||||
},
|
||||
workbox: {
|
||||
globPatterns: ['**/*.{js,css,html,svg,ico,woff,woff2}'],
|
||||
globIgnores: ['**/*.map'],
|
||||
navigateFallback: '/index.html',
|
||||
navigateFallbackDenylist: [/^\/api\//, /^\/\.well-known\//],
|
||||
maximumFileSizeToCacheInBytes: 3 * 1024 * 1024,
|
||||
runtimeCaching: [
|
||||
// Map tiles — cache-first for offline viewing of the planned route.
|
||||
{
|
||||
urlPattern: ({ url }: { url: URL }) =>
|
||||
url.hostname.endsWith('tile.openstreetmap.org')
|
||||
|| url.hostname.endsWith('basemaps.cartocdn.com')
|
||||
|| /tile/i.test(url.hostname),
|
||||
handler: 'CacheFirst',
|
||||
options: {
|
||||
cacheName: 'map-tiles',
|
||||
expiration: { maxEntries: 800, maxAgeSeconds: 60 * 60 * 24 * 30 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// Geocoding + routing APIs — network-first, with cached fallback.
|
||||
{
|
||||
urlPattern: ({ url }: { url: URL }) =>
|
||||
url.hostname.endsWith('nominatim.openstreetmap.org')
|
||||
|| url.hostname.endsWith('router.project-osrm.org'),
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'geo-api',
|
||||
networkTimeoutSeconds: 4,
|
||||
expiration: { maxEntries: 200, maxAgeSeconds: 60 * 60 * 24 * 7 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
// Our own API — network-first, cached short-term so the last good
|
||||
// response stays visible when LTE drops.
|
||||
{
|
||||
urlPattern: ({ url }: { url: URL }) => url.pathname.startsWith('/api/'),
|
||||
handler: 'NetworkFirst',
|
||||
options: {
|
||||
cacheName: 'app-api',
|
||||
networkTimeoutSeconds: 3,
|
||||
expiration: { maxEntries: 50, maxAgeSeconds: 60 * 60 },
|
||||
cacheableResponse: { statuses: [0, 200] },
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
devOptions: { enabled: false },
|
||||
}),
|
||||
],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': resolve(__dirname, './src'),
|
||||
|
||||
@@ -25,6 +25,10 @@ export const env = {
|
||||
grokEnabled: process.env.GROK_ENABLED !== 'false',
|
||||
forceXaiApi: process.env.FORCE_XAI_API === 'true',
|
||||
|
||||
// Owner auth — single-user gate for the Tesla integration until
|
||||
// auth.tony.codes is wired in. Set OWNER_SECRET to a long random string.
|
||||
ownerSecret: process.env.OWNER_SECRET || '',
|
||||
|
||||
// Tesla Fleet API
|
||||
tesla: {
|
||||
// Public key served at /.well-known/appspecific/com.tesla.3p.public-key.pem
|
||||
|
||||
+12
-3
@@ -11,10 +11,17 @@ import { logger } from './lib/logger.js';
|
||||
import chatRoutes from './routes/chat.js';
|
||||
import tripsRoutes from './routes/trips.js';
|
||||
import teslaRoutes from './routes/tesla.js';
|
||||
import ownerRoutes from './routes/owner.js';
|
||||
import { warnIfMisconfigured as warnOwnerAuth } from './lib/ownerAuth.js';
|
||||
import { createOptionalAuth } from './lib/auth.js';
|
||||
|
||||
const app = express();
|
||||
|
||||
// We sit behind one Nginx hop (Dokku's per-app vhost). Trust exactly one
|
||||
// proxy so req.ip reflects the real client and the rate limiters below
|
||||
// can't be bypassed via a spoofed X-Forwarded-For header.
|
||||
app.set('trust proxy', 1);
|
||||
|
||||
app.use(helmet({ contentSecurityPolicy: false }));
|
||||
app.use(cors({ origin: env.appUrl, credentials: true }));
|
||||
app.use(express.json({ limit: '2mb' }));
|
||||
@@ -38,8 +45,10 @@ if (auth) {
|
||||
logger.info('Auth disabled — set AUTH_SECRET to enable user accounts');
|
||||
}
|
||||
|
||||
// Tesla integration: serves the partner public key + OAuth callback. Mounted
|
||||
// at the app root because Tesla's well-known path is fixed.
|
||||
// Owner auth + Tesla integration. Tesla routes are owner-gated except the
|
||||
// public .well-known partner-key path. Owner routes handle login/logout.
|
||||
warnOwnerAuth();
|
||||
app.use(ownerRoutes);
|
||||
app.use(teslaRoutes);
|
||||
|
||||
app.use('/api', chatRoutes);
|
||||
@@ -53,7 +62,7 @@ const __dirname = path.dirname(__filename);
|
||||
const clientDist = path.resolve(__dirname, '../../client/dist');
|
||||
if (existsSync(clientDist)) {
|
||||
app.use(express.static(clientDist, { index: false, maxAge: '1h' }));
|
||||
app.get('*', (req, res, next) => {
|
||||
app.get(/.*/, (req, res, next) => {
|
||||
// Don't shadow API or well-known paths.
|
||||
if (req.path.startsWith('/api') || req.path.startsWith('/.well-known')) return next();
|
||||
res.sendFile(path.join(clientDist, 'index.html'));
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type { Request, Response, NextFunction } from 'express';
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from './logger.js';
|
||||
|
||||
const log = createLogger('owner-auth');
|
||||
|
||||
const COOKIE_NAME = 'owner_session';
|
||||
const COOKIE_MAX_AGE_MS = 30 * 24 * 3600 * 1000;
|
||||
const OWNER_ID = 'owner';
|
||||
|
||||
function expectedCookieValue(): string {
|
||||
if (!env.ownerSecret) return '';
|
||||
return crypto.createHmac('sha256', env.ownerSecret).update('owner').digest('hex');
|
||||
}
|
||||
|
||||
export function setOwnerCookie(res: Response): void {
|
||||
res.cookie(COOKIE_NAME, expectedCookieValue(), {
|
||||
httpOnly: true,
|
||||
secure: env.nodeEnv === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: COOKIE_MAX_AGE_MS,
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
export function clearOwnerCookie(res: Response): void {
|
||||
res.clearCookie(COOKIE_NAME, { path: '/' });
|
||||
}
|
||||
|
||||
/** True when the request carries a valid owner session cookie. */
|
||||
export function isOwnerAuthenticated(req: Request): boolean {
|
||||
if (!env.ownerSecret) return false;
|
||||
const got = (req as any).cookies?.[COOKIE_NAME];
|
||||
if (typeof got !== 'string' || got.length === 0) return false;
|
||||
const expected = expectedCookieValue();
|
||||
if (got.length !== expected.length) return false;
|
||||
try {
|
||||
return crypto.timingSafeEqual(Buffer.from(got, 'hex'), Buffer.from(expected, 'hex'));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns the authenticated owner id or null. Always a single 'owner' id today. */
|
||||
export function ownerIdFromRequest(req: Request): string | null {
|
||||
return isOwnerAuthenticated(req) ? OWNER_ID : null;
|
||||
}
|
||||
|
||||
/** Express middleware: 401 if the request is not owner-authenticated. */
|
||||
export function requireOwner(req: Request, res: Response, next: NextFunction): void {
|
||||
if (isOwnerAuthenticated(req)) return next();
|
||||
res.status(401).json({ error: 'auth_required' });
|
||||
}
|
||||
|
||||
/** Validates an owner secret against env.ownerSecret in constant time. */
|
||||
export function verifyOwnerSecret(input: unknown): boolean {
|
||||
if (!env.ownerSecret || typeof input !== 'string') return false;
|
||||
const a = Buffer.from(input);
|
||||
const b = Buffer.from(env.ownerSecret);
|
||||
if (a.length !== b.length) return false;
|
||||
return crypto.timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
/** Logs a warning if owner auth isn't configured — Tesla routes will return 401. */
|
||||
export function warnIfMisconfigured(): void {
|
||||
if (!env.ownerSecret) {
|
||||
log.warn('OWNER_SECRET is not set — Tesla routes will refuse all requests with 401. Set it to enable owner login.');
|
||||
}
|
||||
}
|
||||
@@ -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,85 @@
|
||||
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, mode: 0o700 });
|
||||
await fs.chmod(dataDir, 0o700).catch(() => {}); // tighten if it already existed
|
||||
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. Write to a
|
||||
// tmpfile then rename for atomicity; owner-only perms via mode + chmod.
|
||||
writeLock = writeLock
|
||||
.then(async () => {
|
||||
await fs.mkdir(dataDir, { recursive: true, mode: 0o700 });
|
||||
await fs.chmod(dataDir, 0o700).catch(() => {});
|
||||
const tmp = `${tokenFile}.${process.pid}.tmp`;
|
||||
await fs.writeFile(tmp, JSON.stringify(cache, null, 2), { mode: 0o600 });
|
||||
await fs.chmod(tmp, 0o600).catch(() => {});
|
||||
await fs.rename(tmp, tokenFile);
|
||||
await fs.chmod(tokenFile, 0o600).catch(() => {});
|
||||
})
|
||||
.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();
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
import { Router } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import {
|
||||
clearOwnerCookie,
|
||||
isOwnerAuthenticated,
|
||||
setOwnerCookie,
|
||||
verifyOwnerSecret,
|
||||
} from '../lib/ownerAuth.js';
|
||||
|
||||
const log = createLogger('owner');
|
||||
const router = Router();
|
||||
|
||||
// ─── Device pair-code store (in-memory) ─────────────────────────────────────
|
||||
// 6-digit short codes minted by an already-logged-in owner session so a
|
||||
// touchscreen device (the Tesla in-car browser) can log in without retyping
|
||||
// the long OWNER_SECRET.
|
||||
//
|
||||
// Defence in depth — 6 digits is only ~20 bits of entropy on its own:
|
||||
// - single-use (delete on success)
|
||||
// - 5-minute TTL
|
||||
// - per-code failure cap: a code self-destructs after 5 wrong guesses
|
||||
// - per-IP rate limit (req.ip via 'trust proxy' = 1, not spoofable)
|
||||
// - global rate limit across all IPs (defeats parallel-IP brute force)
|
||||
const PAIR_CODE_TTL_MS = 5 * 60 * 1000;
|
||||
const MAX_FAILED_PER_CODE = 3; // tighter — was 5
|
||||
const MAX_REDEEM_PER_IP_PER_MIN = 8;
|
||||
const MAX_REDEEM_GLOBAL_PER_MIN = 30;
|
||||
const LOCKOUT_TRIP_FAILURES_5MIN = 100; // after this many failures…
|
||||
const LOCKOUT_DURATION_MS = 10 * 60 * 1000; // …block the endpoint for 10 min
|
||||
|
||||
interface PairCodeEntry { expiresAt: number; failures: number }
|
||||
const pairCodes = new Map<string, PairCodeEntry>();
|
||||
const redeemAttemptsByIp = new Map<string, number[]>();
|
||||
let redeemAttemptsGlobal: number[] = [];
|
||||
let failuresLast5Min: number[] = [];
|
||||
let lockedUntil = 0;
|
||||
|
||||
function cleanPairCodes(): void {
|
||||
const now = Date.now();
|
||||
for (const [k, v] of pairCodes) if (v.expiresAt < now) pairCodes.delete(k);
|
||||
}
|
||||
|
||||
function recordAttempt(ip: string): { ipLimited: boolean; globalLimited: boolean } {
|
||||
const now = Date.now();
|
||||
const ipArr = (redeemAttemptsByIp.get(ip) || []).filter(t => now - t < 60_000);
|
||||
ipArr.push(now);
|
||||
redeemAttemptsByIp.set(ip, ipArr);
|
||||
redeemAttemptsGlobal = redeemAttemptsGlobal.filter(t => now - t < 60_000);
|
||||
redeemAttemptsGlobal.push(now);
|
||||
return {
|
||||
ipLimited: ipArr.length > MAX_REDEEM_PER_IP_PER_MIN,
|
||||
globalLimited: redeemAttemptsGlobal.length > MAX_REDEEM_GLOBAL_PER_MIN,
|
||||
};
|
||||
}
|
||||
|
||||
router.get('/api/auth/owner/status', (req, res) => {
|
||||
res.json({
|
||||
authenticated: isOwnerAuthenticated(req),
|
||||
required: !!env.ownerSecret,
|
||||
});
|
||||
});
|
||||
|
||||
router.post('/api/auth/owner', (req, res) => {
|
||||
if (!env.ownerSecret) {
|
||||
res.status(503).json({ ok: false, reason: 'owner_auth_not_configured' });
|
||||
return;
|
||||
}
|
||||
const { secret } = (req.body || {}) as { secret?: unknown };
|
||||
if (!verifyOwnerSecret(secret)) {
|
||||
res.status(401).json({ ok: false, reason: 'invalid_secret' });
|
||||
return;
|
||||
}
|
||||
setOwnerCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
router.post('/api/auth/owner/logout', (_req, res) => {
|
||||
clearOwnerCookie(res);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Pair code: mint one (requires owner session) ──────────────────────────
|
||||
router.post('/api/auth/owner/pair-code', (req, res) => {
|
||||
if (!isOwnerAuthenticated(req)) {
|
||||
res.status(401).json({ ok: false, reason: 'auth_required' });
|
||||
return;
|
||||
}
|
||||
cleanPairCodes();
|
||||
// Single-tenant app: only one active pair code at a time. Minting a new
|
||||
// one burns any prior code (its window is what owner-attacker brute-force
|
||||
// would target).
|
||||
pairCodes.clear();
|
||||
const code = String(crypto.randomInt(100_000, 1_000_000));
|
||||
pairCodes.set(code, { expiresAt: Date.now() + PAIR_CODE_TTL_MS, failures: 0 });
|
||||
res.json({ ok: true, code, expiresInS: Math.floor(PAIR_CODE_TTL_MS / 1000) });
|
||||
});
|
||||
|
||||
// Mark a failed redeem against every currently-active code. With 6-digit
|
||||
// entropy, an attacker brute-forcing the keyspace doesn't know which code
|
||||
// the owner minted — but every wrong guess they make burns life from the
|
||||
// real code. Three misses during a code's TTL is enough to invalidate it
|
||||
// and force the owner to re-mint, which defeats parallel-IP brute force.
|
||||
// Also record the failure on a global 5-minute window for endpoint lockout.
|
||||
function markFailureAgainstActiveCodes(): void {
|
||||
for (const [code, entry] of pairCodes) {
|
||||
entry.failures += 1;
|
||||
if (entry.failures > MAX_FAILED_PER_CODE) {
|
||||
pairCodes.delete(code);
|
||||
}
|
||||
}
|
||||
const now = Date.now();
|
||||
failuresLast5Min = failuresLast5Min.filter(t => now - t < 5 * 60_000);
|
||||
failuresLast5Min.push(now);
|
||||
if (failuresLast5Min.length > LOCKOUT_TRIP_FAILURES_5MIN) {
|
||||
lockedUntil = now + LOCKOUT_DURATION_MS;
|
||||
failuresLast5Min = [];
|
||||
log.error({ until: new Date(lockedUntil).toISOString() }, 'pair-redeem endpoint locked due to brute force');
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Pair code: redeem on a new device ──────────────────────────────────────
|
||||
router.post('/api/auth/owner/pair-redeem', (req, res) => {
|
||||
// Hard endpoint lockout after a brute-force storm.
|
||||
if (Date.now() < lockedUntil) {
|
||||
res.status(429).json({ ok: false, reason: 'endpoint_locked' });
|
||||
return;
|
||||
}
|
||||
// req.ip is trustworthy because we set 'trust proxy' = 1 in server/index.ts.
|
||||
// Do NOT fall back to the X-Forwarded-For header — it's client-controlled.
|
||||
const ip = req.ip || 'unknown';
|
||||
const { ipLimited, globalLimited } = recordAttempt(ip);
|
||||
if (ipLimited || globalLimited) {
|
||||
log.warn({ ip, ipLimited, globalLimited }, 'pair-redeem rate limited');
|
||||
res.status(429).json({ ok: false, reason: 'rate_limited' });
|
||||
return;
|
||||
}
|
||||
const { code } = (req.body || {}) as { code?: unknown };
|
||||
if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
|
||||
markFailureAgainstActiveCodes();
|
||||
res.status(400).json({ ok: false, reason: 'bad_code' });
|
||||
return;
|
||||
}
|
||||
cleanPairCodes();
|
||||
const entry = pairCodes.get(code);
|
||||
if (!entry) {
|
||||
markFailureAgainstActiveCodes();
|
||||
res.status(401).json({ ok: false, reason: 'invalid_or_expired' });
|
||||
return;
|
||||
}
|
||||
// Success: consume the code, set the cookie.
|
||||
pairCodes.delete(code);
|
||||
setOwnerCookie(res);
|
||||
log.info({ ip }, 'Device paired via owner pair code');
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
+259
-34
@@ -1,18 +1,28 @@
|
||||
import { Router } from 'express';
|
||||
import { Router, Request, Response } from 'express';
|
||||
import crypto from 'node:crypto';
|
||||
import { env } from '../config/env.js';
|
||||
import { createLogger } from '../lib/logger.js';
|
||||
import { teslaTokenStore } from '../lib/teslaTokenStore.js';
|
||||
import { ownerIdFromRequest, requireOwner } from '../lib/ownerAuth.js';
|
||||
import {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForTokens,
|
||||
getAccessToken,
|
||||
getVehicleData,
|
||||
listVehicles,
|
||||
sendNavigationRequest,
|
||||
wakeVehicle,
|
||||
registerPartnerAccount,
|
||||
getAppToken,
|
||||
} from '../lib/teslaClient.js';
|
||||
|
||||
const log = createLogger('tesla');
|
||||
const router = Router();
|
||||
|
||||
// ─── 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)"`.
|
||||
const TESLA_STATE_COOKIE = 'tesla_oauth_state';
|
||||
const STATE_TTL_S = 10 * 60;
|
||||
|
||||
// ─── Domain verification (PUBLIC — Tesla fetches anonymously) ───────────────
|
||||
router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) => {
|
||||
if (!env.tesla.publicKey) {
|
||||
log.warn('Tesla public key requested but TESLA_FLEET_PUBLIC_KEY is empty');
|
||||
@@ -24,49 +34,264 @@ router.get('/.well-known/appspecific/com.tesla.3p.public-key.pem', (_req, res) =
|
||||
res.send(env.tesla.publicKey);
|
||||
});
|
||||
|
||||
// ─── OAuth callback (stub) ──────────────────────────────────────────────────
|
||||
// Tesla redirects here with ?code=… after the user grants access. We exchange
|
||||
// the code for a refresh token and store it against the logged-in user.
|
||||
// Full implementation lands once partner approval is granted.
|
||||
// ─── Connect status (PUBLIC — used by client to decide UI state) ────────────
|
||||
// Reveals only whether the integration is configured / connected; never
|
||||
// emits any credentials or vehicle data.
|
||||
router.get('/api/tesla/status', async (req, res) => {
|
||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||
res.json({ available: false, reason: 'pending_partner_approval' });
|
||||
return;
|
||||
}
|
||||
const ownerId = ownerIdFromRequest(req);
|
||||
const tokens = ownerId ? await teslaTokenStore.get(ownerId) : null;
|
||||
res.json({
|
||||
available: true,
|
||||
connected: !!tokens,
|
||||
ownerAuthenticated: !!ownerId,
|
||||
connectedAt: tokens?.connectedAt ?? null,
|
||||
vehicleId: tokens?.vehicleId ?? null,
|
||||
vin: tokens?.vin ? `…${tokens.vin.slice(-4)}` : null, // masked
|
||||
carType: tokens?.carType ?? null,
|
||||
trimBadging: tokens?.trimBadging ?? null,
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Everything below requires owner auth ──────────────────────────────────
|
||||
|
||||
// ─── Start the OAuth dance ──────────────────────────────────────────────────
|
||||
router.get('/api/auth/tesla/start', requireOwner, (req, res) => {
|
||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||
res.status(503).json({ error: 'tesla_not_configured' });
|
||||
return;
|
||||
}
|
||||
// Random state — bound to the browser via an httpOnly cookie so a CSRF
|
||||
// attacker can't pre-seed a state that lands tokens in our account.
|
||||
const state = crypto.randomBytes(16).toString('hex');
|
||||
res.cookie(TESLA_STATE_COOKIE, state, {
|
||||
httpOnly: true,
|
||||
secure: env.nodeEnv === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: STATE_TTL_S * 1000,
|
||||
path: '/',
|
||||
});
|
||||
const url = buildAuthorizeUrl(state);
|
||||
res.json({ authorizeUrl: url });
|
||||
});
|
||||
|
||||
// ─── OAuth callback ─────────────────────────────────────────────────────────
|
||||
// Tesla redirects the browser here after the user authorises. The owner cookie
|
||||
// from when /start was called must still be present (SameSite=Lax allows it
|
||||
// through on the top-level navigation back from Tesla), and the state in the
|
||||
// cookie must equal the state Tesla bounced back.
|
||||
router.get('/api/auth/tesla/callback', async (req, res) => {
|
||||
const { code, state, error } = req.query as Record<string, string | undefined>;
|
||||
const cookieState = (req as any).cookies?.[TESLA_STATE_COOKIE];
|
||||
|
||||
// Always clear the state cookie — single-use.
|
||||
res.clearCookie(TESLA_STATE_COOKIE, { path: '/' });
|
||||
|
||||
if (error) {
|
||||
log.warn({ error, state }, 'Tesla OAuth error returned to callback');
|
||||
log.warn({ error }, 'Tesla OAuth error');
|
||||
res.redirect(`/?tesla_error=${encodeURIComponent(error)}`);
|
||||
return;
|
||||
}
|
||||
if (!code) {
|
||||
res.status(400).type('text/plain').send('Missing ?code from Tesla');
|
||||
if (!code || !state) {
|
||||
res.status(400).type('text/plain').send('Missing code or state');
|
||||
return;
|
||||
}
|
||||
if (!env.tesla.clientId || !env.tesla.clientSecret) {
|
||||
log.warn('Tesla OAuth callback hit but client credentials not configured');
|
||||
res.status(503).type('text/plain').send('Tesla integration not yet configured');
|
||||
if (!cookieState || typeof cookieState !== 'string' || cookieState.length !== state.length
|
||||
|| !crypto.timingSafeEqual(Buffer.from(cookieState), Buffer.from(state))) {
|
||||
log.warn({ haveCookie: !!cookieState }, 'OAuth state cookie missing or mismatched — rejecting');
|
||||
res.status(400).type('text/plain').send('OAuth state mismatch — restart from the planner');
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO once Tesla approves partner registration:
|
||||
// 1. POST to https://auth.tesla.com/oauth2/v3/token with grant_type=authorization_code
|
||||
// 2. Decode the id_token, persist refresh_token against req.auth.userId
|
||||
// 3. Optional: enrol the vehicle via /api/1/partner_accounts/public_key
|
||||
// 4. Redirect to / with a success flag
|
||||
log.info({ codeLen: code.length, state }, 'Tesla OAuth callback received (stub)');
|
||||
res.redirect('/?tesla_connected=pending');
|
||||
const ownerId = ownerIdFromRequest(req);
|
||||
if (!ownerId) {
|
||||
// Owner cookie expired or the user opened the callback in a different
|
||||
// browser. Refuse rather than fall back to a global identity.
|
||||
res.status(401).type('text/plain').send('Owner session expired — log in and reconnect');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const tokens = await exchangeCodeForTokens(code);
|
||||
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) {
|
||||
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');
|
||||
}
|
||||
|
||||
await teslaTokenStore.set(ownerId, {
|
||||
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(),
|
||||
});
|
||||
|
||||
// ─── Vehicle state (stub) ───────────────────────────────────────────────────
|
||||
// Returns the current battery %, range and location for the connected vehicle.
|
||||
// Until partner approval, returns 503 so the client can hide the integration UI.
|
||||
router.get('/api/tesla/state', async (_req, res) => {
|
||||
log.info({ ownerId, vehicleId, vin: vin?.slice(-4) }, 'Tesla connected');
|
||||
res.redirect('/?tesla_connected=1');
|
||||
} catch (err) {
|
||||
log.error({ err: String(err) }, 'Tesla OAuth callback failed');
|
||||
res.redirect(`/?tesla_error=${encodeURIComponent('token_exchange_failed')}`);
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Disconnect ─────────────────────────────────────────────────────────────
|
||||
router.post('/api/tesla/disconnect', requireOwner, async (req, res) => {
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
await teslaTokenStore.remove(ownerId);
|
||||
res.json({ ok: true });
|
||||
});
|
||||
|
||||
// ─── Vehicle state (battery, range, location) ───────────────────────────────
|
||||
router.get('/api/tesla/state', requireOwner, async (req, res) => {
|
||||
if (!env.tesla.clientId) {
|
||||
res.status(503).json({ connected: false, reason: 'pending_partner_approval' });
|
||||
return;
|
||||
}
|
||||
// TODO: look up the user's stored refresh token, exchange for access token,
|
||||
// call https://fleet-api.prd.eu.vn.cloud.tesla.com/api/1/vehicles/{id}/vehicle_data,
|
||||
// and shape the response into { battery, range, lat, lng, state, etc. }.
|
||||
res.status(501).json({ connected: false, reason: 'not_implemented' });
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
const tokens = await getAccessToken(ownerId);
|
||||
if (!tokens) {
|
||||
res.status(401).json({ connected: false, reason: 'not_connected' });
|
||||
return;
|
||||
}
|
||||
if (!tokens.vehicleId) {
|
||||
res.status(404).json({ connected: true, reason: 'no_vehicles' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await getVehicleData(tokens.accessToken, tokens.vehicleId);
|
||||
const cs = data?.charge_state || {};
|
||||
const ds = data?.drive_state || {};
|
||||
const vs = data?.vehicle_state || {};
|
||||
const vc = data?.vehicle_config || {};
|
||||
res.json({
|
||||
connected: true,
|
||||
asleep: false,
|
||||
battery: typeof cs.battery_level === 'number' ? cs.battery_level : null,
|
||||
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,
|
||||
chargerPowerKw: typeof cs.charger_power === 'number' ? cs.charger_power : null,
|
||||
timeToFullCharge: typeof cs.time_to_full_charge === 'number' ? cs.time_to_full_charge : null,
|
||||
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,
|
||||
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 ? `…${tokens.vin.slice(-4)}` : null,
|
||||
vehicleName: vs.vehicle_name ?? null,
|
||||
softwareVersion: vs.car_version ?? null,
|
||||
// Active in-car nav destination (set in Tesla nav by the driver, or by
|
||||
// our own send-to-nav). Useful as a default trip destination.
|
||||
activeRoute: (typeof ds.active_route_destination === 'string' && ds.active_route_destination)
|
||||
? {
|
||||
destination: ds.active_route_destination,
|
||||
lat: typeof ds.active_route_latitude === 'number' ? ds.active_route_latitude : null,
|
||||
lng: typeof ds.active_route_longitude === 'number' ? ds.active_route_longitude : null,
|
||||
kmToArrival: typeof ds.active_route_miles_to_arrival === 'number'
|
||||
? Math.round(ds.active_route_miles_to_arrival * 1.60934) : null,
|
||||
minutesToArrival: typeof ds.active_route_minutes_to_arrival === 'number'
|
||||
? Math.round(ds.active_route_minutes_to_arrival) : null,
|
||||
trafficMinutesDelay: typeof ds.active_route_traffic_minutes_delay === 'number'
|
||||
? Math.round(ds.active_route_traffic_minutes_delay) : 0,
|
||||
}
|
||||
: null,
|
||||
fetchedAt: Date.now(),
|
||||
});
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tesla/wake', requireOwner, async (req, res) => {
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
const tokens = await getAccessToken(ownerId);
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/api/tesla/send-to-nav', requireOwner, async (req: Request, res: Response) => {
|
||||
const ownerId = ownerIdFromRequest(req)!;
|
||||
const tokens = await getAccessToken(ownerId);
|
||||
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'
|
||||
|| lat < -90 || lat > 90 || lng < -180 || lng > 180) {
|
||||
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' });
|
||||
}
|
||||
});
|
||||
|
||||
// ─── Partner-account registration ─────────────────────────────────────────
|
||||
// One-shot. Owner-gated. Does NOT echo Tesla's response body to the client.
|
||||
router.post('/api/tesla/register-partner', requireOwner, 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;
|
||||
await registerPartnerAccount(appToken, domain);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
log.error({ err: String(err) }, 'partner registration failed');
|
||||
res.status(502).json({ ok: false, reason: 'partner_register_failed' });
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user