/**
* Tesla Fleet API service
* A service for connecting to and interacting with the Tesla Fleet API
* Based on documentation at: https://developer.tesla.com/docs/fleet-api
*/
import axios from 'axios';
import dotenv from 'dotenv';
import * as fs from 'fs';
import * as path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// For ESM __dirname equivalent
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
// Load environment variables from different potential locations
const envPaths = [
path.join(process.cwd(), '.env'),
path.join(__dirname, '../.env'),
path.join(__dirname, '../../.env'),
];
let envLoaded = false;
for (const envPath of envPaths) {
if (fs.existsSync(envPath)) {
console.error(`Loading environment from ${envPath}`);
dotenv.config({ path: envPath });
envLoaded = true;
break;
}
}
if (!envLoaded) {
console.error('Warning: No .env file found. Environment variables must be set manually.');
}
// Paths to keys
const KEYS_DIR = path.join(__dirname, '../keys');
const PRIVATE_KEY_PATH = path.join(KEYS_DIR, 'private-key.pem');
// API constants - choose the appropriate endpoint based on your region
const BASE_URLS = {
'NA': 'https://fleet-api.prd.na.vn.cloud.tesla.com', // North America, Asia-Pacific (excluding China)
'EU': 'https://fleet-api.prd.eu.vn.cloud.tesla.com', // Europe, Middle East, Africa
'CN': 'https://fleet-api.prd.cn.vn.cloud.tesla.cn' // China
};
const BASE_URL = BASE_URLS.NA; // Default to North America
const AUTH_URL = 'https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token';
// Types
export interface Vehicle {
id: string;
vin: string;
display_name: string;
state: string;
vehicle_id: number;
[key: string]: any;
}
export interface VehicleData {
id: string;
vehicle_id: number;
vin: string;
[key: string]: any;
}
// Check registration status
function isAppRegistered(): boolean {
return fs.existsSync(PRIVATE_KEY_PATH);
}
// Tesla API Service class
export class TeslaService {
private accessToken: string | null = null;
private tokenExpiration: number = 0;
private isRegistered: boolean = false;
constructor() {
this.isRegistered = isAppRegistered();
if (!this.isRegistered) {
// We'll use a specific error instead of console.warn
console.error('Warning: Application does not appear to be registered with Tesla API');
console.error('Run "pnpm register" to complete the registration process');
}
}
/**
* Authorize with the Tesla API using the refresh token
* Following the official OAuth flow from Tesla's documentation
*/
private async authorize(): Promise<void> {
try {
// Get credentials from environment
const clientId = process.env.TESLA_CLIENT_ID;
const clientSecret = process.env.TESLA_CLIENT_SECRET;
const refreshToken = process.env.TESLA_REFRESH_TOKEN;
// Validate credentials
if (!clientId) {
throw new Error('TESLA_CLIENT_ID is not set in environment variables');
}
if (!clientSecret) {
throw new Error('TESLA_CLIENT_SECRET is not set in environment variables');
}
if (!refreshToken) {
throw new Error('TESLA_REFRESH_TOKEN is not set in environment variables');
}
// Create form data per Tesla Fleet API docs
const params = new URLSearchParams();
params.append('grant_type', 'refresh_token');
params.append('client_id', clientId);
params.append('refresh_token', refreshToken);
const response = await axios.post(AUTH_URL, params, {
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
}
});
this.accessToken = response.data.access_token;
// Set token expiration (token is valid for response.data.expires_in seconds)
this.tokenExpiration = Date.now() + (response.data.expires_in * 1000);
// Update refresh token if a new one was returned (refresh tokens are single-use)
if (response.data.refresh_token) {
process.env.TESLA_REFRESH_TOKEN = response.data.refresh_token;
// Also update .env file
try {
const envPath = path.join(process.cwd(), '.env');
let envContent = fs.readFileSync(envPath, 'utf8');
envContent = envContent.replace(
/TESLA_REFRESH_TOKEN=.*/,
`TESLA_REFRESH_TOKEN=${response.data.refresh_token}`
);
fs.writeFileSync(envPath, envContent);
} catch {
// Silently fail if .env can't be updated
}
}
} catch (error: any) {
// Simplify error logging to avoid interfering with JSON
const errorDetails = error.response?.data || error.message;
throw new Error(`Failed to authorize with Tesla API: ${JSON.stringify(errorDetails)}`);
}
}
/**
* Get access token, refreshing if necessary
*/
private async getAccessToken(): Promise<string> {
if (!this.accessToken || Date.now() >= this.tokenExpiration) {
await this.authorize();
}
if (!this.accessToken) {
throw new Error('Could not obtain access token');
}
return this.accessToken;
}
/**
* Get list of vehicles
*/
async getVehicles(): Promise<Vehicle[]> {
const token = await this.getAccessToken();
try {
const response = await axios.get(`${BASE_URL}/api/1/vehicles`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
return response.data.response || [];
} catch (error: any) {
// Simplify error handling
throw new Error('Failed to fetch vehicles');
}
}
/**
* Wake up a vehicle
* This is often needed before sending commands to a vehicle that is asleep
*/
async wakeUp(vehicleId: string): Promise<Vehicle> {
const token = await this.getAccessToken();
try {
const response = await axios.post(`${BASE_URL}/api/1/vehicles/${vehicleId}/wake_up`, {}, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
return response.data.response;
} catch (error: any) {
throw new Error(`Failed to wake up vehicle: ${error.message}`);
}
}
/**
* Get vehicle data (live call to vehicle - may wake it).
* Returns full vehicle data including all requested endpoint data.
*/
async getVehicleData(vehicleId: string, includeLocation: boolean = false): Promise<any> {
const token = await this.getAccessToken();
try {
const baseEndpoints = 'drive_state;charge_state;climate_state;vehicle_state;vehicle_config;gui_settings';
let endpoints = baseEndpoints;
let locationFallback = false;
if (includeLocation) {
// Try with location_data first (requires vehicle_location scope)
try {
const locResponse = await axios.get(`${BASE_URL}/api/1/vehicles/${vehicleId}/vehicle_data?endpoints=${encodeURIComponent(`location_data;${baseEndpoints}`)}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
// If it works, use this response directly
const locData = locResponse.data?.response;
if (locData) {
const driveState = locData.drive_state;
const locationData = locData.location_data;
const result: any = { ...locData };
result._debug_fields_present = Object.keys(locData).filter((k: string) => locData[k] != null && typeof locData[k] === 'object');
if (driveState) {
result.latitude = driveState.latitude; result.longitude = driveState.longitude;
result.heading = driveState.heading; result.speed = driveState.speed;
result.shift_state = driveState.shift_state;
result.native_latitude = driveState.native_latitude; result.native_longitude = driveState.native_longitude;
}
if (locationData) {
result.latitude = result.latitude ?? locationData.latitude;
result.longitude = result.longitude ?? locationData.longitude;
result.native_latitude = result.native_latitude ?? locationData.native_latitude;
result.native_longitude = result.native_longitude ?? locationData.native_longitude;
}
return result;
}
} catch (locError: any) {
// 403 = missing vehicle_location scope, fall back to basic endpoints
if (locError.response?.status === 403) {
locationFallback = true;
} else {
throw locError;
}
}
}
const response = await axios.get(`${BASE_URL}/api/1/vehicles/${vehicleId}/vehicle_data?endpoints=${encodeURIComponent(endpoints)}`, {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
});
if (locationFallback) {
// Let the caller know location needs the scope
const data = response.data?.response ?? {};
data._location_scope_missing = true;
}
const data = response.data?.response;
if (!data) {
throw new Error('No vehicle data in response');
}
const driveState = data.drive_state;
const locationData = data.location_data;
const result: any = { ...data };
// Track which data sections the API returned (for debugging)
result._debug_fields_present = Object.keys(data).filter((k: string) =>
data[k] != null && typeof data[k] === 'object'
);
if (driveState) {
result.latitude = driveState.latitude;
result.longitude = driveState.longitude;
result.heading = driveState.heading;
result.speed = driveState.speed;
result.shift_state = driveState.shift_state;
result.native_latitude = driveState.native_latitude;
result.native_longitude = driveState.native_longitude;
}
// location_data is the newer method (firmware 2023.38+)
if (locationData) {
result.latitude = result.latitude ?? locationData.latitude;
result.longitude = result.longitude ?? locationData.longitude;
result.native_latitude = result.native_latitude ?? locationData.native_latitude;
result.native_longitude = result.native_longitude ?? locationData.native_longitude;
}
return result;
} catch (error: any) {
throw new Error(`Failed to get vehicle data: ${error.message}`);
}
}
/**
* Get nearby charging sites for a vehicle.
*/
async getNearbyCharging(vehicleId: string): Promise<any> {
const token = await this.getAccessToken();
try {
const response = await axios.get(
`${BASE_URL}/api/1/vehicles/${vehicleId}/nearby_charging_sites`,
{
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
return response.data.response;
} catch (error: any) {
throw new Error(`Failed to get nearby charging sites: ${error.message}`);
}
}
}
// Create and export default instance
const teslaService = new TeslaService();
export default teslaService;