Files
tesla-roadtrip/server/lib/teslaTokenStore.ts
T
tony d705669dda 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
2026-05-31 22:23:38 +01:00

79 lines
2.4 KiB
TypeScript

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();
},
};