← back to Charge And Explore
backend/src/providers/telematics.ts
150 lines
// Pluggable per-brand vehicle telematics. Smartcar is the primary provider —
// ONE OAuth connects ~40 brands (Tesla, Ford, GM, Hyundai/Kia, VW, BMW…) and
// returns a normalized battery/charge schema, so we don't chase N manufacturer
// partner programs. A tesla-direct (or any other) provider can implement the
// same TelematicsProvider interface later without touching the routes.
export interface NormalizedVehicle {
id: string; // provider-scoped vehicle id
make: string;
model: string;
year: number | null;
}
export interface VehicleCharge {
batteryPercent: number | null; // 0–100
rangeMiles: number | null;
isCharging: boolean | null;
chargeState: string | null; // CHARGING | FULLY_CHARGED | NOT_CHARGING | …
pluggedIn: boolean | null;
updatedAt: string; // ISO timestamp of this read
}
export interface OAuthTokens {
accessToken: string;
refreshToken: string;
expiresAt: number; // epoch ms
}
export interface TelematicsProvider {
readonly id: string; // "smartcar"
readonly configured: boolean; // are the required env creds present?
connectUrl(redirectUri: string, state: string): string;
exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens>;
refresh(refreshToken: string): Promise<OAuthTokens>;
listVehicles(accessToken: string): Promise<NormalizedVehicle[]>;
vehicleCharge(accessToken: string, vehicleId: string): Promise<VehicleCharge>;
}
const KM_TO_MI = 0.621371;
function pct(n: unknown): number | null {
// Smartcar returns percentRemaining as 0–1; tolerate 0–100 too.
if (typeof n !== "number" || Number.isNaN(n)) return null;
return Math.round((n <= 1 ? n * 100 : n));
}
function miles(n: unknown, unitSystem: string): number | null {
if (typeof n !== "number" || Number.isNaN(n)) return null;
return Math.round(unitSystem === "imperial" ? n : n * KM_TO_MI);
}
export class SmartcarProvider implements TelematicsProvider {
readonly id = "smartcar";
private clientId = process.env.SMARTCAR_CLIENT_ID ?? "";
private clientSecret = process.env.SMARTCAR_CLIENT_SECRET ?? "";
// Smartcar Connect accepts ONLY "live" | "simulated" (docs default: "live").
// "test" was never a valid value, so the old `?? "test"` default put an
// invalid literal on every authorize URL. Normalize instead of forwarding:
// anything that is not exactly "simulated" resolves to "live". Safe because
// connectUrl() is unreachable unless BOTH credentials are set
// (server.ts:1261 returns {configured:false} first), so an unset
// SMARTCAR_MODE can only apply on a deployment deliberately given live keys.
private mode = process.env.SMARTCAR_MODE === "simulated" ? "simulated" : "live";
private scope = "read_vehicle_info read_battery read_charge";
private unit = "imperial";
get configured(): boolean {
return !!(this.clientId && this.clientSecret);
}
private basicAuth(): string {
return "Basic " + Buffer.from(`${this.clientId}:${this.clientSecret}`).toString("base64");
}
connectUrl(redirectUri: string, state: string): string {
const u = new URL("https://connect.smartcar.com/oauth/authorize");
u.searchParams.set("response_type", "code");
u.searchParams.set("client_id", this.clientId);
u.searchParams.set("scope", this.scope);
u.searchParams.set("redirect_uri", redirectUri);
u.searchParams.set("state", state);
u.searchParams.set("mode", this.mode);
return u.toString();
}
private async token(params: Record<string, string>): Promise<OAuthTokens> {
const r = await fetch("https://auth.smartcar.com/oauth/token", {
method: "POST",
headers: { authorization: this.basicAuth(), "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(params),
});
const j: any = await r.json();
if (!r.ok || !j.access_token) throw new Error(`smartcar token failed: ${r.status} ${JSON.stringify(j).slice(0, 200)}`);
return {
accessToken: j.access_token,
refreshToken: j.refresh_token,
expiresAt: Date.now() + (Number(j.expires_in ?? 7200) * 1000),
};
}
exchangeCode(code: string, redirectUri: string): Promise<OAuthTokens> {
return this.token({ grant_type: "authorization_code", code, redirect_uri: redirectUri });
}
refresh(refreshToken: string): Promise<OAuthTokens> {
return this.token({ grant_type: "refresh_token", refresh_token: refreshToken });
}
private async api(accessToken: string, path: string): Promise<any> {
const r = await fetch(`https://api.smartcar.com/v2.0${path}`, {
headers: { authorization: `Bearer ${accessToken}`, "sc-unit-system": this.unit },
});
const j: any = await r.json().catch(() => ({}));
if (!r.ok) throw new Error(`smartcar api ${path} failed: ${r.status} ${JSON.stringify(j).slice(0, 160)}`);
return j;
}
async listVehicles(accessToken: string): Promise<NormalizedVehicle[]> {
const list = await this.api(accessToken, "/vehicles");
const ids: string[] = list.vehicles ?? [];
const out: NormalizedVehicle[] = [];
for (const id of ids) {
try {
const a = await this.api(accessToken, `/vehicles/${id}`);
out.push({ id, make: a.make ?? "Vehicle", model: a.model ?? "", year: a.year ?? null });
} catch {
out.push({ id, make: "Vehicle", model: "", year: null });
}
}
return out;
}
async vehicleCharge(accessToken: string, vehicleId: string): Promise<VehicleCharge> {
const [bat, chg] = await Promise.all([
this.api(accessToken, `/vehicles/${vehicleId}/battery`).catch(() => ({})),
this.api(accessToken, `/vehicles/${vehicleId}/charge`).catch(() => ({})),
]);
return normalizeCharge(bat, chg, this.unit);
}
}
// Pure normalizer — unit-tested without any network.
export function normalizeCharge(bat: any, chg: any, unitSystem = "imperial"): VehicleCharge {
const state = chg?.state ?? null;
return {
batteryPercent: pct(bat?.percentRemaining),
rangeMiles: miles(bat?.range, unitSystem),
isCharging: state == null ? null : state === "CHARGING",
chargeState: state,
pluggedIn: typeof chg?.isPluggedIn === "boolean" ? chg.isPluggedIn : null,
updatedAt: new Date().toISOString(),
};
}