Skip to content

Commit

Permalink
First Tesla Fleet API implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
fredli74 committed Jan 31, 2024
1 parent 22e8e7d commit b214610
Show file tree
Hide file tree
Showing 7 changed files with 184 additions and 51 deletions.
2 changes: 1 addition & 1 deletion providers/provider-app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 8,7 @@ import { IProvider } from ".";
import { VueConstructor } from "vue";
import { Vue } from "vue-property-decorator";

export type ProviderVuePage = "new" | "config" | "view";
export type ProviderVuePage = "new" | "auth" | "config" | "view";

export interface IProviderApp extends IProvider {
logo: any;
Expand Down
108 changes: 96 additions & 12 deletions providers/tesla/app/tesla.vue
Original file line number Diff line number Diff line change
@@ -1,10 1,18 @@
<template>
<div class="provider.name">
<div v-if="page === 'new'">
<TeslaTokenVue v-if="showTokenForm" @token="newProvider" />
<div v-if="page === 'new' || page === 'auth'">
<v-card-actions v-if="showAuthButton" class="justify-center">
<v-btn
color="primary"
:disabled="loading"
:loading="loading"
@click="authorize"
>Authorize Access<v-icon right>mdi-chevron-right</v-icon></v-btn
>
</v-card-actions>
<div v-else>
<v-card-actions class="justify-center">
<v-btn text small color="primary" @click="showTokenForm = true"
<v-btn text small color="primary" @click="authorize"
>change Tesla account</v-btn
>
</v-card-actions>
Expand All @@ -30,8 38,12 @@
</template>

<script lang="ts">
import { strict as assert } from "assert";
import { Component, Vue, Prop } from "vue-property-decorator";
import apollo from "@app/plugins/apollo";
import eventBus, { BusEvent } from "@app/plugins/event-bus";
import { hashID } from "@shared/utils";
import { TeslaNewListEntry } from "./tesla-helper";
import TeslaTokenVue from "./components/tesla-token.vue";
import TeslaNewVehicleList from "./components/tesla-new-list.vue";
Expand All @@ -42,6 54,12 @@ import provider, {
TeslaToken,
} from "..";
const AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize";
const CLIENT_ID = "45618b860d7c-4186-89f4-2374bc1b1b83";
const REDIRECT_URL = `${window.location.origin}/provider/tesla/auth`;
const SCOPE =
"openid offline_access vehicle_device_data vehicle_cmds vehicle_charging_cmds";
@Component({
components: {
TeslaTokenVue,
Expand All @@ -56,7 74,7 @@ export default class TeslaVue extends Vue {
// REACTIVE PROPERTIES
loading!: boolean;
showTokenForm!: boolean;
showAuthButton!: boolean;
allProviderVehicles!: TeslaNewListEntry[];
get newVehiclesNotConnected() {
return this.allProviderVehicles.filter((f) => f.vehicle_uuid === undefined);
Expand All @@ -70,13 88,53 @@ export default class TeslaVue extends Vue {
// data() hook for undefined values
return {
loading: false,
showTokenForm: false,
showAuthButton: false,
allProviderVehicles: [],
};
}
async mounted() {
await this.loadTeslaVehicles();
if (this.page === "auth") {
this.showAuthButton = true;
this.loading = true;
const params = new URLSearchParams(window.location.search);
const code = params.get("code");
const state = params.get("state");
console.debug(`code: ${code}, state: ${state}`);
if (code === null || state === null || state !== this.authorizeState) {
console.debug(`Invalid code or state: ${state}`);
eventBus.$emit(
BusEvent.AlertWarning,
"Invalid authorization flow, please try again"
);
}
try {
const token = await apollo.providerMutate(provider.name, {
mutation: TeslaProviderMutates.Authorize,
code,
callbackURI: REDIRECT_URL,
});
// rewrite the URL to remove the code and state and change auth to new
window.history.replaceState(
{},
document.title,
window.location.pathname.replace("/auth", "/new")
);
await this.loadTeslaVehicles(token);
} catch (err) {
console.debug(err);
eventBus.$emit(
BusEvent.AlertWarning,
"Unable to verify Tesla Authorization"
);
}
this.loading = false;
} else {
await this.loadTeslaVehicles();
}
}
// ACTIONS
Expand Down Expand Up @@ -108,15 166,10 @@ export default class TeslaVue extends Vue {
this.loading = false;
if (this.allProviderVehicles.length === 0) {
this.showTokenForm = true;
this.showAuthButton = true;
}
}
async newProvider(token: TeslaToken) {
this.showTokenForm = false;
this.loadTeslaVehicles(token);
}
async selectVehicle(vehicle: TeslaNewListEntry) {
this.loading = true;
await apollo.providerMutate(provider.name, {
Expand All @@ -126,6 179,37 @@ export default class TeslaVue extends Vue {
this.loading = false;
this.$router.push("/");
}
get authorizeState() {
assert(apollo.account, "No user ID found");
return hashID(apollo.account.id, `teslaAuthState`);
}
async authorize() {
this.loading = true;
/*
Parameters
Name Required Example Description
response_type Yes code A string, always use the value "code".
client_id Yes abc-123 Partner application client id.
redirect_uri Yes https://example.com/auth/callback Partner application callback url, spec: rfc6749.
scope Yes openid offline_access user_data vehicle_device_data vehicle_cmds vehicle_charging_cmds Space delimited list of scopes, include openid and offline_access to obtain a refresh token.
state Yes db4af3f87... Random value used for validation.
nonce No 7baf90cda... Random value used for replay prevention.
*/
const authUrl = `${AUTHORIZE_URL}?client_id=${encodeURIComponent(
CLIENT_ID
)}&locale=en-US&prompt=login&redirect_uri=${encodeURIComponent(
REDIRECT_URL
)}&response_type=code&scope=${encodeURIComponent(
SCOPE
)}&state=${encodeURIComponent(
this.authorizeState
)}&nonce=${encodeURIComponent(
hashID(new Date().toISOString(), Math.random().toString())
)}`;
window.location.href = authUrl;
}
}
</script>

Expand Down
1 change: 1 addition & 0 deletions providers/tesla/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 29,7 @@ export enum TeslaProviderQueries {
Vehicles = "vehicles",
}
export enum TeslaProviderMutates {
Authorize = "authorize",
RefreshToken = "refreshToken",
NewVehicle = "newVehicle",
}
Expand Down
10 changes: 7 additions & 3 deletions providers/tesla/tesla-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 53,7 @@ enum ChargeControl {
interface TeslaSubject {
teslaID: string;
vehicleUUID: string;
vin: string;
data?: GQLVehicle;
online: boolean;
pollerror: number | undefined;
Expand Down Expand Up @@ -362,7 363,7 @@ export class TeslaAgent extends AbstractAgent {
}
return true;
}
this.adjustInterval(job, 10); // poll more often after an interaction
// this.adjustInterval(job, 10); // poll more often after an interaction
return false;
}

Expand Down Expand Up @@ -506,11 507,13 @@ export class TeslaAgent extends AbstractAgent {
if (input.chargingTo) {
await this.setStatus(subject, "Charging"); // We are charging
insomnia = true; // Can not sleep while charging
this.adjustInterval(job, 10); // Poll more often when charging
// this.adjustInterval(job, 10); // Poll more often when charging
this.adjustInterval(job, config.TESLA_POLL_INTERVAL); // not allowed to poll more than 5 minutes now
} else if (input.isDriving) {
await this.setStatus(subject, "Driving"); // We are driving
insomnia = true; // Can not sleep while driving
this.adjustInterval(job, 10); // Poll more often when driving
// this.adjustInterval(job, 10); // Poll more often when driving
this.adjustInterval(job, config.TESLA_POLL_INTERVAL); // not allowed to poll more than 5 minutes now
} else {
this.adjustInterval(job, config.TESLA_POLL_INTERVAL);
if (data.vehicle_state.is_user_present) {
Expand Down Expand Up @@ -1020,6 1023,7 @@ export class TeslaAgent extends AbstractAgent {
job.state[v.vehicle_uuid] = {
vehicleUUID: v.vehicle_uuid,
teslaID: v.id_s,
vin: v.vin,
online: false,
pollerror: undefined,
pollstate: undefined,
Expand Down
88 changes: 57 additions & 31 deletions providers/tesla/tesla-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 21,62 @@ export class TeslaAPI {
return token.expires_at <= time();
}

public parseTokenResponse(response: any): TeslaToken {
// Parse the token response
if (typeof response.access_token !== "string") {
throw new RestClientError(
`Error parsing Tesla oauth2 v3 token response, missing access_token ${JSON.stringify(
response
)}`,
500
);
}
if (typeof response.refresh_token !== "string") {
throw new RestClientError(
`Error parsing Tesla oauth2 v3 token response, missing refresh_token ${JSON.stringify(
response
)}`,
500
);
}
const expires = Number.parseInt(response.expires_in);
if (!Number.isInteger(expires)) {
throw new RestClientError(
`Error parsing Tesla oauth2 v3 token response, invalid expires_in ${JSON.stringify(
response
)}`,
500
);
}

return {
access_token: response.access_token,
expires_at: time() expires - config.TOKEN_EXPIRATION_WINDOW,
refresh_token: response.refresh_token,
};
}

public async authorize(
code: string,
callbackURI: string
): Promise<TeslaToken> {
try {
log(LogLevel.Trace, `authorize(${code}, ${callbackURI})`);
const authResponse = (await this.authAPI.post("/oauth2/v3/token", {
grant_type: "authorization_code",
client_id: config.TESLA_CLIENT_ID,
client_secret: config.TESLA_CLIENT_SECRET,
code: code,
audience: config.TESLA_API_BASE_URL,
redirect_uri: callbackURI,
})) as any;
return this.parseTokenResponse(authResponse);
} catch (e) {
console.debug(`TeslaAPI.authorize error: ${e}`);
throw e;
}
}

public async renewToken(refresh_token: string): Promise<TeslaToken> {
try {
log(LogLevel.Trace, `renewToken(${refresh_token})`);
Expand All @@ -34,38 90,8 @@ export class TeslaAPI {
client_id: "ownerapi",
refresh_token: refresh_token,
})) as any;
// Parse the token response
if (typeof authResponse.access_token !== "string") {
throw new RestClientError(
`Error parsing Tesla oauth2 v3 token response, missing access_token ${JSON.stringify(
authResponse
)}`,
500
);
}
if (typeof authResponse.refresh_token !== "string") {
throw new RestClientError(
`Error parsing Tesla oauth2 v3 token response, missing refresh_token ${JSON.stringify(
authResponse
)}`,
500
);
}
const expires = Number.parseInt(authResponse.expires_in);
if (!Number.isInteger(expires)) {
throw new RestClientError(
`Error parsing Tesla oauth2 v3 token response, invalid expires_in ${JSON.stringify(
authResponse
)}`,
500
);
}
return this.parseTokenResponse(authResponse);

return {
access_token: authResponse.access_token,
expires_at: time() expires - config.TOKEN_EXPIRATION_WINDOW,
refresh_token: authResponse.refresh_token,
};
} catch (e) {
console.debug(`TeslaAPI.renewToken error: ${e}`);
throw e;
Expand Down
8 changes: 4 additions & 4 deletions providers/tesla/tesla-config.ts
Original file line number Diff line number Diff line change
@@ -1,5 1,5 @@
const config = {
TESLA_API_BASE_URL: `https://owner-api.teslamotors.com/`,
TESLA_API_BASE_URL: `https://fleet-api.prd.eu.vn.cloud.tesla.com`,
TESLA_API_PROXY: undefined,
TESLA_API_HEADERS: {
"User-Agent": `curl/7.64.1`,
Expand All @@ -15,16 15,16 @@ const config = {
// "X-Requested-With": "com.teslamotors.tesla"
},

TESLA_CLIENT_ID: `81527cff06843c8634fdc09e8ac0abefb46ac849f38fe1e431c2ef2106796384`,
TESLA_CLIENT_SECRET: `c7257eb71a564034f9419ee651c7d0e5f7aa6bfbd18bafb5c5c033b093bb2fa3`,
TESLA_CLIENT_ID: `*** INSERT TESLA DEVELOPER APP CREDENTIALS ***`,
TESLA_CLIENT_SECRET: `*** INSERT TESLA DEVELOPER APP CREDENTIALS ***`,
TOKEN_EXPIRATION_WINDOW: 300, // Pre-expiration Tesla API token renewal window

TIME_BEFORE_TIRED: 20 * 60e3, // stay online 20 min after a drive or charge
TIME_BEING_TIRED: 30 * 60e3, // try counting sheep for 30 minutes
TRIP_HVAC_ON_WINDOW: 15 * 60e3, // turn HVAC on 15 minutes before trip start
TRIP_HVAC_ON_DURATION: 20 * 60e3, // turn HVAC off 20 minutes after scheduled trip start

TESLA_POLL_INTERVAL: 120,
TESLA_POLL_INTERVAL: 5 * 60, // 5 minutes minimum on new API restrictions

DEFAULT_MINIMUM_LEVEL: 30,
DEFAULT_MAXIMUM_LEVEL: 90,
Expand Down
18 changes: 18 additions & 0 deletions providers/tesla/tesla-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 15,21 @@ import config from "./tesla-config";
import { DBServiceProvider } from "@server/db-schema";
import { strict as assert } from "assert";

export async function authorize(
db: DBInterface,
code: string,
callbackURI: string
): Promise<TeslaToken> {
try {
log(LogLevel.Trace, `authorize(${code}, ${callbackURI})`);
const newToken = await teslaAPI.authorize(code, callbackURI);
return maintainToken(db, newToken);
} catch (err) {
log(LogLevel.Error, err);
throw new ApolloError("Invalid token", "INVALID_TOKEN");
}
}

// Check token and refresh through direct database update
export async function maintainToken(
db: DBInterface,
Expand Down Expand Up @@ -187,6 202,9 @@ const server: IProviderServer = {
},
mutation: async (data: any, context: IContext) => {
switch (data.mutation) {
case TeslaProviderMutates.Authorize: {
return await authorize(context.db, data.code, data.callbackURI);
}
case TeslaProviderMutates.RefreshToken: {
return await maintainToken(
context.db,
Expand Down

0 comments on commit b214610

Please sign in to comment.