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