d705669dda
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
79 lines
2.4 KiB
TypeScript
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();
|
|
},
|
|
};
|