/**
* Questmate Custom Component - Tesla Remote Control (Questscript V2)
*
* @UseApp {TESLA}
*
* License:
* Permission is hereby granted, free of charge, to any person obtaining a copy of this software
* and associated documentation files (the βSoftwareβ), to deal in the Software without restriction,
* including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense,
* and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so.
*
* THE SOFTWARE IS PROVIDED βAS ISβ, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
* LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
* IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
* SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* Changelog:
* v0.1 Initial version
* v0.2 Added retry logic for vehicle wakeup
* v0.3 Use refresh token to obtain access token
* v1.0 Updated to use Questscript v2
* v1.1 Added support for more info & controls
* v1.2 Remove refresh token from code in favor of newly added account linking
* v1.3 Added support for chargeport controls
* v1.4 Switched to official Tesla API, add support for app invite request
* v1.5 Changed charge to max use percentage instead of max endpoint
* v1.6 Charge to 85% instead of 80% when turning of charge to max
*
* This is not an official Tesla app. Use at your own risk.
* We take no responsibility for any loss or damage to your Tesla, other property, or lives.
*/
enableDebugMode();
// Tesla Client
class TeslaApiClient {
async request(
path,
method = "GET",
body = undefined,
checkResponseSuccess = (response) => {
return true;
},
retry = { maxRetries: 0, delay: 0 }
) {
return await retryOperation(
async () => {
console.log("Telsa HTTP Request", path, method, body, retry);
const response = await fetch(
`https://fleet-api.prd.na.vn.cloud.tesla.com/${path}`,
{
method,
headers: {
"Content-Type": body ? "application/json" : undefined,
},
body: body ? JSON.stringify(body) : undefined,
}
);
console.log(response.status);
console.log(await response.text());
const responseJson = await response.json();
console.log(JSON.stringify(responseJson));
const responseSuccess = await checkResponseSuccess(responseJson);
if (!responseSuccess) {
throw new Error("Response validation failed.");
}
return responseJson;
},
retry.delay,
retry.maxRetries
);
}
}
const teslaApiClient = new TeslaApiClient();
// Define retry helpers (https://stackoverflow.com/a/44577075/1067078)
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
const retryOperation = (operation, delay, maxRetries) =>
new Promise((resolve, reject) => {
return operation()
.then(resolve)
.catch((reason) => {
if (maxRetries > 0) {
return wait(delay)
.then(retryOperation.bind(null, operation, delay, maxRetries - 1))
.then(resolve)
.catch(reject);
}
return reject(reason);
});
});
// Ensure car is awake
const ensureCarAwake = async (vehicleId) => {
console.log("Waking up vehicle...");
await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/wake_up`,
"POST",
undefined,
(response) => {
const isOnline = response.response.state === "online";
if (isOnline) {
console.log("Vehicle is online!");
} else {
console.log("Vehicle is NOT online!");
throw new Error("Vehicle is NOT online!");
}
return isOnline;
},
{
maxRetries: 5,
delay: 1000,
}
);
};
const tempState = {
recentLockedState: null,
recentAirConditioningOnState: null,
};
return defineCustomItem((Questmate) => {
Questmate.registerView("ITEM_CONFIG_VIEW", async ({ useConfigData }) => {
const [vehicleId, setVehicleId] = useConfigData("vehicleId", null);
const [showKilometers, setShowKilometers] = useConfigData(
"showKilometers",
false
);
const [enableLockControls, setEnableLockControls] = useConfigData(
"enableLockControls",
false
);
const [enableChargeInfo, setEnableChargeInfo] = useConfigData(
"enableChargeInfo",
false
);
const [enableChargeControls, setEnableChargeControls] = useConfigData(
"enableChargeControls",
false
);
const [enableChargePortControls, setEnableChargePortControls] =
useConfigData("enableChargePortControls", false);
const [enableAirConditioningControls, setEnableAirConditioningControls] =
useConfigData("enableAirConditioningControls", false);
const [enableHornControls, setEnableHornControls] = useConfigData(
"enableHornControls",
false
);
const [enableLightsControls, setEnableLightsControls] = useConfigData(
"enableLightsControls",
false
);
const [enableTrunkControls, setEnableTrunkControls] = useConfigData(
"enableTrunkControls",
false
);
const [enableAppInviteRequest, setEnableAppInviteRequest] = useConfigData(
"enableAppInviteRequest",
false
);
return {
components: [
{
id: "vehicle",
type: "dropdown",
title: "Vehicle",
value: vehicleId,
optionNoun: "Vehicle",
optionPluralNoun: "Vehicles",
onSelect: setVehicleId,
getOptions: async () =>
(await vehiclesDataSource.retrieve()).map((vehicle) => ({
label: vehicle.display_name,
value: vehicle.id,
})),
},
{
id: "unit",
type: "switch",
title: "Use kilometers",
value: showKilometers,
onSwitch: setShowKilometers,
},
{
id: "lock_controls",
type: "switch",
title: "Lock controls",
value: enableLockControls,
onSwitch: setEnableLockControls,
},
{
id: "charge_info",
type: "switch",
title: "Charge info",
value: enableChargeInfo,
onSwitch: setEnableChargeInfo,
},
{
id: "charge_controls",
type: "switch",
title: "Charge controls",
value: enableChargeControls,
onSwitch: setEnableChargeControls,
},
{
id: "charge_port_controls",
type: "switch",
title: "Charge port controls",
value: enableChargePortControls,
onSwitch: setEnableChargePortControls,
},
{
id: "air_conditioning_controls",
type: "switch",
title: "A/C controls",
value: enableAirConditioningControls,
onSwitch: setEnableAirConditioningControls,
},
{
id: "trunk_controls",
type: "switch",
title: "Trunk controls",
value: enableTrunkControls,
onSwitch: setEnableTrunkControls,
},
{
id: "horn_controls",
type: "switch",
title: "Horn controls",
value: enableHornControls,
onSwitch: setEnableHornControls,
},
{
id: "lights_controls",
type: "switch",
title: "Lights controls",
value: enableLightsControls,
onSwitch: setEnableLightsControls,
},
{
id: "share_link_request",
type: "switch",
title: "Share link request",
value: enableAppInviteRequest,
onSwitch: setEnableAppInviteRequest,
},
],
};
});
Questmate.registerView("ITEM_RUN_VIEW", async ({ useConfigData, useRunData }) => {
const [shareLink, setShareLink] = useRunData("shareLink", null);
const [vehicleId] = useConfigData("vehicleId", null);
const [showKilometers] = useConfigData("showKilometers", false);
const [enableLockControls] = useConfigData("enableLockControls", false);
const [enableChargeInfo] = useConfigData("enableChargeInfo", false);
const [enableChargeControls] = useConfigData("enableChargeControls", false);
const [enableChargePortControls] = useConfigData(
"enableChargePortControls",
false
);
const [enableAirConditioningControls] = useConfigData(
"enableAirConditioningControls",
false
);
const [enableHornControls] = useConfigData("enableHornControls", false);
const [enableLightsControls] = useConfigData("enableLightsControls", false);
const [enableTrunkControls] = useConfigData("enableTrunkControls", false);
const [enableAppInviteRequest] = useConfigData(
"enableAppInviteRequest",
false
);
const { charge_state, vehicle_state, climate_state } =
await vehicleDataSource.retrieve(vehicleId);
const timeToChargeHours = Math.floor(
charge_state.minutes_to_full_charge / 60
);
const timeToChargeMinutes = charge_state.minutes_to_full_charge % 60;
const timeToChargeString = timeToChargeHours
? `${timeToChargeHours}h ${timeToChargeMinutes}m`
: `${timeToChargeMinutes}m`;
const components = [];
if (enableLockControls) {
components.push({
id: "locked",
type: "switch",
title: "π Car locked",
value:
tempState.recentLockedState === null
? vehicle_state.locked
: tempState.recentLockedState,
onSwitch: async (switchOn) => {
if (switchOn) {
await ensureCarAwake(vehicleId);
const { response } = await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/door_lock`,
"POST"
);
if (response.result === true) {
console.log("Locked car");
tempState.recentLockedState = true;
}
} else {
const { response } = await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/door_unlock`,
"POST"
);
if (response.result === true) {
console.log("Unlocked car");
tempState.recentLockedState = false;
}
}
vehicleDataSource.markResultsAsStale();
},
});
}
if (enableChargeInfo) {
components.push(
{
id: "battery_level",
type: "text",
title: "π Battery",
content: `${charge_state.battery_level}%`,
},
{
id: "range",
type: "text",
title: "π£οΈ Range",
content: showKilometers
? `${parseInt(charge_state.battery_range * 1.609344, 10)} km`
: `${parseInt(charge_state.battery_range, 10)} mi`,
},
{
id: "charging_state",
type: "text",
title: "β‘οΈ Charging state",
content: charge_state.charging_state,
},
{
id: "charing_eta",
type: "text",
title: "β±οΈ Charging ETA",
content: timeToChargeString,
}
);
}
if (enableChargeControls) {
components.push({
id: "chargeToMax",
type: "switch",
title: "π― Charge to max",
value: charge_state.charge_limit_soc === 100,
onSwitch: async (switchOn) => {
await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/set_charge_limit`,
"POST",
{
percent: switchOn ? 100 : 85,
},
({ response }) =>
response.result === true || response.reason === "already_set",
{
maxRetries: 5,
delay: 1,
}
);
vehicleDataSource.markResultsAsStale();
},
});
}
if (enableChargePortControls) {
components.push({
id: "openUnlockChargePort",
type: "button",
title: "β½οΈ Charge port",
buttonLabel: "Open/Unlock",
onPress: async () => {
await ensureCarAwake(vehicleId);
await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/charge_port_door_open`,
"POST"
);
vehicleDataSource.markResultsAsStale();
},
});
}
if (enableAirConditioningControls) {
components.push({
id: "ac_control",
type: "switch",
title: "βοΈ Air conditioning",
value:
tempState.recentAirConditioningOnState === null
? climate_state.is_auto_conditioning_on
: tempState.recentAirConditioningOnState,
onSwitch: async (switchOn) => {
await ensureCarAwake(vehicleId);
if (switchOn) {
const { response } = await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/auto_conditioning_start`,
"POST"
);
if (response.result === true) {
console.log("Turned on air conditioning");
tempState.recentAirConditioningOnState = true;
}
} else {
const { response } = await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/auto_conditioning_stop`,
"POST"
);
if (response.result === true) {
console.log("Turned off air conditioning");
tempState.recentAirConditioningOnState = false;
}
}
vehicleDataSource.markResultsAsStale();
},
});
}
if (enableTrunkControls) {
components.push(
{
id: "trunkStatus",
type: "text",
title: "πͺ Trunk status",
content: vehicle_state.rt ? "Open" : "Closed",
},
{
id: "actuateTrunk",
type: "button",
title: "πͺ Trunk",
buttonLabel: "Open/Close",
onPress: async () => {
await ensureCarAwake(vehicleId);
await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/actuate_trunk`,
"POST",
{
which_trunk: "rear",
}
);
vehicleDataSource.markResultsAsStale();
},
}
);
}
if (enableHornControls) {
components.push({
id: "honkHorn",
type: "button",
title: "π― Horn",
buttonLabel: "Honk",
onPress: async () => {
await ensureCarAwake(vehicleId);
await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/honk_horn`,
"POST"
);
},
});
}
if (enableLightsControls) {
components.push({
id: "flashLights",
type: "button",
title: "π¦ Lights",
buttonLabel: "Flash",
onPress: async () => {
await ensureCarAwake(vehicleId);
await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/command/flash_lights`,
"POST"
);
},
});
}
if (enableAppInviteRequest) {
if(!shareLink) {
components.push({
id: "appInviteRequest",
type: "button",
title: "π² Tesla App Invite",
buttonLabel: "Request",
onPress: async () => {
const { response } = await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/invitations`,
"POST"
);
if(response.share_link) {
setShareLink(response.share_link);
}
},
});
} else {
components.push(
{
id: "share_link",
type: "NavigationAction",
labelText: "Open Tesla App",
to: shareLink,
}
)
}
}
return { components };
});
const vehiclesDataSource = Questmate.registerDataSource({
id: "vehicles",
initialData: [],
refreshInterval: 120,
fetcher: () => async () => {
const vehiclesData = await teslaApiClient.request("/api/1/vehicles");
const { response } = vehiclesData;
if (!response) {
return {
error: {
message: "Failed to retrieve vehicles",
details: { response },
},
};
}
return { data: response };
},
});
const vehicleDataSource = Questmate.registerDataSource({
id: "vehicle",
refreshInterval: 10,
aggregate: ({ results, staleResults }) => {
const latestFreshResult = results[results.length - 1];
if (latestFreshResult) {
return latestFreshResult;
} else {
return staleResults[staleResults.length - 1];
}
},
fetcher: () => async (vehicleId) => {
await ensureCarAwake(vehicleId);
const { response } = await teslaApiClient.request(
`/api/1/vehicles/${vehicleId}/vehicle_data`
);
if (!response) {
return {
error: {
message: "Failed to retrieve vehicle",
details: { response },
},
};
}
return { data: response };
},
});
});