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,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();
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user