Tesla Remote Control

This component allows you to give granular access to a Tesla as part of a Quest.

It provides a configuration view where you can select the vehicle and define what access you want to provide. This includes the ability to show distances in miles or kilometers, provide control over the vehicle's lock, access charging information and control charging options, manage air conditioning controls, allow the remote operation of the horn and lights, as well as control the trunk.

/**
 * 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 };
    },
  });
});