This guide connects your Tesla to the AI assistant you already use, so you can say "is the car locked?", "warm the car up to 70" or "send Whole Foods to my car's navigation" and have it actually happen. It works with Claude, ChatGPT, Gemini and Grok, including Grok in the car.
I built this for my own 2023 Model 3 and tested it on the car with Claude and Grok: status, locks, climate, navigation, horn and lights, and the trunk with phone approval. It uses Tesla's official Fleet API and runs in your own free Cloudflare account, with your own Tesla developer app. There's no subscription and no third-party service in between, and nothing passes through my servers.
Why your AI can't control your Tesla out of the box
Tesla offers the Fleet API, its official way for apps to read and control your car. Using it takes three things an AI assistant can't do by itself: a registered Tesla developer app, a virtual key (a digital key paired to your car, like a phone key), and signed commands. Since 2024, most Teslas ignore any command that isn't cryptographically signed by a key the car has paired.
AI assistants talk to outside services through MCP connectors (Model Context Protocol, a standard way for assistants to use tools). The existing Tesla connectors I found are paid, hosted services that hold access to your car for you, at around $5 to $10 a month.
This guide sets up a small bridge that does it all in your own account: it holds your Tesla app's key, signs every command, and offers your car to your assistants as a connector. Sensitive commands like unlocking only run after you tap Approve on your phone.
What you need
| Requirement | Notes |
|---|---|
| A Tesla and the Tesla app | You need to be the car's owner on your Tesla account. Tested on a 2023 Model 3. Model 3, Model Y and 2021+ Model S and X all use signed commands, which the bridge handles. |
| A Tesla developer account | Free to create on Tesla's developer site, using your normal Tesla account with two-factor sign-in turned on. Tesla charges per use, with a $10 monthly credit that covers normal personal use (see cost). Tesla asks for a card when you create the app. |
| A free Cloudflare account | The bridge runs here. The free plan is plenty for one car. |
| An AI assistant with custom connectors | Claude (any plan, including Free with one custom connector), ChatGPT (Plus, Pro or Business), Gemini (US, 18+, personal Google account) or Grok. Details in Connect your assistant. |
| A way to approve on your phone (optional) | Needed only for sensitive commands (unlock, trunk, frunk, remote start, garage, and a few more). Choose from ntfy, Telegram, Pushover or email in Step 8. Without one, those commands are simply refused. |
Have a password manager open, and do the last steps near your car. You'll choose one passphrase and copy two values from Tesla. Paste them; don't type them. Step 7 pairs the key with your car and needs your phone and key card.
Setup, step by step
Cloudflare and Tesla update their dashboards often. If a button is worded slightly differently from what you see here, look for the closest match.
1Create the bridge in Cloudflare
/*
* Tesla MCP Bridge v0.7.1
* Lets AI assistants (Claude, ChatGPT, Gemini, Grok) read and control your Tesla through Tesla's
* official Fleet API, with phone approval for sensitive commands.
*
* Setup guide: https://fullyautomatedthings.com/guides/control-tesla-with-ai/
*
* Paste all of this code into your Cloudflare Worker's code editor, replacing everything there.
* Required: a KV namespace bound as TESLA_KV; variable TESLA_CLIENT_ID; secrets TESLA_CLIENT_SECRET
* and PROXY_PASSPHRASE (16+ characters). Optional: FLEET_API_BASE (default North America), and an
* approval channel (ntfy, Telegram, Pushover or email: see the guide).
*
* NOT A SECURITY OR SAFETY SYSTEM. Do not rely on it to secure your car or to protect people,
* pets, or property. You are responsible for your own accounts, car, and Tesla API costs.
*
* MIT License
*
* Copyright (c) 2026 Fully Automated Things
*
* 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, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* 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.
*
* Third-party notice: the protocol schema embedded below (SCHEMA) is derived from Tesla's
* vehicle-command protocol definitions (https://github.com/teslamotors/vehicle-command) with
* Teslemetry's extensions (https://github.com/Teslemetry/tesla-protocol), both licensed under the
* Apache License, Version 2.0 (https://www.apache.org/licenses/LICENSE-2.0). The command-signing
* behaviour follows Tesla's published protocol specification and Teslemetry's python-tesla-fleet-api
* (Apache-2.0). Not affiliated with or endorsed by Tesla, Inc.
*/
var __defProp = Object.defineProperty;
var __name = (target, value) => __defProp(target, "name", { value, configurable: true });
// src/keys.js
var KV_KEY = "tesla:private_key";
var SPKI_P256_PREFIX = "3059301306072a8648ce3d020106082a8648ce3d030107034200";
var cache = null;
async function privateKeyPem(env) {
if (env.TESLA_PRIVATE_KEY) return env.TESLA_PRIVATE_KEY;
return env.TESLA_KV ? env.TESLA_KV.get(KV_KEY) : null;
}
__name(privateKeyPem, "privateKeyPem");
async function ensureKey(env) {
if (env.TESLA_PRIVATE_KEY) return "secret";
if (await env.TESLA_KV.get(KV_KEY)) return "existing";
const pair = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const der = new Uint8Array(await crypto.subtle.exportKey("pkcs8", pair.privateKey));
await env.TESLA_KV.put(KV_KEY, toPem(der, "PRIVATE KEY"));
cache = null;
return "created";
}
__name(ensureKey, "ensureKey");
async function publicKeyPem(env) {
const pem = await privateKeyPem(env);
if (!pem) return null;
if (cache?.pem === pem) return cache.publicPem;
const key = await crypto.subtle.importKey("pkcs8", pemToDer(pem), { name: "ECDH", namedCurve: "P-256" }, true, ["deriveBits"]);
const jwk = await crypto.subtle.exportKey("jwk", key);
const raw = new Uint8Array(65);
raw[0] = 4;
raw.set(b64urlToBytes(jwk.x), 1);
raw.set(b64urlToBytes(jwk.y), 33);
const spki = new Uint8Array([...hexToBytes(SPKI_P256_PREFIX), ...raw]);
cache = { pem, publicPem: toPem(spki, "PUBLIC KEY") };
return cache.publicPem;
}
__name(publicKeyPem, "publicKeyPem");
function toPem(der, label) {
let b64 = "";
for (let i = 0; i < der.length; i += 32768) b64 += String.fromCharCode(...der.subarray(i, i + 32768));
b64 = btoa(b64);
return `-----BEGIN ${label}-----
${b64.match(/.{1,64}/g).join("\n")}
-----END ${label}-----
`;
}
__name(toPem, "toPem");
function pemToDer(pem) {
const b64 = pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "");
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
__name(pemToDer, "pemToDer");
function b64urlToBytes(s) {
const b64 = s.replace(/-/g, "+").replace(/_/g, "/") + "===".slice((s.length + 3) % 4);
return Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
}
__name(b64urlToBytes, "b64urlToBytes");
function hexToBytes(hex) {
return Uint8Array.from(hex.match(/../g), (h) => parseInt(h, 16));
}
__name(hexToBytes, "hexToBytes");
// src/slim-schema.js
var SCHEMA = { "messages": { "CarServer.Action": [[2, "vehicleAction", "m", "CarServer.VehicleAction", 2]], "CarServer.ActionStatus": [[1, "result", "e", "CarServer.OperationStatus_E", 0], [2, "result_reason", "m", "CarServer.ResultReason", 2, "resultReason"]], "CarServer.AutoSeatClimateAction": [[1, "carseat", "m", "CarServer.AutoSeatClimateAction.CarSeat", 3]], "CarServer.AutoSeatClimateAction.CarSeat": [[1, "on", "s", 8, 0], [2, "seat_position", "e", "CarServer.AutoSeatClimateAction.AutoSeatPosition_E", 0, "seatPosition"]], "CarServer.AutoStwHeatAction": [[1, "on", "s", 8, 0]], "CarServer.BoomboxAction": [[1, "sound", "s", 13, 0]], "CarServer.ChargeSchedule": [[1, "id", "s", 4, 0], [2, "name", "s", 9, 0], [3, "days_of_week", "s", 5, 0, "daysOfWeek"], [4, "start_enabled", "s", 8, 0, "startEnabled"], [5, "start_time", "s", 5, 0, "startTime"], [6, "end_enabled", "s", 8, 0, "endEnabled"], [7, "end_time", "s", 5, 0, "endTime"], [8, "one_time", "s", 8, 0, "oneTime"], [9, "enabled", "s", 8, 0], [10, "latitude", "s", 2, 0], [11, "longitude", "s", 2, 0]], "CarServer.ChargingSetLimitAction": [[1, "percent", "s", 5, 0]], "CarServer.ChargingStartStopAction": [[2, "start", "m", "CarServer.Void", 2], [3, "start_standard", "m", "CarServer.Void", 2, "startStandard"], [4, "start_max_range", "m", "CarServer.Void", 2, "startMaxRange"], [5, "stop", "m", "CarServer.Void", 2]], "CarServer.DashcamSaveClipAction": [], "CarServer.DrivingClearSpeedLimitPinAction": [[1, "pin", "s", 9, 0]], "CarServer.DrivingSetSpeedLimitAction": [[1, "limit_mph", "s", 1, 0, "limitMph"]], "CarServer.DrivingSpeedLimitAction": [[1, "activate", "s", 8, 0], [2, "pin", "s", 9, 0]], "CarServer.HvacAutoAction": [[1, "power_on", "s", 8, 0, "powerOn"]], "CarServer.HvacBioweaponModeAction": [[1, "on", "s", 8, 0], [2, "manual_override", "s", 8, 0, "manualOverride"]], "CarServer.HvacClimateKeeperAction": [[1, "ClimateKeeperAction", "e", "CarServer.HvacClimateKeeperAction.ClimateKeeperAction_E", 0]], "CarServer.HvacRecirculationAction": [[1, "on", "s", 8, 0]], "CarServer.HvacSeatHeaterActions": [[1, "hvacSeatHeaterAction", "m", "CarServer.HvacSeatHeaterActions.HvacSeatHeaterAction", 3]], "CarServer.HvacSeatHeaterActions.HvacSeatHeaterAction": [[2, "SEAT_HEATER_OFF", "m", "CarServer.Void", 2, "SEATHEATEROFF"], [3, "SEAT_HEATER_LOW", "m", "CarServer.Void", 2, "SEATHEATERLOW"], [4, "SEAT_HEATER_MED", "m", "CarServer.Void", 2, "SEATHEATERMED"], [5, "SEAT_HEATER_HIGH", "m", "CarServer.Void", 2, "SEATHEATERHIGH"], [7, "CAR_SEAT_FRONT_LEFT", "m", "CarServer.Void", 2, "CARSEATFRONTLEFT"], [8, "CAR_SEAT_FRONT_RIGHT", "m", "CarServer.Void", 2, "CARSEATFRONTRIGHT"], [9, "CAR_SEAT_REAR_LEFT", "m", "CarServer.Void", 2, "CARSEATREARLEFT"], [10, "CAR_SEAT_REAR_LEFT_BACK", "m", "CarServer.Void", 2, "CARSEATREARLEFTBACK"], [11, "CAR_SEAT_REAR_CENTER", "m", "CarServer.Void", 2, "CARSEATREARCENTER"], [12, "CAR_SEAT_REAR_RIGHT", "m", "CarServer.Void", 2, "CARSEATREARRIGHT"], [13, "CAR_SEAT_REAR_RIGHT_BACK", "m", "CarServer.Void", 2, "CARSEATREARRIGHTBACK"]], "CarServer.HvacSetPreconditioningMaxAction": [[1, "on", "s", 8, 0], [2, "manual_override", "s", 8, 0, "manualOverride"]], "CarServer.HvacSteeringWheelHeaterAction": [[1, "power_on", "s", 8, 0, "powerOn"]], "CarServer.HvacTemperatureAdjustmentAction": [[6, "driver_temp_celsius", "s", 2, 0, "driverTempCelsius"], [7, "passenger_temp_celsius", "s", 2, 0, "passengerTempCelsius"]], "CarServer.LatLong": [[1, "latitude", "s", 2, 0], [2, "longitude", "s", 2, 0]], "CarServer.MediaNextFavorite": [], "CarServer.MediaNextTrack": [], "CarServer.MediaPlayAction": [], "CarServer.MediaPreviousFavorite": [], "CarServer.MediaPreviousTrack": [], "CarServer.MediaUpdateVolume": [[1, "volume_delta", "s", 17, 2, "volumeDelta"], [3, "volume_absolute_float", "s", 2, 2, "volumeAbsoluteFloat"]], "CarServer.NavigationGpsDestinationRequest": [[1, "lat", "s", 1, 0], [2, "lon", "s", 1, 0], [3, "destination", "s", 9, 0], [4, "order", "e", "CarServer.NavigationGpsDestinationRequest.RemoteNavTripOrder", 0]], "CarServer.NavigationGpsRequest": [[1, "lat", "s", 1, 0], [2, "lon", "s", 1, 0], [3, "order", "e", "CarServer.NavigationGpsRequest.RemoteNavTripOrder", 0]], "CarServer.NavigationRequest": [[1, "destination", "s", 9, 0]], "CarServer.NavigationSuperchargerRequest": [[2, "remote_nav_trip_order", "e", "CarServer.NavigationSuperchargerRequest.RemoteNavTripOrder", 0, "remoteNavTripOrder"]], "CarServer.NavigationWaypointsRequest": [[1, "waypoints", "s", 9, 0]], "CarServer.PreconditionSchedule": [[1, "id", "s", 4, 0], [2, "name", "s", 9, 0], [3, "days_of_week", "s", 5, 0, "daysOfWeek"], [4, "precondition_time", "s", 5, 0, "preconditionTime"], [5, "one_time", "s", 8, 0, "oneTime"], [6, "enabled", "s", 8, 0], [7, "latitude", "s", 2, 0], [8, "longitude", "s", 2, 0]], "CarServer.RemoveChargeScheduleAction": [[1, "id", "s", 4, 0]], "CarServer.RemovePreconditionScheduleAction": [[1, "id", "s", 4, 0]], "CarServer.Response": [[1, "actionStatus", "m", "CarServer.ActionStatus", 2]], "CarServer.ResultReason": [[1, "plain_text", "s", 9, 2, "plainText"]], "CarServer.ScheduledChargingAction": [[1, "enabled", "s", 8, 0], [2, "charging_time", "s", 5, 0, "chargingTime"]], "CarServer.SetCabinOverheatProtectionAction": [[1, "on", "s", 8, 0], [2, "fan_only", "s", 8, 0, "fanOnly"]], "CarServer.SetChargingAmpsAction": [[1, "charging_amps", "s", 5, 0, "chargingAmps"]], "CarServer.SetCopTempAction": [[1, "copActivationTemp", "e", "CarServer.ClimateState.CopActivationTemp", 0]], "CarServer.SetKeepAccessoryPowerModeAction": [[1, "keep_accessory_power_mode", "s", 8, 0, "keepAccessoryPowerMode"]], "CarServer.SetLowPowerModeAction": [[1, "low_power_mode", "s", 8, 0, "lowPowerMode"]], "CarServer.SetVehicleNameAction": [[1, "vehicleName", "s", 9, 0]], "CarServer.StwHeatLevelAction": [[1, "stw_heat_level", "e", "CarServer.StwHeatLevel", 0, "stwHeatLevel"]], "CarServer.VehicleAction": [[5, "chargingSetLimitAction", "m", "CarServer.ChargingSetLimitAction", 2], [6, "chargingStartStopAction", "m", "CarServer.ChargingStartStopAction", 2], [7, "drivingClearSpeedLimitPinAction", "m", "CarServer.DrivingClearSpeedLimitPinAction", 2], [8, "drivingSetSpeedLimitAction", "m", "CarServer.DrivingSetSpeedLimitAction", 2], [9, "drivingSpeedLimitAction", "m", "CarServer.DrivingSpeedLimitAction", 2], [10, "hvacAutoAction", "m", "CarServer.HvacAutoAction", 2], [12, "hvacSetPreconditioningMaxAction", "m", "CarServer.HvacSetPreconditioningMaxAction", 2], [13, "hvacSteeringWheelHeaterAction", "m", "CarServer.HvacSteeringWheelHeaterAction", 2], [14, "hvacTemperatureAdjustmentAction", "m", "CarServer.HvacTemperatureAdjustmentAction", 2], [15, "mediaPlayAction", "m", "CarServer.MediaPlayAction", 2], [16, "mediaUpdateVolume", "m", "CarServer.MediaUpdateVolume", 2], [17, "mediaNextFavorite", "m", "CarServer.MediaNextFavorite", 2], [18, "mediaPreviousFavorite", "m", "CarServer.MediaPreviousFavorite", 2], [19, "mediaNextTrack", "m", "CarServer.MediaNextTrack", 2], [20, "mediaPreviousTrack", "m", "CarServer.MediaPreviousTrack", 2], [21, "navigationRequest", "m", "CarServer.NavigationRequest", 2], [22, "navigationSuperchargerRequest", "m", "CarServer.NavigationSuperchargerRequest", 2], [25, "vehicleControlCancelSoftwareUpdateAction", "m", "CarServer.VehicleControlCancelSoftwareUpdateAction", 2], [26, "vehicleControlFlashLightsAction", "m", "CarServer.VehicleControlFlashLightsAction", 2], [27, "vehicleControlHonkHornAction", "m", "CarServer.VehicleControlHonkHornAction", 2], [28, "vehicleControlResetValetPinAction", "m", "CarServer.VehicleControlResetValetPinAction", 2], [29, "vehicleControlScheduleSoftwareUpdateAction", "m", "CarServer.VehicleControlScheduleSoftwareUpdateAction", 2], [30, "vehicleControlSetSentryModeAction", "m", "CarServer.VehicleControlSetSentryModeAction", 2], [31, "vehicleControlSetValetModeAction", "m", "CarServer.VehicleControlSetValetModeAction", 2], [33, "vehicleControlTriggerHomelinkAction", "m", "CarServer.VehicleControlTriggerHomelinkAction", 2], [34, "vehicleControlWindowAction", "m", "CarServer.VehicleControlWindowAction", 2], [35, "hvacBioweaponModeAction", "m", "CarServer.HvacBioweaponModeAction", 2], [36, "hvacSeatHeaterActions", "m", "CarServer.HvacSeatHeaterActions", 2], [41, "scheduledChargingAction", "m", "CarServer.ScheduledChargingAction", 2], [43, "setChargingAmpsAction", "m", "CarServer.SetChargingAmpsAction", 2], [44, "hvacClimateKeeperAction", "m", "CarServer.HvacClimateKeeperAction", 2], [45, "hvacRecirculationAction", "m", "CarServer.HvacRecirculationAction", 2], [47, "dashcamSaveClipAction", "m", "CarServer.DashcamSaveClipAction", 2], [48, "autoSeatClimateAction", "m", "CarServer.AutoSeatClimateAction", 2], [50, "setCabinOverheatProtectionAction", "m", "CarServer.SetCabinOverheatProtectionAction", 2], [53, "navigationGpsRequest", "m", "CarServer.NavigationGpsRequest", 2], [54, "setVehicleNameAction", "m", "CarServer.SetVehicleNameAction", 2], [64, "boomboxAction", "m", "CarServer.BoomboxAction", 2], [65, "guestModeAction", "m", "CarServer.VehicleState.GuestMode", 2], [66, "setCopTempAction", "m", "CarServer.SetCopTempAction", 2], [70, "autoStwHeatAction", "m", "CarServer.AutoStwHeatAction", 2], [71, "stwHeatLevelAction", "m", "CarServer.StwHeatLevelAction", 2], [77, "vehicleControlSetPinToDriveAction", "m", "CarServer.VehicleControlSetPinToDriveAction", 2], [78, "vehicleControlResetPinToDriveAction", "m", "CarServer.VehicleControlResetPinToDriveAction", 2], [90, "navigationWaypointsRequest", "m", "CarServer.NavigationWaypointsRequest", 2], [97, "addChargeScheduleAction", "m", "CarServer.ChargeSchedule", 2], [98, "removeChargeScheduleAction", "m", "CarServer.RemoveChargeScheduleAction", 2], [99, "addPreconditionScheduleAction", "m", "CarServer.PreconditionSchedule", 2], [100, "removePreconditionScheduleAction", "m", "CarServer.RemovePreconditionScheduleAction", 2], [106, "navigationGpsDestinationRequest", "m", "CarServer.NavigationGpsDestinationRequest", 2], [130, "setLowPowerModeAction", "m", "CarServer.SetLowPowerModeAction", 2], [138, "setKeepAccessoryPowerModeAction", "m", "CarServer.SetKeepAccessoryPowerModeAction", 2]], "CarServer.VehicleControlCancelSoftwareUpdateAction": [], "CarServer.VehicleControlFlashLightsAction": [], "CarServer.VehicleControlHonkHornAction": [], "CarServer.VehicleControlResetPinToDriveAction": [], "CarServer.VehicleControlResetValetPinAction": [], "CarServer.VehicleControlScheduleSoftwareUpdateAction": [[1, "offset_sec", "s", 5, 0, "offsetSec"]], "CarServer.VehicleControlSetPinToDriveAction": [[1, "on", "s", 8, 0], [2, "password", "s", 9, 0]], "CarServer.VehicleControlSetSentryModeAction": [[1, "on", "s", 8, 0]], "CarServer.VehicleControlSetValetModeAction": [[1, "on", "s", 8, 0], [2, "password", "s", 9, 0]], "CarServer.VehicleControlTriggerHomelinkAction": [[1, "location", "m", "CarServer.LatLong", 2]], "CarServer.VehicleControlWindowAction": [[3, "vent", "m", "CarServer.Void", 2], [4, "close", "m", "CarServer.Void", 2]], "CarServer.VehicleState.GuestMode": [[1, "GuestModeActive", "s", 8, 0]], "CarServer.Void": [], "Errors.NominalError": [[1, "genericError", "e", "Errors.GenericError_E", 0], [8, "keyNotFoundContext", "m", "Errors.KeyNotFoundContext", 2]], "Signatures.AES_GCM_Response_Signature_Data": [[1, "nonce", "s", 12, 0], [2, "counter", "s", 13, 0], [3, "tag", "s", 12, 0]], "Signatures.HMAC_Personalized_Signature_Data": [[1, "epoch", "s", 12, 0], [2, "counter", "s", 13, 0], [3, "expires_at", "s", 7, 0, "expiresAt"], [4, "tag", "s", 12, 0]], "Signatures.HMAC_Signature_Data": [[1, "tag", "s", 12, 0]], "Signatures.KeyIdentity": [[1, "public_key", "s", 12, 2, "publicKey"]], "Signatures.SessionInfo": [[1, "counter", "s", 13, 0], [2, "publicKey", "s", 12, 0], [3, "epoch", "s", 12, 0], [4, "clock_time", "s", 7, 0, "clockTime"], [5, "status", "e", "Signatures.Session_Info_Status", 0]], "Signatures.SignatureData": [[1, "signer_identity", "m", "Signatures.KeyIdentity", 2, "signerIdentity"], [6, "session_info_tag", "m", "Signatures.HMAC_Signature_Data", 2, "sessionInfoTag"], [8, "HMAC_Personalized_data", "m", "Signatures.HMAC_Personalized_Signature_Data", 2, "HMACPersonalizedData"], [9, "AES_GCM_Response_data", "m", "Signatures.AES_GCM_Response_Signature_Data", 2, "AESGCMResponseData"]], "UniversalMessage.Destination": [[1, "domain", "e", "UniversalMessage.Domain", 2], [2, "routing_address", "s", 12, 2, "routingAddress"]], "UniversalMessage.MessageStatus": [[1, "operation_status", "e", "UniversalMessage.OperationStatus_E", 0, "operationStatus"], [2, "signed_message_fault", "e", "UniversalMessage.MessageFault_E", 0, "signedMessageFault"]], "UniversalMessage.RoutableMessage": [[6, "to_destination", "m", "UniversalMessage.Destination", 2, "toDestination"], [7, "from_destination", "m", "UniversalMessage.Destination", 2, "fromDestination"], [10, "protobuf_message_as_bytes", "s", 12, 2, "protobufMessageAsBytes"], [14, "session_info_request", "m", "UniversalMessage.SessionInfoRequest", 2, "sessionInfoRequest"], [15, "session_info", "s", 12, 2, "sessionInfo"], [13, "signature_data", "m", "Signatures.SignatureData", 2, "signatureData"], [12, "signedMessageStatus", "m", "UniversalMessage.MessageStatus", 2], [50, "request_uuid", "s", 12, 0, "requestUuid"], [51, "uuid", "s", 12, 0], [52, "flags", "s", 13, 0]], "UniversalMessage.SessionInfoRequest": [[1, "public_key", "s", 12, 0, "publicKey"]], "VCSEC.ClosureMoveRequest": [[5, "rearTrunk", "e", "VCSEC.ClosureMoveType_E", 0], [6, "frontTrunk", "e", "VCSEC.ClosureMoveType_E", 0], [7, "chargePort", "e", "VCSEC.ClosureMoveType_E", 0]], "VCSEC.CommandStatus": [[1, "operationStatus", "e", "VCSEC.OperationStatus_E", 0]], "VCSEC.FromVCSECMessage": [[4, "commandStatus", "m", "VCSEC.CommandStatus", 2], [46, "nominalError", "m", "Errors.NominalError", 2]], "VCSEC.UnsignedMessage": [[2, "RKEAction", "e", "VCSEC.RKEAction_E", 2], [4, "closureMoveRequest", "m", "VCSEC.ClosureMoveRequest", 2]] }, "enums": { "CarServer.AutoSeatClimateAction.AutoSeatPosition_E": { "AutoSeatPosition_Unknown": 0, "AutoSeatPosition_FrontLeft": 1, "AutoSeatPosition_FrontRight": 2 }, "CarServer.ClimateState.CopActivationTemp": { "CopActivationTempUnspecified": 0, "CopActivationTempLow": 1, "CopActivationTempMedium": 2, "CopActivationTempHigh": 3 }, "CarServer.HvacClimateKeeperAction.ClimateKeeperAction_E": { "ClimateKeeperAction_Off": 0, "ClimateKeeperAction_On": 1, "ClimateKeeperAction_Dog": 2, "ClimateKeeperAction_Camp": 3 }, "CarServer.NavigationGpsDestinationRequest.RemoteNavTripOrder": { "REMOTE_NAV_TRIP_ORDER_UNKNOWN": 0, "REMOTE_NAV_TRIP_ORDER_REPLACE": 1, "REMOTE_NAV_TRIP_ORDER_PREPEND": 2, "REMOTE_NAV_TRIP_ORDER_APPEND": 3 }, "CarServer.NavigationGpsRequest.RemoteNavTripOrder": { "REMOTE_NAV_TRIP_ORDER_UNKNOWN": 0, "REMOTE_NAV_TRIP_ORDER_REPLACE": 1, "REMOTE_NAV_TRIP_ORDER_PREPEND": 2, "REMOTE_NAV_TRIP_ORDER_APPEND": 3 }, "CarServer.NavigationSuperchargerRequest.RemoteNavTripOrder": { "REMOTE_NAV_TRIP_ORDER_UNKNOWN": 0, "REMOTE_NAV_TRIP_ORDER_REPLACE": 1, "REMOTE_NAV_TRIP_ORDER_PREPEND": 2, "REMOTE_NAV_TRIP_ORDER_APPEND": 3 }, "CarServer.OperationStatus_E": { "OPERATIONSTATUS_OK": 0, "OPERATIONSTATUS_ERROR": 1 }, "CarServer.StwHeatLevel": { "StwHeatLevel_Unknown": 0, "StwHeatLevel_Off": 1, "StwHeatLevel_Low": 2, "StwHeatLevel_High": 3 }, "Errors.GenericError_E": { "GENERICERROR_NONE": 0, "GENERICERROR_UNKNOWN": 1, "GENERICERROR_CLOSURES_OPEN": 2, "GENERICERROR_ALREADY_ON": 3, "GENERICERROR_DISABLED_FOR_USER_COMMAND": 4, "GENERICERROR_VEHICLE_NOT_IN_PARK": 5, "GENERICERROR_UNAUTHORIZED": 6, "GENERICERROR_NOT_ALLOWED_OVER_TRANSPORT": 7, "GENERICERROR_KEY_NOT_FOUND": 8, "GENERICERROR_NOT_SUPPORTED": 9 }, "Signatures.Session_Info_Status": { "SESSION_INFO_STATUS_OK": 0, "SESSION_INFO_STATUS_KEY_NOT_ON_WHITELIST": 1, "SESSION_INFO_STATUS_INVALID_HANDLE": 2 }, "UniversalMessage.Domain": { "DOMAIN_BROADCAST": 0, "DOMAIN_VEHICLE_SECURITY": 2, "DOMAIN_INFOTAINMENT": 3, "DOMAIN_AUTOPILOT": 4, "DOMAIN_AUTHD": 5, "DOMAIN_ENERGY_DEVICE": 7, "DOMAIN_ENERGY_DEVICE_AUTH": 8 }, "UniversalMessage.MessageFault_E": { "MESSAGEFAULT_ERROR_NONE": 0, "MESSAGEFAULT_ERROR_BUSY": 1, "MESSAGEFAULT_ERROR_TIMEOUT": 2, "MESSAGEFAULT_ERROR_UNKNOWN_KEY_ID": 3, "MESSAGEFAULT_ERROR_INACTIVE_KEY": 4, "MESSAGEFAULT_ERROR_INVALID_SIGNATURE": 5, "MESSAGEFAULT_ERROR_INVALID_TOKEN_OR_COUNTER": 6, "MESSAGEFAULT_ERROR_INSUFFICIENT_PRIVILEGES": 7, "MESSAGEFAULT_ERROR_INVALID_DOMAINS": 8, "MESSAGEFAULT_ERROR_INVALID_COMMAND": 9, "MESSAGEFAULT_ERROR_DECODING": 10, "MESSAGEFAULT_ERROR_INTERNAL": 11, "MESSAGEFAULT_ERROR_WRONG_PERSONALIZATION": 12, "MESSAGEFAULT_ERROR_BAD_PARAMETER": 13, "MESSAGEFAULT_ERROR_KEYCHAIN_IS_FULL": 14, "MESSAGEFAULT_ERROR_INCORRECT_EPOCH": 15, "MESSAGEFAULT_ERROR_IV_INCORRECT_LENGTH": 16, "MESSAGEFAULT_ERROR_TIME_EXPIRED": 17, "MESSAGEFAULT_ERROR_NOT_PROVISIONED_WITH_IDENTITY": 18, "MESSAGEFAULT_ERROR_COULD_NOT_HASH_METADATA": 19, "MESSAGEFAULT_ERROR_TIME_TO_LIVE_TOO_LONG": 20, "MESSAGEFAULT_ERROR_REMOTE_ACCESS_DISABLED": 21, "MESSAGEFAULT_ERROR_REMOTE_SERVICE_ACCESS_DISABLED": 22, "MESSAGEFAULT_ERROR_COMMAND_REQUIRES_ACCOUNT_CREDENTIALS": 23, "MESSAGEFAULT_ERROR_REQUEST_MTU_EXCEEDED": 24, "MESSAGEFAULT_ERROR_RESPONSE_MTU_EXCEEDED": 25, "MESSAGEFAULT_ERROR_REPEATED_COUNTER": 26, "MESSAGEFAULT_ERROR_INVALID_KEY_HANDLE": 27, "MESSAGEFAULT_ERROR_REQUIRES_RESPONSE_ENCRYPTION": 28, "MESSAGEFAULT_ERROR_COMMAND_REQUIRES_PHYSICAL_PROXIMITY": 29 }, "UniversalMessage.OperationStatus_E": { "OPERATIONSTATUS_OK": 0, "OPERATIONSTATUS_WAIT": 1, "OPERATIONSTATUS_ERROR": 2 }, "VCSEC.ClosureMoveType_E": { "CLOSURE_MOVE_TYPE_NONE": 0, "CLOSURE_MOVE_TYPE_MOVE": 1, "CLOSURE_MOVE_TYPE_STOP": 2, "CLOSURE_MOVE_TYPE_OPEN": 3, "CLOSURE_MOVE_TYPE_CLOSE": 4, "CLOSURE_MOVE_TYPE_OVERRIDE_OPEN": 5, "CLOSURE_MOVE_TYPE_OVERRIDE_CLOSE": 6 }, "VCSEC.OperationStatus_E": { "OPERATIONSTATUS_OK": 0, "OPERATIONSTATUS_WAIT": 1, "OPERATIONSTATUS_ERROR": 2 }, "VCSEC.RKEAction_E": { "RKE_ACTION_UNLOCK": 0, "RKE_ACTION_LOCK": 1, "RKE_ACTION_OPEN_TRUNK": 2, "RKE_ACTION_OPEN_FRUNK": 3, "RKE_ACTION_OPEN_CHARGE_PORT": 4, "RKE_ACTION_CLOSE_CHARGE_PORT": 5, "RKE_ACTION_CANCEL_EXTERNAL_AUTHENTICATE": 6, "RKE_ACTION_UNKNOWN": 19, "RKE_ACTION_REMOTE_DRIVE": 20, "RKE_ACTION_AUTO_SECURE_VEHICLE": 29, "RKE_ACTION_WAKE_VEHICLE": 30, "RKE_ACTION_UNLOCK_AND_REMOTE_DRIVE": 31, "RKE_ACTION_UNLOCK_UNRESTRICTED_CLOSURES": 32 } } };
// src/pb.js
var REPEATED = 1;
var ALWAYS = 2;
var PACKED = 4;
var T = { DOUBLE: 1, FLOAT: 2, INT64: 3, UINT64: 4, INT32: 5, FIXED64: 6, FIXED32: 7, BOOL: 8, STRING: 9, BYTES: 12, UINT32: 13, SFIXED32: 15, SFIXED64: 16, SINT32: 17, SINT64: 18 };
var enc = new TextEncoder();
var dec = new TextDecoder();
var index = {};
function fieldsOf(typeName) {
if (index[typeName]) return index[typeName];
const raw = SCHEMA.messages[typeName];
if (!raw) throw new Error(`pb: unknown message type ${typeName}`);
const list = raw.map(([num2, name, kind, type, flags, local]) => ({ num: num2, name, kind, type, flags, local }));
const byKey = /* @__PURE__ */ new Map();
const byNum = /* @__PURE__ */ new Map();
for (const f of list) {
byKey.set(f.name, f);
if (f.local) byKey.set(f.local, f);
byNum.set(f.num, f);
}
return index[typeName] = { list, byKey, byNum };
}
__name(fieldsOf, "fieldsOf");
function enumNumber(enumName, v) {
if (typeof v === "number") return v;
const map = SCHEMA.enums[enumName];
if (!map || !(v in map)) throw new Error(`pb: ${enumName} has no value "${v}"`);
return map[v];
}
__name(enumNumber, "enumNumber");
var Writer = class {
static {
__name(this, "Writer");
}
constructor() {
this.chunks = [];
this.len = 0;
}
push(u8) {
this.chunks.push(u8);
this.len += u8.length;
}
varint(v) {
let n = typeof v === "bigint" ? BigInt.asUintN(64, v) : v < 0 ? BigInt.asUintN(64, BigInt(v)) : v;
const out = [];
if (typeof n === "bigint") {
while (n > 0x7fn) {
out.push(Number(n & 0x7fn) | 128);
n >>= 7n;
}
out.push(Number(n));
} else {
while (n > 127) {
out.push(n & 127 | 128);
n = Math.floor(n / 128);
}
out.push(n);
}
this.push(Uint8Array.from(out));
}
tag(num2, wire) {
this.varint(num2 * 8 + wire);
}
fixed32(v, float) {
const b = new Uint8Array(4);
const dv = new DataView(b.buffer);
if (float) dv.setFloat32(0, v, true);
else if (v < 0) dv.setInt32(0, v, true);
else dv.setUint32(0, v, true);
this.push(b);
}
fixed64(v, double) {
const b = new Uint8Array(8);
const dv = new DataView(b.buffer);
if (double) dv.setFloat64(0, v, true);
else dv.setBigUint64(0, BigInt.asUintN(64, BigInt(v)), true);
this.push(b);
}
bytes(u8) {
this.varint(u8.length);
this.push(u8);
}
finish() {
const out = new Uint8Array(this.len);
let o = 0;
for (const c of this.chunks) {
out.set(c, o);
o += c.length;
}
return out;
}
};
var zigzag32 = /* @__PURE__ */ __name((n) => (n << 1 ^ n >> 31) >>> 0, "zigzag32");
var zigzag64 = /* @__PURE__ */ __name((n) => {
const b = BigInt(n);
return BigInt.asUintN(64, b << 1n ^ b >> 63n);
}, "zigzag64");
function wireOf(f) {
if (f.kind === "m") return 2;
if (f.kind === "e") return 0;
switch (f.type) {
case T.DOUBLE:
case T.FIXED64:
case T.SFIXED64:
return 1;
case T.FLOAT:
case T.FIXED32:
case T.SFIXED32:
return 5;
case T.STRING:
case T.BYTES:
return 2;
default:
return 0;
}
}
__name(wireOf, "wireOf");
function writeScalar(w, type, v) {
switch (type) {
case T.DOUBLE:
return w.fixed64(v, true);
case T.FLOAT:
return w.fixed32(v, true);
case T.FIXED32:
case T.SFIXED32:
return w.fixed32(v, false);
case T.FIXED64:
case T.SFIXED64:
return w.fixed64(v, false);
case T.BOOL:
return w.varint(v ? 1 : 0);
case T.STRING:
return w.bytes(enc.encode(v));
case T.BYTES:
return w.bytes(v);
case T.SINT32:
return w.varint(zigzag32(v));
case T.SINT64:
return w.varint(zigzag64(v));
case T.INT32:
return w.varint(v | 0);
case T.UINT32:
return w.varint(v >>> 0);
default:
return w.varint(typeof v === "bigint" ? v : BigInt(v));
}
}
__name(writeScalar, "writeScalar");
function isDefault(f, v) {
if (v === void 0 || v === null) return true;
if (f.kind === "e") return v === 0;
if (f.type === T.STRING) return v === "";
if (f.type === T.BYTES) return v.length === 0;
if (f.type === T.BOOL) return v === false;
return v === 0 || v === 0n;
}
__name(isDefault, "isDefault");
function writeValue(w, f, v) {
if (f.kind === "m") return w.bytes(encode(f.type, v ?? {}));
if (f.kind === "e") return w.varint(v);
return writeScalar(w, f.type, v);
}
__name(writeValue, "writeValue");
function encode(typeName, obj) {
const { list, byKey } = fieldsOf(typeName);
const values = /* @__PURE__ */ new Map();
for (const [key, raw] of Object.entries(obj || {})) {
const f = byKey.get(key);
if (!f) throw new Error(`pb: ${typeName} has no field "${key}"`);
if (raw === void 0) continue;
values.set(f, f.kind === "e" ? Array.isArray(raw) ? raw.map((x) => enumNumber(f.type, x)) : enumNumber(f.type, raw) : raw);
}
const w = new Writer();
for (const f of list) {
if (!values.has(f)) continue;
const v = values.get(f);
if (f.flags & REPEATED) {
if (!v.length) continue;
if (f.flags & PACKED) {
const inner = new Writer();
for (const x of v) writeValue(inner, f, x);
w.tag(f.num, 2);
w.bytes(inner.finish());
} else {
for (const x of v) {
w.tag(f.num, wireOf(f));
writeValue(w, f, x);
}
}
continue;
}
if (!(f.flags & ALWAYS) && isDefault(f, v)) continue;
w.tag(f.num, wireOf(f));
writeValue(w, f, v);
}
return w.finish();
}
__name(encode, "encode");
var Reader = class {
static {
__name(this, "Reader");
}
constructor(u8) {
this.b = u8;
this.p = 0;
this.dv = new DataView(u8.buffer, u8.byteOffset, u8.byteLength);
}
eof() {
return this.p >= this.b.length;
}
varint() {
let r = 0n;
let s = 0n;
for (let i = 0; i < 10; i++) {
if (this.p >= this.b.length) throw new Error("pb: truncated varint");
const byte = this.b[this.p++];
r |= BigInt(byte & 127) << s;
if (byte < 128) return r;
s += 7n;
}
throw new Error("pb: varint too long");
}
take(n) {
if (this.p + n > this.b.length) throw new Error("pb: truncated field");
const out = this.b.subarray(this.p, this.p + n);
this.p += n;
return out;
}
fixed32() {
const v = this.dv.getUint32(this.p, true);
this.p += 4;
return v;
}
fixed64() {
const v = this.dv.getBigUint64(this.p, true);
this.p += 8;
return v;
}
skip(wire) {
if (wire === 0) this.varint();
else if (wire === 1) this.take(8);
else if (wire === 2) this.take(Number(this.varint()));
else if (wire === 5) this.take(4);
else throw new Error(`pb: unsupported wire type ${wire}`);
}
};
function readScalar(r, type, wire) {
switch (type) {
case T.DOUBLE: {
const b = r.take(8);
return new DataView(b.buffer, b.byteOffset, 8).getFloat64(0, true);
}
case T.FLOAT: {
const b = r.take(4);
return new DataView(b.buffer, b.byteOffset, 4).getFloat32(0, true);
}
case T.FIXED32:
return r.fixed32();
case T.SFIXED32:
return r.fixed32() | 0;
case T.FIXED64:
return r.fixed64();
case T.SFIXED64:
return BigInt.asIntN(64, r.fixed64());
case T.BOOL:
return r.varint() !== 0n;
case T.STRING:
return dec.decode(r.take(Number(r.varint())));
case T.BYTES:
return r.take(Number(r.varint())).slice();
case T.SINT32: {
const n = r.varint();
return Number(n >> 1n ^ -(n & 1n));
}
case T.SINT64: {
const n = r.varint();
return n >> 1n ^ -(n & 1n);
}
case T.INT32:
return Number(BigInt.asIntN(32, r.varint()));
case T.UINT32:
return Number(BigInt.asUintN(32, r.varint()));
case T.INT64:
return BigInt.asIntN(64, r.varint());
case T.UINT64:
return r.varint();
default:
throw new Error(`pb: unsupported scalar type ${type} (wire ${wire})`);
}
}
__name(readScalar, "readScalar");
function readValue(r, f, wire) {
if (f.kind === "m") return decode(f.type, r.take(Number(r.varint())));
if (f.kind === "e") return Number(BigInt.asIntN(32, r.varint()));
return readScalar(r, f.type, wire);
}
__name(readValue, "readValue");
function decode(typeName, bytes) {
const { byNum } = fieldsOf(typeName);
const r = new Reader(bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes));
const out = {};
while (!r.eof()) {
const key = r.varint();
const num2 = Number(key >> 3n);
const wire = Number(key & 7n);
const f = byNum.get(num2);
if (!f) {
r.skip(wire);
continue;
}
if (f.flags & REPEATED) {
const arr = out[f.name] ||= [];
if (wire === 2 && f.kind !== "m" && !(f.kind === "s" && (f.type === T.STRING || f.type === T.BYTES))) {
const sub = new Reader(r.take(Number(r.varint())));
while (!sub.eof()) arr.push(readValue(sub, f, wireOf(f)));
} else {
arr.push(readValue(r, f, wire));
}
} else {
out[f.name] = readValue(r, f, wire);
}
}
return out;
}
__name(decode, "decode");
// src/signer.js
var Domain = { BROADCAST: 0, VEHICLE_SECURITY: 2, INFOTAINMENT: 3 };
var Tag = { SIGNATURE_TYPE: 0, DOMAIN: 1, PERSONALIZATION: 2, EPOCH: 3, EXPIRES_AT: 4, COUNTER: 5, CHALLENGE: 6, FLAGS: 7, REQUEST_HASH: 8, FAULT: 9, END: 255 };
var SignatureType = { AES_GCM_PERSONALIZED: 5, HMAC: 6, HMAC_PERSONALIZED: 8, AES_GCM_RESPONSE: 9 };
var KEY_NOT_ON_WHITELIST = 1;
var OPERATION_WAIT = 1;
var MessageFault_E = SCHEMA.enums["UniversalMessage.MessageFault_E"];
var FAULT_NAMES = Object.fromEntries(Object.entries(MessageFault_E).map(([k, v]) => [v, k]));
var COMMAND_TTL_SECONDS = 10;
var MAX_ATTEMPTS = 3;
var enc2 = new TextEncoder();
var VehicleAsleepError = class extends Error {
static {
__name(this, "VehicleAsleepError");
}
constructor() {
super("The car is asleep or offline.");
this.name = "VehicleAsleepError";
}
};
var SignerError = class extends Error {
static {
__name(this, "SignerError");
}
constructor(message, fault) {
super(message);
this.name = "SignerError";
this.fault = fault;
}
};
var RESYNC_FAULTS = /* @__PURE__ */ new Set([
MessageFault_E.MESSAGEFAULT_ERROR_INCORRECT_EPOCH,
MessageFault_E.MESSAGEFAULT_ERROR_INVALID_TOKEN_OR_COUNTER,
MessageFault_E.MESSAGEFAULT_ERROR_TIME_EXPIRED,
MessageFault_E.MESSAGEFAULT_ERROR_REPEATED_COUNTER,
MessageFault_E.MESSAGEFAULT_ERROR_INVALID_SIGNATURE
]);
function concat(...parts) {
const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0));
let o = 0;
for (const p of parts) {
out.set(p, o);
o += p.length;
}
return out;
}
__name(concat, "concat");
function u32be(n) {
const b = new Uint8Array(4);
new DataView(b.buffer).setUint32(0, n >>> 0);
return b;
}
__name(u32be, "u32be");
function serializeMetadata(items) {
const parts = [...items].sort((a, b) => a[0] - b[0]).map(([tag, value]) => {
if (value.length > 255) throw new Error("metadata value too long");
return concat(new Uint8Array([tag, value.length]), value);
});
return concat(...parts, new Uint8Array([Tag.END]));
}
__name(serializeMetadata, "serializeMetadata");
async function importClientKey(pkcs8Pem) {
return crypto.subtle.importKey("pkcs8", pemToDer2(pkcs8Pem), { name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
}
__name(importClientKey, "importClientKey");
async function rawPublicKey(spkiPem) {
const key = await crypto.subtle.importKey("spki", pemToDer2(spkiPem), { name: "ECDH", namedCurve: "P-256" }, true, []);
return new Uint8Array(await crypto.subtle.exportKey("raw", key));
}
__name(rawPublicKey, "rawPublicKey");
async function deriveSharedKey(clientPrivateKey, vehiclePublicRaw) {
const peer = await crypto.subtle.importKey("raw", vehiclePublicRaw, { name: "ECDH", namedCurve: "P-256" }, false, []);
const sx = await crypto.subtle.deriveBits({ name: "ECDH", public: peer }, clientPrivateKey, 256);
return new Uint8Array(await crypto.subtle.digest("SHA-1", sx)).slice(0, 16);
}
__name(deriveSharedKey, "deriveSharedKey");
async function hmacSha256(key, data) {
const k = await crypto.subtle.importKey("raw", key, { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
return new Uint8Array(await crypto.subtle.sign("HMAC", k, data));
}
__name(hmacSha256, "hmacSha256");
async function sessionInfoKey(K) {
return hmacSha256(K, enc2.encode("session info"));
}
__name(sessionInfoKey, "sessionInfoKey");
async function commandKey(K) {
return hmacSha256(K, enc2.encode("authenticated command"));
}
__name(commandKey, "commandKey");
async function expectedSessionInfoTag(K, vin, challenge, sessionInfoBytes) {
const metadata = serializeMetadata([
[Tag.SIGNATURE_TYPE, new Uint8Array([SignatureType.HMAC])],
[Tag.PERSONALIZATION, enc2.encode(vin)],
[Tag.CHALLENGE, challenge]
]);
return hmacSha256(await sessionInfoKey(K), concat(metadata, sessionInfoBytes));
}
__name(expectedSessionInfoTag, "expectedSessionInfoTag");
function commandMetadata({ domain, vin, epoch, expiresAt, counter, flags = 0 }) {
const items = [
[Tag.SIGNATURE_TYPE, new Uint8Array([SignatureType.HMAC_PERSONALIZED])],
[Tag.DOMAIN, new Uint8Array([domain])],
[Tag.PERSONALIZATION, enc2.encode(vin)],
[Tag.EPOCH, epoch],
[Tag.EXPIRES_AT, u32be(expiresAt)],
[Tag.COUNTER, u32be(counter)]
];
if (flags) items.push([Tag.FLAGS, u32be(flags)]);
return serializeMetadata(items);
}
__name(commandMetadata, "commandMetadata");
function responseMetadata({ domain, vin, counter, flags, requestHash, fault }) {
return serializeMetadata([
[Tag.SIGNATURE_TYPE, new Uint8Array([SignatureType.AES_GCM_RESPONSE])],
[Tag.DOMAIN, new Uint8Array([domain])],
[Tag.PERSONALIZATION, enc2.encode(vin)],
[Tag.COUNTER, u32be(counter)],
[Tag.FLAGS, u32be(flags)],
[Tag.REQUEST_HASH, requestHash],
[Tag.FAULT, u32be(fault)]
]);
}
__name(responseMetadata, "responseMetadata");
async function decryptResponse(K, metadata, nonce, ciphertext, tag) {
const aad = new Uint8Array(await crypto.subtle.digest("SHA-256", metadata));
const key = await crypto.subtle.importKey("raw", K, { name: "AES-GCM" }, false, ["decrypt"]);
const pt = await crypto.subtle.decrypt({ name: "AES-GCM", iv: nonce, additionalData: aad, tagLength: 128 }, key, concat(ciphertext, tag));
return new Uint8Array(pt);
}
__name(decryptResponse, "decryptResponse");
function timingSafeEqual(a, b) {
if (a.length !== b.length) return false;
let diff = 0;
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
return diff === 0;
}
__name(timingSafeEqual, "timingSafeEqual");
var VehicleSigner = class {
static {
__name(this, "VehicleSigner");
}
// opts: { kv, fleetBase, vin, getAccessToken, clientKeyPem, publicKeyPem, fetch?, now? }
constructor(opts) {
this.kv = opts.kv;
this.fleetBase = opts.fleetBase;
this.vin = opts.vin;
this.getAccessToken = opts.getAccessToken;
this.clientKeyPem = opts.clientKeyPem;
this.publicKeyPem = opts.publicKeyPem;
this.fetch = opts.fetch || ((...a) => fetch(...a));
this.now = opts.now || (() => Math.floor(Date.now() / 1e3));
this.sleep = opts.sleep || ((ms) => new Promise((r) => setTimeout(r, ms)));
}
async keys() {
if (!this._keys) {
this._keys = {
priv: await importClientKey(this.clientKeyPem),
pub: await rawPublicKey(this.publicKeyPem)
};
}
return this._keys;
}
sessionKey(domain) {
return `session:${this.vin}:${domain}`;
}
async loadSession(domain) {
const s = await this.kv.get(this.sessionKey(domain), "json");
if (!s) return null;
return { ...s, epoch: hexToBytes2(s.epoch), vehiclePublicKey: hexToBytes2(s.vehiclePublicKey) };
}
async saveSession(domain, s) {
await this.kv.put(
this.sessionKey(domain),
JSON.stringify({ ...s, epoch: bytesToHex(s.epoch), vehiclePublicKey: bytesToHex(s.vehiclePublicKey) }),
{ expirationTtl: 7 * 24 * 3600 }
);
}
// Send one application payload (CarServer.Action or VCSEC.UnsignedMessage bytes) to a domain.
// Returns { domain, payload } with the plaintext reply bytes (possibly empty).
async send(domain, payload) {
let forceHandshake = false;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
let session = forceHandshake ? null : await this.loadSession(domain);
if (!session) session = await this.handshake(domain);
const { msg, tag } = await this.sign(domain, session, payload);
const resp = await this.transmit(msg);
if (sessionInfoOf(resp)) await this.acceptSessionInfo(domain, resp, msg.uuid, session);
const fault = resp.signedMessageStatus?.signed_message_fault || 0;
if (fault && RESYNC_FAULTS.has(fault)) {
forceHandshake = !sessionInfoOf(resp);
continue;
}
if (fault) throw new SignerError(`Car rejected the command: ${FAULT_NAMES[fault] || fault}`, fault);
if (resp.signedMessageStatus?.operation_status === OPERATION_WAIT) {
await this.sleep(2e3);
continue;
}
const plaintext = await this.openResponse(domain, session, resp, tag);
return { domain: resp.from_destination?.domain ?? domain, payload: plaintext };
}
throw new SignerError("Gave up after repeated session resyncs or busy replies.");
}
async handshake(domain) {
const { pub } = await this.keys();
const uuid = randomBytes(16);
const msg = {
to_destination: { domain },
from_destination: { routing_address: randomBytes(16) },
session_info_request: { public_key: pub },
uuid
};
const resp = await this.transmit(msg);
if (!sessionInfoOf(resp)) {
const fault = resp.signedMessageStatus?.signed_message_fault;
throw new SignerError(`Handshake failed${fault ? `: ${FAULT_NAMES[fault] || fault}` : ""}`, fault);
}
const session = await this.acceptSessionInfo(domain, resp, uuid, null);
if (!session) throw new SignerError("Handshake reply was stale.");
return session;
}
// Authenticate a SessionInfo reply (bound to our request uuid) and commit it. Returns the
// committed session, or null if it's a stale replay.
async acceptSessionInfo(domain, resp, requestUuid, current) {
if (resp.request_uuid?.length && !timingSafeEqual(resp.request_uuid, requestUuid)) {
throw new SignerError("Session info reply doesn't match our request.");
}
const raw = sessionInfoOf(resp);
const d = decode("Signatures.SessionInfo", raw);
const info = { counter: d.counter ?? 0, publicKey: d.publicKey, epoch: d.epoch ?? new Uint8Array(), clockTime: d.clock_time ?? 0, status: d.status ?? 0 };
if (!info.publicKey?.length) {
if (info.status === KEY_NOT_ON_WHITELIST) {
throw new SignerError("This bridge's key isn't paired with the car. Pair it again from the Tesla app.");
}
throw new SignerError("Session info reply has no vehicle key.");
}
const { priv } = await this.keys();
const K = await deriveSharedKey(priv, info.publicKey);
const tag = resp.signature_data?.session_info_tag?.tag ?? null;
if (!tag?.length) throw new SignerError("Session info reply is missing its authentication tag.");
const expected = await expectedSessionInfoTag(K, this.vin, requestUuid, raw);
if (!timingSafeEqual(expected, tag)) throw new SignerError("Session info reply failed authentication.");
if (info.status === KEY_NOT_ON_WHITELIST) {
throw new SignerError("This bridge's key isn't paired with the car. Pair it again from the Tesla app.");
}
const prior = current || await this.loadSession(domain);
const sameEpoch = prior && timingSafeEqual(prior.epoch, info.epoch);
if (sameEpoch && prior.lastClock !== void 0 && info.clockTime < prior.lastClock) return null;
const session = {
epoch: info.epoch,
counter: sameEpoch ? Math.max(prior.counter, info.counter) : info.counter,
delta: this.now() - info.clockTime,
lastClock: info.clockTime,
vehiclePublicKey: info.publicKey
};
await this.saveSession(domain, session);
return session;
}
async sign(domain, session, payload) {
const { priv, pub } = await this.keys();
session.counter += 1;
await this.saveSession(domain, session);
const expiresAt = this.now() - session.delta + COMMAND_TTL_SECONDS;
const K = await deriveSharedKey(priv, session.vehiclePublicKey);
const metadata = commandMetadata({ domain, vin: this.vin, epoch: session.epoch, expiresAt, counter: session.counter });
const tag = await hmacSha256(await commandKey(K), concat(metadata, payload));
const msg = {
to_destination: { domain },
from_destination: { routing_address: randomBytes(16) },
protobuf_message_as_bytes: payload,
signature_data: {
signer_identity: { public_key: pub },
HMAC_Personalized_data: { epoch: session.epoch, counter: session.counter, expires_at: expiresAt, tag }
},
uuid: randomBytes(16)
};
return { msg, tag };
}
async openResponse(domain, session, resp, requestTag) {
const payload = resp.protobuf_message_as_bytes ?? new Uint8Array();
const data = resp.signature_data?.AES_GCM_Response_data;
if (!data) return payload;
let requestHash = concat(new Uint8Array([SignatureType.HMAC_PERSONALIZED]), requestTag);
if (domain === Domain.VEHICLE_SECURITY) requestHash = requestHash.slice(0, 17);
const { priv } = await this.keys();
const K = await deriveSharedKey(priv, session.vehiclePublicKey);
const metadata = responseMetadata({
domain: resp.from_destination?.domain ?? domain,
vin: this.vin,
counter: data.counter ?? 0,
flags: resp.flags || 0,
requestHash,
fault: resp.signedMessageStatus?.signed_message_fault || 0
});
return decryptResponse(K, metadata, data.nonce ?? new Uint8Array(), payload, data.tag ?? new Uint8Array());
}
async transmit(msg) {
const token = await this.getAccessToken();
const res = await this.fetch(`${this.fleetBase}/api/1/vehicles/${this.vin}/signed_command`, {
method: "POST",
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: JSON.stringify({ routable_message: bytesToB64(encode("UniversalMessage.RoutableMessage", msg)) })
});
if (res.status === 408) throw new VehicleAsleepError();
const body = await res.json().catch(() => ({}));
if (!res.ok || typeof body.response !== "string") {
throw new SignerError(`Fleet API signed_command failed: HTTP ${res.status} ${body.error || ""}`.trim());
}
return decode("UniversalMessage.RoutableMessage", b64ToBytes(body.response));
}
};
function randomBytes(n) {
return crypto.getRandomValues(new Uint8Array(n));
}
__name(randomBytes, "randomBytes");
function pemToDer2(pem) {
return b64ToBytes(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, ""));
}
__name(pemToDer2, "pemToDer");
function b64ToBytes(b64) {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out;
}
__name(b64ToBytes, "b64ToBytes");
function bytesToB64(bytes) {
let s = "";
for (let i = 0; i < bytes.length; i += 32768) s += String.fromCharCode(...bytes.subarray(i, i + 32768));
return btoa(s);
}
__name(bytesToB64, "bytesToB64");
function hexToBytes2(hex) {
const out = new Uint8Array(hex.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.substr(i * 2, 2), 16);
return out;
}
__name(hexToBytes2, "hexToBytes");
function bytesToHex(bytes) {
return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join("");
}
__name(bytesToHex, "bytesToHex");
function sessionInfoOf(resp) {
return resp.session_info?.length ? resp.session_info : null;
}
__name(sessionInfoOf, "sessionInfoOf");
// src/commands.js
var INFO = 3;
var VCSEC = 2;
var VOID = {};
var va = /* @__PURE__ */ __name((msg) => ({ vehicleAction: msg }), "va");
var SEATS = ["CAR_SEAT_FRONT_LEFT", "CAR_SEAT_FRONT_RIGHT", "CAR_SEAT_REAR_LEFT", "CAR_SEAT_REAR_LEFT_BACK", "CAR_SEAT_REAR_CENTER", "CAR_SEAT_REAR_RIGHT", "CAR_SEAT_REAR_RIGHT_BACK"];
var SEAT_LEVELS = ["SEAT_HEATER_OFF", "SEAT_HEATER_LOW", "SEAT_HEATER_MED", "SEAT_HEATER_HIGH"];
var KEEPER = { off: "ClimateKeeperAction_Off", on: "ClimateKeeperAction_On", dog: "ClimateKeeperAction_Dog", camp: "ClimateKeeperAction_Camp" };
var COP_TEMP = { low: "CopActivationTempLow", medium: "CopActivationTempMedium", high: "CopActivationTempHigh" };
var STW_LEVEL = { off: "StwHeatLevel_Off", low: "StwHeatLevel_Low", high: "StwHeatLevel_High" };
var DAYS = { sun: 1, mon: 2, tue: 4, wed: 8, thu: 16, fri: 32, sat: 64 };
function daysMask(days) {
if (typeof days === "number") return days;
const list = String(days).toLowerCase().split(/[\s,]+/).filter(Boolean);
if (list.includes("all")) return 127;
if (list.includes("weekdays")) return 62;
let mask = 0;
for (const d of list) {
const bit = DAYS[d.slice(0, 3)];
if (!bit) throw new Error(`Unknown day: ${d}`);
mask |= bit;
}
return mask;
}
__name(daysMask, "daysMask");
var req = /* @__PURE__ */ __name((args, name) => {
if (args[name] === void 0 || args[name] === null || args[name] === "") throw new Error(`Missing argument: ${name}`);
return args[name];
}, "req");
var num = /* @__PURE__ */ __name((args, name, min, max) => {
const v = Number(req(args, name));
if (!Number.isFinite(v) || v < min || v > max) throw new Error(`${name} must be a number from ${min} to ${max}`);
return v;
}, "num");
var bool = /* @__PURE__ */ __name((args, name) => {
const v = req(args, name);
if (typeof v !== "boolean") throw new Error(`${name} must be true or false`);
return v;
}, "bool");
var pick = /* @__PURE__ */ __name((map, args, name) => {
const v = map[String(req(args, name)).toLowerCase()];
if (!v) throw new Error(`${name} must be one of: ${Object.keys(map).join(", ")}`);
return v;
}, "pick");
var COMMANDS = {
// --- Locks, trunks, start (VCSEC) -------------------------------------------------------
door_lock: { domain: VCSEC, build: /* @__PURE__ */ __name(() => ({ RKEAction: "RKE_ACTION_LOCK" }), "build") },
door_unlock: { domain: VCSEC, sensitive: true, build: /* @__PURE__ */ __name(() => ({ RKEAction: "RKE_ACTION_UNLOCK" }), "build") },
remote_start_drive: { domain: VCSEC, sensitive: true, build: /* @__PURE__ */ __name(() => ({ RKEAction: "RKE_ACTION_REMOTE_DRIVE" }), "build") },
open_frunk: { domain: VCSEC, sensitive: true, build: /* @__PURE__ */ __name(() => ({ closureMoveRequest: { frontTrunk: "CLOSURE_MOVE_TYPE_MOVE" } }), "build") },
actuate_rear_trunk: { domain: VCSEC, sensitive: true, build: /* @__PURE__ */ __name(() => ({ closureMoveRequest: { rearTrunk: "CLOSURE_MOVE_TYPE_MOVE" } }), "build") },
// CLOSE (Tesla vehicle-command CloseTrunk): powered trunks only. Not sensitive: closing secures the car.
close_rear_trunk: { domain: VCSEC, build: /* @__PURE__ */ __name(() => ({ closureMoveRequest: { rearTrunk: "CLOSURE_MOVE_TYPE_CLOSE" } }), "build") },
charge_port_door_open: { domain: VCSEC, build: /* @__PURE__ */ __name(() => ({ closureMoveRequest: { chargePort: "CLOSURE_MOVE_TYPE_OPEN" } }), "build") },
charge_port_door_close: { domain: VCSEC, build: /* @__PURE__ */ __name(() => ({ closureMoveRequest: { chargePort: "CLOSURE_MOVE_TYPE_CLOSE" } }), "build") },
// --- Alerts -------------------------------------------------------------------------------
honk_horn: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ vehicleControlHonkHornAction: VOID }), "build") },
flash_lights: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ vehicleControlFlashLightsAction: VOID }), "build") },
remote_boombox: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ boomboxAction: { sound: a.sound ?? 0 } }), "build") },
// --- Climate --------------------------------------------------------------------------------
climate_on: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ hvacAutoAction: { power_on: true } }), "build") },
climate_off: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ hvacAutoAction: { power_on: false } }), "build") },
set_temps: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => va({ hvacTemperatureAdjustmentAction: { driver_temp_celsius: num(a, "driver_c", 15, 28), passenger_temp_celsius: num(a, "passenger_c", 15, 28) } }), "build")
},
set_preconditioning_max: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ hvacSetPreconditioningMaxAction: { on: bool(a, "on"), manual_override: !!a.manual_override } }), "build") },
set_climate_keeper_mode: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ hvacClimateKeeperAction: { ClimateKeeperAction: pick(KEEPER, a, "mode") } }), "build") },
set_cabin_overheat_protection: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ setCabinOverheatProtectionAction: { on: bool(a, "on"), fan_only: !!a.fan_only } }), "build") },
set_cop_temp: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ setCopTempAction: { copActivationTemp: pick(COP_TEMP, a, "level") } }), "build") },
set_recirculation: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ hvacRecirculationAction: { on: bool(a, "on") } }), "build") },
seat_heater: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => {
const seat = SEATS[num(a, "seat", 0, SEATS.length - 1)];
const level = SEAT_LEVELS[num(a, "level", 0, 3)];
return va({ hvacSeatHeaterActions: { hvacSeatHeaterAction: [{ [seat]: VOID, [level]: VOID }] } });
}, "build")
},
auto_seat_climate: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => va({ autoSeatClimateAction: { carseat: [{ on: bool(a, "on"), seat_position: num(a, "seat", 1, 2) }] } }), "build")
},
steering_wheel_heater: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ hvacSteeringWheelHeaterAction: { power_on: bool(a, "on") } }), "build") },
steering_wheel_heat_level: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ stwHeatLevelAction: { stw_heat_level: pick(STW_LEVEL, a, "level") } }), "build") },
auto_steering_wheel_heat: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ autoStwHeatAction: { on: bool(a, "on") } }), "build") },
set_bioweapon_mode: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ hvacBioweaponModeAction: { on: bool(a, "on"), manual_override: !!a.manual_override } }), "build") },
// --- Charging -------------------------------------------------------------------------------
charge_start: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ chargingStartStopAction: { start: VOID } }), "build") },
charge_stop: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ chargingStartStopAction: { stop: VOID } }), "build") },
charge_standard: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ chargingStartStopAction: { start_standard: VOID } }), "build") },
charge_max_range: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ chargingStartStopAction: { start_max_range: VOID } }), "build") },
set_charge_limit: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ chargingSetLimitAction: { percent: num(a, "percent", 50, 100) } }), "build") },
set_charging_amps: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ setChargingAmpsAction: { charging_amps: num(a, "amps", 1, 48) } }), "build") },
set_scheduled_charging: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => va({ scheduledChargingAction: { enabled: bool(a, "enabled"), charging_time: num(a, "minutes_after_midnight", 0, 1439) } }), "build")
},
add_charge_schedule: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => {
if (a.start_minutes === void 0 && a.end_minutes === void 0) throw new Error("Give start_minutes, end_minutes, or both");
const s = {
days_of_week: daysMask(req(a, "days")),
enabled: a.enabled ?? true,
start_enabled: a.start_minutes !== void 0,
end_enabled: a.end_minutes !== void 0,
latitude: num(a, "lat", -90, 90),
longitude: num(a, "lon", -180, 180)
};
if (a.start_minutes !== void 0) s.start_time = num(a, "start_minutes", 0, 1439);
if (a.end_minutes !== void 0) s.end_time = num(a, "end_minutes", 0, 1439);
if (a.one_time !== void 0) s.one_time = !!a.one_time;
if (a.id !== void 0) s.id = BigInt(a.id);
if (a.name) s.name = String(a.name);
return va({ addChargeScheduleAction: s });
}, "build")
},
remove_charge_schedule: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ removeChargeScheduleAction: { id: BigInt(req(a, "id")) } }), "build") },
add_precondition_schedule: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => {
const s = {
days_of_week: daysMask(req(a, "days")),
enabled: a.enabled ?? true,
precondition_time: num(a, "minutes_after_midnight", 0, 1439),
latitude: num(a, "lat", -90, 90),
longitude: num(a, "lon", -180, 180)
};
if (a.one_time !== void 0) s.one_time = !!a.one_time;
if (a.id !== void 0) s.id = BigInt(a.id);
if (a.name) s.name = String(a.name);
return va({ addPreconditionScheduleAction: s });
}, "build")
},
remove_precondition_schedule: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ removePreconditionScheduleAction: { id: BigInt(req(a, "id")) } }), "build") },
// --- Navigation -----------------------------------------------------------------------------
navigate_to_address: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ navigationRequest: { destination: String(req(a, "destination")) } }), "build") },
navigate_to_coordinates: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => va({ navigationGpsRequest: { lat: num(a, "lat", -90, 90), lon: num(a, "lon", -180, 180), order: num(a, "order", 0, 3) } }), "build")
},
navigate_to_coordinates_named: {
domain: INFO,
build: /* @__PURE__ */ __name((a) => va({ navigationGpsDestinationRequest: { lat: num(a, "lat", -90, 90), lon: num(a, "lon", -180, 180), destination: String(req(a, "destination")), order: num(a, "order", 0, 3) } }), "build")
},
navigate_to_supercharger: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ navigationSuperchargerRequest: { remote_nav_trip_order: num(a, "order", 0, 3) } }), "build") },
set_waypoints: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ navigationWaypointsRequest: { waypoints: String(req(a, "waypoints")) } }), "build") },
// --- Media ------------------------------------------------------------------------------------
media_toggle_playback: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ mediaPlayAction: VOID }), "build") },
media_next_track: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ mediaNextTrack: VOID }), "build") },
media_prev_track: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ mediaPreviousTrack: VOID }), "build") },
media_next_favorite: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ mediaNextFavorite: VOID }), "build") },
media_prev_favorite: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ mediaPreviousFavorite: VOID }), "build") },
media_volume_up: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ mediaUpdateVolume: { volume_delta: 1 } }), "build") },
media_volume_down: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ mediaUpdateVolume: { volume_delta: -1 } }), "build") },
set_volume: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ mediaUpdateVolume: { volume_absolute_float: num(a, "volume", 0, 11) } }), "build") },
// --- Security and modes ---------------------------------------------------------------------
sentry_on: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ vehicleControlSetSentryModeAction: { on: true } }), "build") },
sentry_off: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name(() => va({ vehicleControlSetSentryModeAction: { on: false } }), "build") },
window_vent: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name(() => va({ vehicleControlWindowAction: { vent: VOID } }), "build") },
window_close: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ vehicleControlWindowAction: { close: VOID } }), "build") },
trigger_homelink: {
domain: INFO,
sensitive: true,
build: /* @__PURE__ */ __name((a) => va({ vehicleControlTriggerHomelinkAction: { location: { latitude: num(a, "lat", -90, 90), longitude: num(a, "lon", -180, 180) } } }), "build")
},
set_valet_mode: {
domain: INFO,
sensitive: true,
build: /* @__PURE__ */ __name((a) => va({ vehicleControlSetValetModeAction: a.pin !== void 0 ? { on: bool(a, "on"), password: String(a.pin) } : { on: bool(a, "on") } }), "build")
},
reset_valet_pin: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name(() => va({ vehicleControlResetValetPinAction: VOID }), "build") },
set_pin_to_drive: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name((a) => va({ vehicleControlSetPinToDriveAction: { on: bool(a, "on"), password: String(req(a, "pin")) } }), "build") },
reset_pin_to_drive_pin: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name(() => va({ vehicleControlResetPinToDriveAction: VOID }), "build") },
speed_limit_activate: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ drivingSpeedLimitAction: { activate: true, pin: String(req(a, "pin")) } }), "build") },
speed_limit_deactivate: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name((a) => va({ drivingSpeedLimitAction: { activate: false, pin: String(req(a, "pin")) } }), "build") },
speed_limit_set: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ drivingSetSpeedLimitAction: { limit_mph: num(a, "mph", 50, 120) } }), "build") },
speed_limit_clear_pin: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name((a) => va({ drivingClearSpeedLimitPinAction: { pin: String(req(a, "pin")) } }), "build") },
guest_mode: { domain: INFO, sensitive: true, build: /* @__PURE__ */ __name((a) => va({ guestModeAction: { GuestModeActive: bool(a, "on") } }), "build") },
// --- Vehicle ----------------------------------------------------------------------------------
set_vehicle_name: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ setVehicleNameAction: { vehicleName: String(req(a, "name")).slice(0, 64) } }), "build") },
schedule_software_update: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ vehicleControlScheduleSoftwareUpdateAction: { offset_sec: num(a, "offset_sec", 0, 86400 * 7) } }), "build") },
cancel_software_update: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ vehicleControlCancelSoftwareUpdateAction: VOID }), "build") },
set_low_power_mode: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ setLowPowerModeAction: { low_power_mode: bool(a, "on") } }), "build") },
set_keep_accessory_power: { domain: INFO, build: /* @__PURE__ */ __name((a) => va({ setKeepAccessoryPowerModeAction: { keep_accessory_power_mode: bool(a, "on") } }), "build") },
dashcam_save_clip: { domain: INFO, build: /* @__PURE__ */ __name(() => va({ dashcamSaveClipAction: VOID }), "build") }
};
function buildPayload(name, args = {}) {
const cmd = COMMANDS[name];
if (!cmd) throw new Error(`Unknown command: ${name}`);
return encode(cmd.domain === VCSEC ? "VCSEC.UnsignedMessage" : "CarServer.Action", cmd.build(args));
}
__name(buildPayload, "buildPayload");
function interpretReply(domain, bytes) {
if (!bytes || !bytes.length) return { ok: true, reason: "" };
if (domain === VCSEC) {
const m = decode("VCSEC.FromVCSECMessage", bytes);
if (m.nominalError) return { ok: false, reason: String(m.nominalError.genericError ?? 0) };
if (m.commandStatus) {
const st = m.commandStatus.operationStatus ?? 0;
return st === 0 ? { ok: true, reason: "" } : { ok: false, reason: `operation status ${st}` };
}
return { ok: true, reason: "" };
}
const r = decode("CarServer.Response", bytes);
if (r.actionStatus) {
return { ok: (r.actionStatus.result ?? 0) === 0, reason: r.actionStatus.result_reason?.plain_text ?? "" };
}
return { ok: true, reason: "" };
}
__name(interpretReply, "interpretReply");
// src/fleet.js
var DEFAULT_WAKE_BUDGET = 50;
var DEFAULT_FLEET_API_BASE = "https://fleet-api.prd.na.vn.cloud.tesla.com";
function fleetBase(env) {
return (env.FLEET_API_BASE || DEFAULT_FLEET_API_BASE).replace(/\/+$/, "");
}
__name(fleetBase, "fleetBase");
var WAKE_TIMEOUT_MS = 45e3;
var WAKE_POLL_MS = 3e3;
var DATA_ENDPOINTS = ["charge_state", "climate_state", "drive_state", "location_data", "vehicle_state", "vehicle_config", "gui_settings"];
async function primaryVehicle(env) {
const vehicles = await env.TESLA_KV.get("tesla:vehicles", "json") || [];
if (!vehicles.length) throw new Error("No car is linked yet. Open /tesla/start on the bridge.");
return vehicles[0];
}
__name(primaryVehicle, "primaryVehicle");
async function rest(env, method, path, body) {
const token = await getAccessToken(env);
const res = await fetch(fleetBase(env) + path, {
method,
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: body ? JSON.stringify(body) : void 0
});
const data = await res.json().catch(() => ({}));
return { status: res.status, data };
}
__name(rest, "rest");
async function vehicleState(env) {
const v = await primaryVehicle(env);
const { status, data } = await rest(env, "GET", `/api/1/vehicles/${v.vin}`);
if (status !== 200) throw new Error(`Couldn't read the car's state: HTTP ${status} ${data.error || ""}`.trim());
return data.response?.state || "unknown";
}
__name(vehicleState, "vehicleState");
async function vehicleData(env) {
const v = await primaryVehicle(env);
const q = encodeURIComponent(DATA_ENDPOINTS.join(";"));
const { status, data } = await rest(env, "GET", `/api/1/vehicles/${v.vin}/vehicle_data?endpoints=${q}`);
if (status === 200 && data.response) {
await env.TESLA_KV.put("tesla:last_data", JSON.stringify({ at: Date.now(), data: data.response }));
return { live: true, data: data.response };
}
if (status === 408) {
const last = await env.TESLA_KV.get("tesla:last_data", "json");
return { live: false, asleep: true, lastKnownAt: last ? new Date(last.at).toISOString() : null, data: last?.data || null };
}
throw new Error(`Couldn't read vehicle data: HTTP ${status} ${data.error || ""}`.trim());
}
__name(vehicleData, "vehicleData");
async function nearbyChargers(env) {
const v = await primaryVehicle(env);
const { status, data } = await rest(env, "GET", `/api/1/vehicles/${v.vin}/nearby_charging_sites`);
if (status === 408) throw new VehicleAsleepError();
if (status !== 200) throw new Error(`Couldn't read nearby chargers: HTTP ${status} ${data.error || ""}`.trim());
return data.response;
}
__name(nearbyChargers, "nearbyChargers");
async function navigateToAddress(env, text2, { allowWake = true } = {}) {
const v = await primaryVehicle(env);
const body = {
type: "share_ext_content_raw",
locale: "en-US",
timestamp_ms: String(Date.now()),
value: { "android.intent.extra.TEXT": String(text2) }
};
for (let attempt = 0; attempt < 2; attempt++) {
const { status, data } = await rest(env, "POST", `/api/1/vehicles/${v.vin}/command/navigation_request`, body);
if (status === 408 && attempt === 0 && allowWake) {
await wakeVehicle(env);
continue;
}
if (status !== 200) throw new Error(`navigation_request failed: HTTP ${status} ${data.error || ""}`.trim());
const r = data.response || {};
return { ok: r.result !== false, reason: r.reason || "", woke: attempt > 0 };
}
throw new Error("The car didn't respond after waking.");
}
__name(navigateToAddress, "navigateToAddress");
function wakeBudget(env) {
const n = parseInt(env.WAKE_BUDGET_PER_DAY || "", 10);
return Number.isFinite(n) && n >= 0 ? n : DEFAULT_WAKE_BUDGET;
}
__name(wakeBudget, "wakeBudget");
var wakeKey = /* @__PURE__ */ __name(() => `wakes:${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}`, "wakeKey");
async function wakesUsedToday(env) {
return parseInt(await env.TESLA_KV.get(wakeKey()) || "0", 10);
}
__name(wakesUsedToday, "wakesUsedToday");
async function wakeVehicle(env) {
const used = await wakesUsedToday(env);
if (used >= wakeBudget(env)) {
throw new Error(`Today's wake budget (${wakeBudget(env)}) is used up. Wakes reset at midnight UTC.`);
}
const v = await primaryVehicle(env);
await env.TESLA_KV.put(wakeKey(), String(used + 1), { expirationTtl: 2 * 86400 });
const { status, data } = await rest(env, "POST", `/api/1/vehicles/${v.vin}/wake_up`);
if (status !== 200) throw new Error(`Wake request failed: HTTP ${status} ${data.error || ""}`.trim());
if (data.response?.state === "online") return "online";
const deadline = Date.now() + WAKE_TIMEOUT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, WAKE_POLL_MS));
if (await vehicleState(env) === "online") return "online";
}
throw new Error("The car didn't wake up within 45 seconds. It may have no signal.");
}
__name(wakeVehicle, "wakeVehicle");
async function signerFor(env) {
const v = await primaryVehicle(env);
const clientKeyPem = await privateKeyPem(env);
if (!clientKeyPem) throw new Error("This bridge has no app key yet. Open /setup on the bridge.");
return new VehicleSigner({
kv: env.TESLA_KV,
fleetBase: fleetBase(env),
vin: v.vin,
getAccessToken: /* @__PURE__ */ __name(() => getAccessToken(env), "getAccessToken"),
clientKeyPem,
publicKeyPem: await publicKeyPem(env)
});
}
__name(signerFor, "signerFor");
async function runCommand(env, name, args = {}, { allowWake = true } = {}) {
const cmd = COMMANDS[name];
if (!cmd) throw new Error(`Unknown command: ${name}`);
const payload = buildPayload(name, args);
const signer = await signerFor(env);
let woke = false;
for (let attempt = 0; attempt < 2; attempt++) {
try {
const reply = await signer.send(cmd.domain, payload);
const result = interpretReply(cmd.domain, reply.payload);
return { ...result, woke };
} catch (err) {
if (!(err instanceof VehicleAsleepError) || woke || !allowWake) throw err;
await wakeVehicle(env);
woke = true;
}
}
throw new Error("The command didn't go through after waking the car.");
}
__name(runCommand, "runCommand");
// src/tesla-auth.js
var TOKEN_URL = "https://fleet-auth.prd.vn.cloud.tesla.com/oauth2/v3/token";
var AUTHORIZE_URL = "https://auth.tesla.com/oauth2/v3/authorize";
var USER_SCOPES = "openid offline_access vehicle_device_data vehicle_location vehicle_cmds vehicle_charging_cmds";
var STATE_TTL = 600;
var KV_REFRESH = "tesla:refresh_token";
var KV_ACCESS = "tesla:access_token";
var KV_VEHICLES = "tesla:vehicles";
function redirectUri(url) {
return `${url.origin}/tesla/callback`;
}
__name(redirectUri, "redirectUri");
async function startSignIn(env, url) {
const state = randomToken(24);
const verifier = randomToken(48);
await env.TESLA_KV.put(`oauth_state:${state}`, verifier, { expirationTtl: STATE_TTL });
const auth = new URL(AUTHORIZE_URL);
auth.search = new URLSearchParams({
response_type: "code",
client_id: env.TESLA_CLIENT_ID,
redirect_uri: redirectUri(url),
scope: USER_SCOPES,
state,
code_challenge: await s256(verifier),
code_challenge_method: "S256",
prompt_missing_scopes: "true"
}).toString();
return Response.redirect(auth.toString(), 302);
}
__name(startSignIn, "startSignIn");
async function finishSignIn(env, url) {
const error = url.searchParams.get("error");
if (error) return { ok: false, lines: [`Tesla returned an error: ${error} ${url.searchParams.get("error_description") || ""}`] };
const state = url.searchParams.get("state") || "";
const code = url.searchParams.get("code") || "";
const verifier = state && await env.TESLA_KV.get(`oauth_state:${state}`);
if (!verifier || !code) return { ok: false, lines: ["This sign-in link expired or was already used. Start again from /tesla/start."] };
await env.TESLA_KV.delete(`oauth_state:${state}`);
const tokens = await tokenRequest(env, {
grant_type: "authorization_code",
code,
code_verifier: verifier,
redirect_uri: redirectUri(url)
});
await saveTokens(env, tokens);
const lines = ["Signed in to Tesla. Tokens stored in Cloudflare KV."];
const scopes = tokenScopes(tokens.access_token);
if (scopes.length) lines.push(`Granted scopes: ${scopes.join(", ")}`);
const res = await fetch(`${fleetBase(env)}/api/1/vehicles`, {
headers: { authorization: `Bearer ${tokens.access_token}` }
});
const data = await res.json().catch(() => ({}));
if (res.ok && Array.isArray(data.response)) {
const vehicles = data.response.map((v) => ({ vin: v.vin, name: v.display_name, id: v.id }));
await env.TESLA_KV.put(KV_VEHICLES, JSON.stringify(vehicles));
lines.push(`Vehicles on this account: ${vehicles.map((v) => `${v.name || "(unnamed)"} (VIN ending ${String(v.vin).slice(-6)})`).join("; ") || "none"}`);
} else {
lines.push(`Vehicle list: HTTP ${res.status} ${data.error || ""}`.trim());
}
return { ok: true, lines };
}
__name(finishSignIn, "finishSignIn");
async function getAccessToken(env) {
const cached = await env.TESLA_KV.get(KV_ACCESS, "json");
if (cached && cached.expires_at > Date.now() + 6e4) return cached.token;
const refresh = await env.TESLA_KV.get(KV_REFRESH);
if (!refresh) throw new Error("Not signed in to Tesla. Open /tesla/start.");
const tokens = await tokenRequest(env, { grant_type: "refresh_token", refresh_token: refresh });
await saveTokens(env, tokens);
return tokens.access_token;
}
__name(getAccessToken, "getAccessToken");
async function tokenRequest(env, params) {
const body = new URLSearchParams({
client_id: env.TESLA_CLIENT_ID,
client_secret: env.TESLA_CLIENT_SECRET,
audience: fleetBase(env),
...params
});
const res = await fetch(TOKEN_URL, { method: "POST", body });
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) {
throw new Error(`Tesla token request failed: HTTP ${res.status} ${data.error || ""} ${data.error_description || ""}`.trim());
}
return data;
}
__name(tokenRequest, "tokenRequest");
async function saveTokens(env, tokens) {
if (tokens.refresh_token) await env.TESLA_KV.put(KV_REFRESH, tokens.refresh_token);
const expiresIn = Number(tokens.expires_in) || 3600;
await env.TESLA_KV.put(KV_ACCESS, JSON.stringify({ token: tokens.access_token, expires_at: Date.now() + expiresIn * 1e3 }), {
expirationTtl: Math.max(60, expiresIn)
});
}
__name(saveTokens, "saveTokens");
function tokenScopes(jwt) {
try {
const payload = JSON.parse(atob(jwt.split(".")[1].replace(/-/g, "+").replace(/_/g, "/")));
return Array.isArray(payload.scp) ? payload.scp : [];
} catch {
return [];
}
}
__name(tokenScopes, "tokenScopes");
function randomToken(bytes) {
const b = crypto.getRandomValues(new Uint8Array(bytes));
return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
__name(randomToken, "randomToken");
async function s256(text2) {
const d = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(text2)));
return btoa(String.fromCharCode(...d)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
__name(s256, "s256");
// src/channels.js
var NTFY_ATTEMPTS = 5;
var TG_API = "https://api.telegram.org";
function configuredChannels(env) {
const list = [];
if (typeof env.NTFY_SERVER === "string" && /^https?:\/\//.test(env.NTFY_SERVER.trim())) list.push(ntfy);
if (env.TELEGRAM_BOT_TOKEN) list.push(telegram);
if (env.PUSHOVER_APP_TOKEN && env.PUSHOVER_USER_KEY) list.push(pushover);
if (env.EMAIL && env.APPROVAL_EMAIL_FROM && env.APPROVAL_EMAIL_TO) list.push(email);
return list;
}
__name(configuredChannels, "configuredChannels");
async function sendApproval(env, req2) {
const channels = configuredChannels(env);
const results = await Promise.allSettled(channels.map((c) => c.approval(env, req2)));
const sent = channels.filter((_, i) => results[i].status === "fulfilled").map((c) => c.name);
const failed = channels.map((c, i) => results[i].status === "rejected" ? `${c.name}: ${results[i].reason?.message || results[i].reason}` : null).filter(Boolean);
if (!sent.length) throw new Error(`Couldn't send the phone notification (${failed.join("; ") || "no channel configured"}).`);
return { sent, failed };
}
__name(sendApproval, "sendApproval");
async function sendResult(env, { title, text: text2, ok }) {
await Promise.allSettled(configuredChannels(env).map((c) => c.result(env, { title, text: text2, ok })));
}
__name(sendResult, "sendResult");
async function ntfyTopic(env) {
let topic = await env.TESLA_KV.get("ntfy:topic");
if (!topic) {
topic = `car-${randomToken2(18)}`;
await env.TESLA_KV.put("ntfy:topic", topic);
}
return topic;
}
__name(ntfyTopic, "ntfyTopic");
async function ntfyPost(env, { title, message, priority = "default", tags = "", actions = "", click = "" }) {
const headers = { Title: title, Priority: priority };
if (env.NTFY_TOKEN) headers.Authorization = `Bearer ${env.NTFY_TOKEN}`;
if (tags) headers.Tags = tags;
if (actions) headers.Actions = actions;
if (click) headers.Click = click;
const url = `${env.NTFY_SERVER.replace(/\/+$/, "")}/${await ntfyTopic(env)}`;
let last = "";
let tries = 0;
for (let attempt = 1; attempt <= NTFY_ATTEMPTS; attempt++) {
tries = attempt;
try {
const res = await fetch(url, { method: "POST", headers, body: message });
if (res.ok) return;
last = `HTTP ${res.status}`;
if (res.status < 500) break;
} catch (err) {
last = err.message;
}
if (attempt < NTFY_ATTEMPTS) await new Promise((r) => setTimeout(r, 300 * attempt));
}
throw new Error(`ntfy ${last}${tries > 1 ? ` after ${tries} tries` : ""}`);
}
__name(ntfyPost, "ntfyPost");
var ntfy = {
name: "ntfy",
approval: /* @__PURE__ */ __name((env, req2) => ntfyPost(env, {
title: req2.title,
message: req2.body,
priority: "high",
tags: "warning,red_car",
actions: `http, Approve, ${req2.approveUrl}, method=POST, clear=true; http, Deny, ${req2.denyUrl}, method=POST, clear=true`
}), "approval"),
result: /* @__PURE__ */ __name((env, { title, text: text2, ok }) => ntfyPost(env, { title, message: text2, tags: ok ? "white_check_mark" : "x" }), "result")
};
async function tg(env, method, payload) {
const res = await fetch(`${TG_API}/bot${env.TELEGRAM_BOT_TOKEN}/${method}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(payload)
});
const data = await res.json().catch(() => ({}));
if (!data.ok) throw new Error(`Telegram ${method}: ${data.description || `HTTP ${res.status}`}`);
return data.result;
}
__name(tg, "tg");
async function telegramChatId(env) {
return env.TELEGRAM_CHAT_ID || await env.TESLA_KV.get("telegram:chat_id");
}
__name(telegramChatId, "telegramChatId");
var telegram = {
name: "Telegram",
async approval(env, req2) {
const chatId = await telegramChatId(env);
if (!chatId) throw new Error("Telegram isn't paired yet (open /phone)");
await tg(env, "sendMessage", {
chat_id: chatId,
text: `${req2.title}
${req2.body}`,
reply_markup: {
inline_keyboard: [[
{ text: "\u2705 Approve", callback_data: `a:${req2.id}:${req2.token}` },
{ text: "\u274C Deny", callback_data: `d:${req2.id}:${req2.token}` }
]]
}
});
},
async result(env, { title, text: text2 }) {
const chatId = await telegramChatId(env);
if (chatId) await tg(env, "sendMessage", { chat_id: chatId, text: `${title}
${text2}` });
}
};
async function telegramStartPairing(env, origin) {
let secret2 = await env.TESLA_KV.get("telegram:webhook_secret");
if (!secret2) {
secret2 = randomToken2(24).replace(/[^A-Za-z0-9_-]/g, "");
await env.TESLA_KV.put("telegram:webhook_secret", secret2);
}
await tg(env, "setWebhook", {
url: `${origin}/telegram/webhook`,
secret_token: secret2,
allowed_updates: ["message", "callback_query"]
});
const me = await tg(env, "getMe", {});
const code = randomToken2(9).replace(/[^A-Za-z0-9]/g, "x");
await env.TESLA_KV.put("telegram:pairing_code", code, { expirationTtl: 900 });
return { code, botUsername: me.username, link: `https://t.me/${me.username}?start=${code}` };
}
__name(telegramStartPairing, "telegramStartPairing");
async function telegramWebhook(env, request, decide) {
const secret2 = await env.TESLA_KV.get("telegram:webhook_secret");
if (!secret2 || request.headers.get("X-Telegram-Bot-Api-Secret-Token") !== secret2) {
return new Response("forbidden", { status: 403 });
}
const update = await request.json().catch(() => ({}));
const pairedChat = await telegramChatId(env);
const msg = update.message;
if (msg?.text?.startsWith("/start")) {
const code = msg.text.split(/\s+/)[1] || "";
const expected = await env.TESLA_KV.get("telegram:pairing_code");
if (expected && code === expected) {
await env.TESLA_KV.put("telegram:chat_id", String(msg.chat.id));
await env.TESLA_KV.delete("telegram:pairing_code");
await tg(env, "sendMessage", { chat_id: msg.chat.id, text: "Paired. Approval requests for your car will arrive here." }).catch(() => {
});
} else if (!pairedChat || String(msg.chat.id) !== String(pairedChat)) {
await tg(env, "sendMessage", { chat_id: msg.chat.id, text: "This bot only answers its owner. Start pairing from the bridge's /phone page." }).catch(() => {
});
}
return new Response("ok");
}
const cq = update.callback_query;
if (cq?.data) {
const chatId = cq.message?.chat?.id;
let answer = "Not allowed.";
if (pairedChat && String(chatId) === String(pairedChat)) {
const [act, id, token] = cq.data.split(":");
const r = await decide(id, act === "a" ? "approve" : "deny", token);
answer = r.text;
if (cq.message) {
await tg(env, "editMessageText", {
chat_id: chatId,
message_id: cq.message.message_id,
text: `${cq.message.text}
\u2192 ${r.text}`
}).catch(() => {
});
}
}
await tg(env, "answerCallbackQuery", { callback_query_id: cq.id, text: answer.slice(0, 190) }).catch(() => {
});
}
return new Response("ok");
}
__name(telegramWebhook, "telegramWebhook");
async function pushoverPost(env, fields) {
const res = await fetch("https://api.pushover.net/1/messages.json", {
method: "POST",
body: new URLSearchParams({ token: env.PUSHOVER_APP_TOKEN, user: env.PUSHOVER_USER_KEY, ...fields })
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(`Pushover HTTP ${res.status} ${(data.errors || []).join(", ")}`.trim());
}
}
__name(pushoverPost, "pushoverPost");
var pushover = {
name: "Pushover",
approval: /* @__PURE__ */ __name((env, req2) => pushoverPost(env, { title: req2.title, message: req2.body, priority: "1", url: req2.pageUrl, url_title: "Approve or deny" }), "approval"),
result: /* @__PURE__ */ __name((env, { title, text: text2 }) => pushoverPost(env, { title, message: text2 }), "result")
};
var email = {
name: "email",
approval: /* @__PURE__ */ __name((env, req2) => env.EMAIL.send({
from: env.APPROVAL_EMAIL_FROM,
to: env.APPROVAL_EMAIL_TO,
subject: req2.title,
text: `${req2.body}
Approve or deny: ${req2.pageUrl}
`
}), "approval"),
result: /* @__PURE__ */ __name((env, { title, text: text2 }) => env.EMAIL.send({ from: env.APPROVAL_EMAIL_FROM, to: env.APPROVAL_EMAIL_TO, subject: title, text: text2 }), "result")
};
function randomToken2(bytes = 24) {
const b = crypto.getRandomValues(new Uint8Array(bytes));
return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
__name(randomToken2, "randomToken");
// src/approvals.js
var APPROVAL_TTL = 90;
var DESCRIBE = {
door_unlock: "Unlock the car",
remote_start_drive: "Enable keyless driving (remote start)",
open_frunk: "Open the frunk",
actuate_rear_trunk: "Open the rear trunk (or toggle a powered trunk)",
window_vent: "Vent the windows",
sentry_off: "Turn Sentry Mode OFF",
set_valet_mode: "Change Valet Mode",
reset_valet_pin: "Reset the valet PIN",
set_pin_to_drive: "Change PIN to Drive",
reset_pin_to_drive_pin: "Reset the PIN to Drive PIN",
speed_limit_deactivate: "Turn Speed Limit Mode OFF",
speed_limit_clear_pin: "Clear the speed limit PIN",
guest_mode: "Change Guest Mode",
trigger_homelink: "Open/close the garage (HomeLink)"
};
async function sha256hex(s) {
const d = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(s)));
return [...d].map((x) => x.toString(16).padStart(2, "0")).join("");
}
__name(sha256hex, "sha256hex");
function approvalsConfigured(env) {
return configuredChannels(env).length > 0;
}
__name(approvalsConfigured, "approvalsConfigured");
async function requestApproval(env, origin, name, args) {
if (!COMMANDS[name]?.sensitive) throw new Error(`${name} doesn't need approval.`);
if (!approvalsConfigured(env)) {
throw new Error("Phone approvals aren't set up on this bridge, so this sensitive command can't run from here. Nothing was sent to the car. Set up an approval channel (see the bridge's /phone page), or use the Tesla app or your key.");
}
const id = randomToken2(9);
const token = randomToken2(24);
const record = { id, name, args, status: "pending", created: Date.now(), tokenHash: await sha256hex(token) };
await env.TESLA_KV.put(`approval:${id}`, JSON.stringify(record), { expirationTtl: APPROVAL_TTL + 600 });
const base = `${origin}/approval/${id}`;
const { sent } = await sendApproval(env, {
id,
token,
title: "Approve car command?",
body: `${DESCRIBE[name] || name}${Object.keys(args || {}).length ? `
${describeArgs(args)}` : ""}
Expires in ${APPROVAL_TTL} seconds. If you didn't ask for this, tap Deny.`,
approveUrl: `${base}/approve?t=${token}`,
denyUrl: `${base}/deny?t=${token}`,
pageUrl: `${base}?t=${token}`
});
return { id, sent };
}
__name(requestApproval, "requestApproval");
function describeArgs(args) {
return Object.entries(args).map(([k, v]) => /pin/i.test(k) ? `${k}: ****` : `${k}: ${v}`).join(", ");
}
__name(describeArgs, "describeArgs");
async function approvalStatus(env, id) {
const rec = await env.TESLA_KV.get(`approval:${id}`, "json");
if (!rec) return { status: "unknown" };
if (rec.status === "pending" && Date.now() - rec.created > APPROVAL_TTL * 1e3) return { status: "expired", name: rec.name };
return { status: rec.status, name: rec.name, result: rec.result };
}
__name(approvalStatus, "approvalStatus");
async function peekApproval(env, id, token) {
const rec = await env.TESLA_KV.get(`approval:${id}`, "json");
if (!rec || !token || await sha256hex(token) !== rec.tokenHash) return null;
return { name: rec.name, description: DESCRIBE[rec.name] || rec.name, status: rec.status, expired: Date.now() - rec.created > APPROVAL_TTL * 1e3 };
}
__name(peekApproval, "peekApproval");
async function handleDecision(env, id, decision, token, ctx) {
const key = `approval:${id}`;
const rec = await env.TESLA_KV.get(key, "json");
if (!rec || !token || await sha256hex(token) !== rec.tokenHash) return { code: 404, text: "Unknown or invalid approval." };
if (rec.status !== "pending") return { code: 409, text: `Already ${rec.status}.` };
if (Date.now() - rec.created > APPROVAL_TTL * 1e3) {
await env.TESLA_KV.put(key, JSON.stringify({ ...rec, status: "expired" }), { expirationTtl: 600 });
return { code: 410, text: "This approval expired. Ask again." };
}
if (decision === "deny") {
await env.TESLA_KV.put(key, JSON.stringify({ ...rec, status: "denied" }), { expirationTtl: 600 });
return { code: 200, text: "Denied. Nothing was sent to the car." };
}
await env.TESLA_KV.put(key, JSON.stringify({ ...rec, status: "running" }), { expirationTtl: 600 });
const work = executeApproved(env, key, rec);
if (ctx?.waitUntil) {
ctx.waitUntil(work);
return { code: 202, text: `Approved: ${DESCRIBE[rec.name] || rec.name}. Sending it to the car; you'll get a message with the result.` };
}
const result = await work;
return { code: 200, text: result.text };
}
__name(handleDecision, "handleDecision");
async function executeApproved(env, key, rec) {
let result;
try {
const r = await runCommand(env, rec.name, rec.args);
result = r.ok ? { ok: true, text: `Done: ${DESCRIBE[rec.name] || rec.name}.` } : { ok: false, text: `The car refused: ${r.reason || "no reason given"}` };
} catch (err) {
result = { ok: false, text: `Failed: ${err.message}` };
}
await env.TESLA_KV.put(key, JSON.stringify({ ...rec, status: result.ok ? "done" : "failed", result: result.text }), { expirationTtl: 600 });
await sendResult(env, { title: result.ok ? "Car command done" : "Car command failed", text: result.text, ok: result.ok });
return result;
}
__name(executeApproved, "executeApproved");
// src/tools.js
var APPROVAL_WAIT_MS = 25e3;
var GROUPS = {
climate: {
on: "climate_on",
off: "climate_off",
set_temps: "set_temps",
defrost_max: "set_preconditioning_max",
climate_keeper: "set_climate_keeper_mode",
overheat_protection: "set_cabin_overheat_protection",
overheat_temp: "set_cop_temp",
recirculation: "set_recirculation",
bioweapon_mode: "set_bioweapon_mode",
seat_heater: "seat_heater",
auto_seat_climate: "auto_seat_climate",
steering_wheel_heater: "steering_wheel_heater",
steering_wheel_heat_level: "steering_wheel_heat_level",
auto_steering_wheel_heat: "auto_steering_wheel_heat"
},
charging: {
start: "charge_start",
stop: "charge_stop",
standard: "charge_standard",
max_range: "charge_max_range",
set_limit: "set_charge_limit",
set_amps: "set_charging_amps",
port_open: "charge_port_door_open",
port_close: "charge_port_door_close",
scheduled_charging: "set_scheduled_charging",
add_charge_schedule: "add_charge_schedule",
remove_charge_schedule: "remove_charge_schedule",
add_precondition_schedule: "add_precondition_schedule",
remove_precondition_schedule: "remove_precondition_schedule"
},
navigate: {
address: "navigate_to_address",
coordinates: "navigate_to_coordinates",
named_coordinates: "navigate_to_coordinates_named",
supercharger: "navigate_to_supercharger",
waypoints: "set_waypoints"
},
access: {
lock: "door_lock",
unlock: "door_unlock",
open_frunk: "open_frunk",
rear_trunk: "actuate_rear_trunk",
close_rear_trunk: "close_rear_trunk",
vent_windows: "window_vent",
close_windows: "window_close",
remote_start: "remote_start_drive",
homelink: "trigger_homelink"
},
security: {
sentry_on: "sentry_on",
sentry_off: "sentry_off",
valet_mode: "set_valet_mode",
reset_valet_pin: "reset_valet_pin",
pin_to_drive: "set_pin_to_drive",
reset_pin_to_drive_pin: "reset_pin_to_drive_pin",
speed_limit_on: "speed_limit_activate",
speed_limit_off: "speed_limit_deactivate",
speed_limit_set: "speed_limit_set",
speed_limit_clear_pin: "speed_limit_clear_pin",
guest_mode: "guest_mode"
},
alert: { honk: "honk_horn", flash: "flash_lights", boombox: "remote_boombox" },
media: {
play_pause: "media_toggle_playback",
next_track: "media_next_track",
previous_track: "media_prev_track",
next_favorite: "media_next_favorite",
previous_favorite: "media_prev_favorite",
volume_up: "media_volume_up",
volume_down: "media_volume_down",
set_volume: "set_volume"
},
vehicle_settings: {
rename: "set_vehicle_name",
schedule_update: "schedule_software_update",
cancel_update: "cancel_software_update",
low_power_mode: "set_low_power_mode",
keep_accessory_power: "set_keep_accessory_power",
save_dashcam_clip: "dashcam_save_clip"
}
};
var sensitiveIn = /* @__PURE__ */ __name((group) => Object.entries(GROUPS[group]).filter(([, c]) => COMMANDS[c].sensitive).map(([a]) => a), "sensitiveIn");
var ARG_PROPS = {
on: { type: "boolean", description: "Turn on (true) or off (false)" },
driver_c: { type: "number", description: "Driver temperature in Celsius (15-28). Convert from Fahrenheit if the user gave F." },
passenger_c: { type: "number", description: "Passenger temperature in Celsius (15-28)" },
mode: { type: "string", enum: ["off", "on", "dog", "camp"], description: "Climate keeper mode" },
level: { type: "string", description: "overheat_temp: low|medium|high. steering_wheel_heat_level: off|low|high. seat_heater: 0-3 as a number." },
seat: { type: "number", description: "seat_heater: 0 front left, 1 front right, 2 rear left, 4 rear center, 5 rear right. auto_seat_climate: 1 front left, 2 front right" },
fan_only: { type: "boolean" },
manual_override: { type: "boolean" },
percent: { type: "number", description: "Charge limit percent (50-100)" },
amps: { type: "number", description: "Charging current in amps" },
minutes_after_midnight: { type: "number", description: "Local time as minutes after midnight (e.g. 7:30 am = 450)" },
enabled: { type: "boolean" },
days: { type: "string", description: "Days: 'all', 'weekdays', or a list like 'mon,wed,fri'" },
start_minutes: { type: "number", description: "Charge schedule start, minutes after midnight" },
end_minutes: { type: "number", description: "Charge schedule end, minutes after midnight" },
one_time: { type: "boolean" },
id: { type: "number", description: "Schedule id (from get_vehicle_status)" },
name: { type: "string" },
lat: { type: "number" },
lon: { type: "number" },
destination: { type: "string", description: "Address or place name, e.g. 'Publix, Lake City, FL'" },
order: { type: "number", description: "0 car decides, 1 replace the trip, 2 add as next stop, 3 add as last stop" },
waypoints: { type: "string", description: "Comma-separated Google Maps place ids as 'refId:<id>'; the last is the destination" },
volume: { type: "number", description: "Volume 0-11" },
pin: { type: "string", description: "4-digit PIN" },
mph: { type: "number", description: "Speed limit in mph (50-120)" },
sound: { type: "number", description: "Boombox sound id (0 = default)" },
offset_sec: { type: "number", description: "Seconds from now to start the software update" }
};
var COMMAND_ARGS = {
set_temps: ["driver_c", "passenger_c"],
set_preconditioning_max: ["on", "manual_override"],
set_climate_keeper_mode: ["mode"],
set_cabin_overheat_protection: ["on", "fan_only"],
set_cop_temp: ["level"],
set_recirculation: ["on"],
set_bioweapon_mode: ["on", "manual_override"],
seat_heater: ["seat", "level"],
auto_seat_climate: ["seat", "on"],
steering_wheel_heater: ["on"],
steering_wheel_heat_level: ["level"],
auto_steering_wheel_heat: ["on"],
set_charge_limit: ["percent"],
set_charging_amps: ["amps"],
set_scheduled_charging: ["enabled", "minutes_after_midnight"],
add_charge_schedule: ["days", "lat", "lon", "start_minutes", "end_minutes", "enabled", "one_time", "id", "name"],
remove_charge_schedule: ["id"],
add_precondition_schedule: ["days", "lat", "lon", "minutes_after_midnight", "enabled", "one_time", "id", "name"],
remove_precondition_schedule: ["id"],
navigate_to_address: ["destination"],
navigate_to_coordinates: ["lat", "lon", "order"],
navigate_to_coordinates_named: ["lat", "lon", "destination", "order"],
navigate_to_supercharger: ["order"],
set_waypoints: ["waypoints"],
trigger_homelink: ["lat", "lon"],
set_valet_mode: ["on", "pin"],
set_pin_to_drive: ["on", "pin"],
speed_limit_activate: ["pin"],
speed_limit_deactivate: ["pin"],
speed_limit_set: ["mph"],
speed_limit_clear_pin: ["pin"],
guest_mode: ["on"],
remote_boombox: ["sound"],
set_volume: ["volume"],
set_vehicle_name: ["name"],
schedule_software_update: ["offset_sec"],
set_low_power_mode: ["on"],
set_keep_accessory_power: ["on"]
};
for (const [cmd, args] of Object.entries(COMMAND_ARGS)) {
if (!COMMANDS[cmd]) throw new Error(`COMMAND_ARGS names unknown command ${cmd}`);
for (const a of args) if (!ARG_PROPS[a]) throw new Error(`COMMAND_ARGS: no schema for argument ${a}`);
}
function groupTool(name, description, group) {
const sens = sensitiveIn(group);
const actions = Object.entries(GROUPS[group]);
const props = {};
for (const [, cmd] of actions) for (const a of COMMAND_ARGS[cmd] || []) props[a] = ARG_PROPS[a];
const withArgs = actions.filter(([, cmd]) => COMMAND_ARGS[cmd]?.length).map(([act, cmd]) => `${act}(${COMMAND_ARGS[cmd].join(", ")})`);
return {
name,
description: description + (withArgs.length ? ` Arguments by action: ${withArgs.join("; ")}. Other actions take none.` : "") + (sens.length ? ` Actions ${sens.join(", ")} need the owner to tap Approve on their phone before they run; tell the user to check their phone.` : ""),
inputSchema: {
type: "object",
properties: { action: { type: "string", enum: actions.map(([a]) => a) }, ...props },
required: ["action"]
},
group
};
}
__name(groupTool, "groupTool");
var READ_ONLY = { readOnlyHint: true, destructiveHint: false };
var TOOLS = [
{
name: "get_vehicle_status",
description: "Read the car's live status: location, speed, navigation destination and ETA (only while in Drive), battery and charging, climate and temperatures, locks, doors, windows, sentry, tire pressures, software. Never wakes the car; if it's asleep, returns the last known data and its age.",
inputSchema: { type: "object", properties: { sections: { type: "string", description: "Optional: comma list of location,drive,charge,climate,vehicle,config. Default: all" } } },
annotations: READ_ONLY
},
{
name: "wake_vehicle",
description: "Wake the car so it can answer status reads. Commands wake it automatically when needed, so use this only before a status read on a sleeping car. Costs money (a daily budget applies).",
inputSchema: { type: "object", properties: {} }
},
groupTool("climate", "Climate and comfort: HVAC on/off, temperatures, defrost, dog/camp mode, cabin overheat protection, seat and steering wheel heat.", "climate"),
groupTool("charging", "Charging: start/stop, charge limit, amps, charge port, scheduled charging and departure/precondition schedules.", "charging"),
groupTool("navigate", "Send a destination to the car's navigation: an address or place name, coordinates, a Supercharger, or a multi-stop route.", "navigate"),
groupTool("access", "Locks, trunks, windows, keyless driving, and the garage door (HomeLink). rear_trunk opens (or toggles) the rear trunk; close_rear_trunk closes a powered rear trunk.", "access"),
groupTool("security", "Sentry Mode, Valet Mode, PIN to Drive, Speed Limit Mode, Guest Mode.", "security"),
groupTool("alert", "Honk the horn, flash the lights, or play the boombox to find the car.", "alert"),
groupTool("media", "Media playback and volume.", "media"),
groupTool("vehicle_settings", "Rename the car, schedule or cancel a software update, low power mode, keep accessory power, save a dashcam clip.", "vehicle_settings"),
{
name: "find_chargers",
description: "List Superchargers and destination chargers near the car (needs the car awake).",
inputSchema: { type: "object", properties: {} },
annotations: READ_ONLY
},
{
name: "check_approval",
description: "Check the result of a phone-approval request by its id.",
inputSchema: { type: "object", properties: { id: { type: "string" } }, required: ["id"] },
annotations: READ_ONLY
}
];
var text = /* @__PURE__ */ __name((t, isError = false) => ({ content: [{ type: "text", text: typeof t === "string" ? t : JSON.stringify(t, null, 1) }], ...isError ? { isError: true } : {} }), "text");
function listTools() {
return TOOLS.map(({ group, ...t }) => t);
}
__name(listTools, "listTools");
async function callTool(name, args, env, ctx) {
if (name === "get_vehicle_status") return text(await statusSummary(env, args.sections));
if (name === "wake_vehicle") {
const state = await wakeVehicle(env);
return text(`The car is ${state}. Wakes used today: ${await wakesUsedToday(env)}.`);
}
if (name === "find_chargers") {
try {
return text(await nearbyChargers(env));
} catch (e) {
if (e instanceof VehicleAsleepError) return text("The car is asleep. Wake it first with wake_vehicle.", true);
throw e;
}
}
if (name === "check_approval") return text(await approvalStatus(env, String(args.id || "")));
const tool = TOOLS.find((t) => t.name === name && t.group);
if (!tool) return text(`Unknown tool: ${name}`, true);
const command = GROUPS[tool.group][args.action];
if (!command) return text(`Unknown action "${args.action}" for ${name}. Valid: ${Object.keys(GROUPS[tool.group]).join(", ")}`, true);
const { action, ...cmdArgs } = args;
if (COMMANDS[command].sensitive) {
const { id, sent } = await requestApproval(env, ctx.origin, command, cmdArgs);
const outcome = await waitForApproval(env, id);
if (outcome.status === "done") return text(outcome.result);
if (outcome.status === "failed") return text(outcome.result, true);
if (outcome.status === "denied") return text("The owner denied this on their phone. Nothing was sent to the car.", true);
return text(`Approval request sent to the owner's phone via ${sent.join(", ")} (id ${id}). It runs only after they tap Approve, within 90 seconds. Use check_approval to see the result.`);
}
if (command === "navigate_to_address" && !String(cmdArgs.destination || "").trim()) {
return text("Missing argument: destination", true);
}
const r = command === "navigate_to_address" ? await navigateToAddress(env, cmdArgs.destination) : await runCommand(env, command, cmdArgs);
const woke = r.woke ? " (the car was woken first)" : "";
return r.ok ? text(`Done: ${tool.name} ${action}${woke}.`) : text(`The car refused: ${r.reason || "no reason given"}${woke}`, true);
}
__name(callTool, "callTool");
async function waitForApproval(env, id) {
const deadline = Date.now() + APPROVAL_WAIT_MS;
let s = { status: "pending" };
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 2500));
s = await approvalStatus(env, id);
if (["done", "failed", "denied", "expired"].includes(s.status)) return s;
}
return s;
}
__name(waitForApproval, "waitForApproval");
async function statusSummary(env, sectionsArg) {
const r = await vehicleData(env);
const want = new Set(String(sectionsArg || "location,drive,charge,climate,vehicle,config").split(/[\s,]+/));
if (!r.data) {
const state = await vehicleState(env).catch(() => "unknown");
return { state, note: "The car is asleep and there's no saved data yet. Call wake_vehicle, then try again." };
}
const d = r.data;
const out = { state: r.live ? "online" : "asleep", live: r.live };
if (!r.live) out.last_known_at = r.lastKnownAt;
const ds = d.drive_state || {};
if (want.has("location")) {
out.location = { latitude: ds.latitude, longitude: ds.longitude, heading: ds.heading, at: ds.gps_as_of ? new Date(ds.gps_as_of * 1e3).toISOString() : void 0 };
}
if (want.has("drive")) {
out.drive = {
gear: ds.shift_state || "P",
speed_mph: ds.speed ?? 0,
power_kw: ds.power,
navigation: ds.active_route_destination ? {
destination: ds.active_route_destination,
latitude: ds.active_route_latitude,
longitude: ds.active_route_longitude,
miles_to_arrival: ds.active_route_miles_to_arrival,
minutes_to_arrival: ds.active_route_minutes_to_arrival,
energy_at_arrival_percent: ds.active_route_energy_at_arrival
} : { none_reported: true, note: `The car only reports a route while in Drive. A destination sent while parked waits on screen ("drive to begin") and doesn't show here.` }
};
}
if (want.has("charge") && d.charge_state) {
const c = d.charge_state;
out.charge = {
battery_percent: c.battery_level,
range_miles: c.battery_range,
charging_state: c.charging_state,
limit_percent: c.charge_limit_soc,
charger_power_kw: c.charger_power,
amps: c.charge_amps,
minutes_to_full: c.minutes_to_full_charge,
port_open: c.charge_port_door_open,
scheduled_charging_start: c.scheduled_charging_start_time,
charge_schedules: d.charge_schedule_data?.charge_schedules
};
}
if (want.has("climate") && d.climate_state) {
const c = d.climate_state;
out.climate = {
hvac_on: c.is_climate_on,
inside_c: c.inside_temp,
outside_c: c.outside_temp,
driver_set_c: c.driver_temp_setting,
passenger_set_c: c.passenger_temp_setting,
defrost_max: c.defrost_mode,
keeper_mode: c.climate_keeper_mode,
cabin_overheat_protection: c.cabin_overheat_protection,
seat_heaters: { front_left: c.seat_heater_left, front_right: c.seat_heater_right },
steering_wheel_heater: c.steering_wheel_heater,
preconditioning: c.is_preconditioning
};
}
if (want.has("vehicle") && d.vehicle_state) {
const v = d.vehicle_state;
out.vehicle = {
name: d.display_name || v.vehicle_name,
locked: v.locked,
sentry_mode: v.sentry_mode,
valet_mode: v.valet_mode,
odometer_miles: v.odometer,
software: v.car_version,
update_available: v.software_update?.status || null,
doors_open: { driver_front: !!v.df, passenger_front: !!v.pf, driver_rear: !!v.dr, passenger_rear: !!v.pr },
frunk_open: !!v.ft,
trunk_open: !!v.rt,
windows_open: { driver_front: !!v.fd_window, passenger_front: !!v.fp_window, driver_rear: !!v.rd_window, passenger_rear: !!v.rp_window },
tire_pressure_bar: { front_left: v.tpms_pressure_fl, front_right: v.tpms_pressure_fr, rear_left: v.tpms_pressure_rl, rear_right: v.tpms_pressure_rr },
user_present: v.is_user_present,
speed_limit_mode: v.speed_limit_mode?.active
};
}
if (want.has("config") && d.gui_settings) {
out.units = { distance: d.gui_settings.gui_distance_units, temperature: d.gui_settings.gui_temperature_units };
}
return out;
}
__name(statusSummary, "statusSummary");
// src/oauth.js
var BRIDGE_VERSION = "0.7.1";
var PROTOCOL_VERSIONS = ["2025-06-18", "2025-03-26", "2024-11-05"];
var secret = /* @__PURE__ */ __name((v) => typeof v === "string" ? v.trim() : "", "secret");
var MIN_PASSPHRASE = 16;
function passphrase(env) {
const p = secret(env.PROXY_PASSPHRASE);
if (!p) return null;
if (p.length < MIN_PASSPHRASE && secret(env.ALLOW_SHORT_PASSPHRASE) !== "true") return null;
return p;
}
__name(passphrase, "passphrase");
async function passphraseFingerprint(env) {
const p = passphrase(env);
return p ? (await sha256b64url("fp:" + p)).slice(0, 22) : null;
}
__name(passphraseFingerprint, "passphraseFingerprint");
var ABUSE_LIMIT_PER_HOUR = 12;
var abuseKey = /* @__PURE__ */ __name(() => `abuse:${(/* @__PURE__ */ new Date()).toISOString().slice(0, 13)}`, "abuseKey");
async function overBudget(env) {
return parseInt(await env.TESLA_KV.get(abuseKey()) || "0", 10) >= ABUSE_LIMIT_PER_HOUR;
}
__name(overBudget, "overBudget");
async function spendBudget(env) {
const k = abuseKey();
const n = parseInt(await env.TESLA_KV.get(k) || "0", 10);
await env.TESLA_KV.put(k, String(n + 1), { expirationTtl: 7200 });
}
__name(spendBudget, "spendBudget");
var pause = /* @__PURE__ */ __name((ms) => new Promise((r) => setTimeout(r, ms)), "pause");
var keyFor = /* @__PURE__ */ __name(async (kind, token) => `${kind}:${await sha256b64url(token)}`, "keyFor");
var TOKEN_TTL = 3600;
var REFRESH_TTL = 60 * 60 * 24 * 90;
var CODE_TTL = 600;
var CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "Content-Type, Authorization, MCP-Protocol-Version"
};
function json(body, status = 200, extra = {}) {
return new Response(JSON.stringify(body), {
status,
headers: { "Content-Type": "application/json", ...CORS, ...extra }
});
}
__name(json, "json");
function b64url(bytes) {
let s = "";
for (const b of new Uint8Array(bytes)) s += String.fromCharCode(b);
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
__name(b64url, "b64url");
function randomToken3(len = 32) {
return b64url(crypto.getRandomValues(new Uint8Array(len)));
}
__name(randomToken3, "randomToken");
async function sha256b64url(str) {
return b64url(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(str)));
}
__name(sha256b64url, "sha256b64url");
function safeEqual(a, b) {
const ab = new TextEncoder().encode(a);
const bb = new TextEncoder().encode(b);
let diff = ab.length ^ bb.length;
const n = Math.max(ab.length, bb.length);
for (let i = 0; i < n; i++) diff |= (ab[i] ?? 0) ^ (bb[i] ?? 0);
return diff === 0;
}
__name(safeEqual, "safeEqual");
var ESCAPES = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
var escapeHtml = /* @__PURE__ */ __name((s) => String(s).replace(/[&<>"']/g, (c) => ESCAPES[c]), "escapeHtml");
function authServerMetadata(origin) {
return {
issuer: origin,
authorization_endpoint: `${origin}/authorize`,
token_endpoint: `${origin}/token`,
registration_endpoint: `${origin}/register`,
scopes_supported: ["tesla"],
response_types_supported: ["code"],
grant_types_supported: ["authorization_code", "refresh_token"],
token_endpoint_auth_methods_supported: ["none", "client_secret_post"],
code_challenge_methods_supported: ["S256"]
};
}
__name(authServerMetadata, "authServerMetadata");
function resourceMetadata(origin) {
return {
resource: `${origin}/mcp`,
authorization_servers: [origin],
bearer_methods_supported: ["header"],
scopes_supported: ["tesla"]
};
}
__name(resourceMetadata, "resourceMetadata");
var DEFAULT_ALLOWED_REDIRECT_HOSTS = [
"grok.com",
"x.ai",
"x.com",
// Grok: app, web, and in-car
"claude.ai",
"anthropic.com",
// Claude: web, desktop, mobile
"chatgpt.com",
// ChatGPT custom connectors (chatgpt.com/connector/oauth/...)
// Gemini custom apps sign in through Google's OAuth relay. Only this exact host: the wider
// googleusercontent.com domain also serves user-uploaded content.
"oauth-redirect.googleusercontent.com",
"localhost",
"127.0.0.1"
// local testing (Claude Code, MCP inspectors)
];
function allowedRedirectHosts(env) {
const configured = secret(env.ALLOWED_REDIRECT_HOSTS);
if (!configured) return DEFAULT_ALLOWED_REDIRECT_HOSTS;
return configured.split(",").map((h) => h.trim().toLowerCase()).filter(Boolean);
}
__name(allowedRedirectHosts, "allowedRedirectHosts");
function isAllowedRedirect(uri, env) {
let parsed;
try {
parsed = new URL(uri);
} catch {
return false;
}
const host = parsed.hostname.toLowerCase();
return allowedRedirectHosts(env).some((a) => host === a || host.endsWith(`.${a}`));
}
__name(isAllowedRedirect, "isAllowedRedirect");
async function handleRegister(request, env) {
if (await overBudget(env)) {
return json({
error: "temporarily_unavailable",
error_description: "Too many recent attempts. Try again within the hour."
}, 429);
}
let body;
try {
body = await request.json();
} catch {
return json({ error: "invalid_client_metadata", error_description: "body must be JSON" }, 400);
}
const redirectUris = body.redirect_uris;
if (!Array.isArray(redirectUris) || redirectUris.length === 0) {
return json({ error: "invalid_redirect_uri", error_description: "redirect_uris required" }, 400);
}
for (const uri of redirectUris) {
let parsed;
try {
parsed = new URL(uri);
} catch {
return json({ error: "invalid_redirect_uri", error_description: `not a URL: ${uri}` }, 400);
}
const loopback = parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1";
if (parsed.protocol !== "https:" && !loopback) {
return json({ error: "invalid_redirect_uri", error_description: `must be https: ${uri}` }, 400);
}
if (!isAllowedRedirect(uri, env)) {
return json({
error: "invalid_redirect_uri",
error_description: `redirect host not allowed: ${parsed.hostname}. Allowed: ${allowedRedirectHosts(env).join(", ")}. Set ALLOWED_REDIRECT_HOSTS to add one.`
}, 400);
}
}
const clientId = randomToken3(16);
const record = {
client_id: clientId,
client_name: typeof body.client_name === "string" ? body.client_name.slice(0, 120) : "unnamed",
redirect_uris: redirectUris,
created: Date.now(),
approved: false
};
await env.TESLA_KV.put(`client:${clientId}`, JSON.stringify(record), { expirationTtl: 3600 });
await spendBudget(env);
return json(
{
client_id: clientId,
client_name: record.client_name,
redirect_uris: redirectUris,
grant_types: ["authorization_code", "refresh_token"],
response_types: ["code"],
token_endpoint_auth_method: "none"
},
201
);
}
__name(handleRegister, "handleRegister");
var HIDDEN_FIELDS = [
"client_id",
"redirect_uri",
"state",
"code_challenge",
"code_challenge_method",
"scope",
"resource"
];
function loginPage(params, error) {
const hidden = HIDDEN_FIELDS.filter((k) => params.get(k)).map((k) => `<input type="hidden" name="${k}" value="${escapeHtml(params.get(k))}">`).join("\n ");
const who = params.get("_client_name") || params.get("client_id") || "unknown";
const dest = params.get("redirect_uri") || "";
let destHost = "unknown";
try {
destHost = new URL(dest).host;
} catch {
}
return new Response(
`<!doctype html>
<html lang="en"><head>
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Authorize</title>
<style>
:root { color-scheme: light dark;
--bg:#fff; --fg:#111; --mut:#666; --bd:#d4d4d8; --acc:#2563eb; --err:#b91c1c; }
@media (prefers-color-scheme: dark) {
:root { --bg:#111214; --fg:#f4f4f5; --mut:#a1a1aa; --bd:#3f3f46; --acc:#60a5fa; --err:#f87171; }
}
* { box-sizing:border-box }
body { margin:0; min-height:100vh; display:grid; place-items:center; padding:16px;
background:var(--bg); color:var(--fg);
font:15px/1.5 system-ui,-apple-system,Segoe UI,sans-serif }
.card { width:100%; max-width:380px; border:1px solid var(--bd); border-radius:12px; padding:24px }
h1 { margin:0 0 4px; font-size:17px }
p.sub { margin:0 0 20px; color:var(--mut); font-size:13px }
label { display:block; font-size:13px; font-weight:600; margin-bottom:6px }
input[type=password] { width:100%; padding:10px 12px; font-size:15px; border-radius:8px;
border:1px solid var(--bd); background:transparent; color:var(--fg) }
button { width:100%; margin-top:16px; padding:11px; font-size:15px; font-weight:600;
border:0; border-radius:8px; background:var(--acc); color:#fff; cursor:pointer }
.err { color:var(--err); font-size:13px; margin:0 0 14px }
.who { font-size:12px; color:var(--mut); margin-top:16px; word-break:break-all }
.dest { border:1px solid var(--bd); border-radius:8px; padding:12px 14px; margin:0 0 18px;
font-size:13px; background:rgba(127,127,127,.06) }
.dest .host { font-weight:700; font-size:15px; word-break:break-all }
.dest .lbl { color:var(--mut); display:block; margin-bottom:2px }
</style></head>
<body><div class="card">
<h1>Authorize MCP access</h1>
<p class="sub">A client is asking for control of your car.</p>
${error ? `<p class="err">${escapeHtml(error)}</p>` : ""}
<div class="dest">
<span class="lbl">Access will be sent to</span>
<span class="host">${escapeHtml(destHost)}</span>
</div>
<p class="sub">If you did not just add a connector on that site, close this page.</p>
<form method="POST" action="/authorize">
${hidden}
<label for="pp">Passphrase</label>
<input id="pp" name="passphrase" type="password" autocomplete="current-password" autofocus required>
<button type="submit">Authorize ${escapeHtml(destHost)}</button>
</form>
<p class="who">Client name (self-reported): ${escapeHtml(who)}</p>
</div></body></html>`,
{
status: error ? 401 : 200,
headers: { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" }
}
);
}
__name(loginPage, "loginPage");
async function handleAuthorizeGet(url, env) {
const p = url.searchParams;
const clientId = p.get("client_id");
const redirectUri2 = p.get("redirect_uri");
if (!clientId || !redirectUri2) {
return json(
{ error: "invalid_request", error_description: "client_id and redirect_uri required" },
400
);
}
const raw = await env.TESLA_KV.get(`client:${clientId}`);
if (!raw) return json({ error: "invalid_client" }, 400);
const client = JSON.parse(raw);
if (!client.redirect_uris.includes(redirectUri2)) {
return json({ error: "invalid_request", error_description: "redirect_uri mismatch" }, 400);
}
if (p.get("code_challenge_method") !== "S256" || !p.get("code_challenge")) {
return json({ error: "invalid_request", error_description: "PKCE S256 required" }, 400);
}
if (!isAllowedRedirect(redirectUri2, env)) {
return json({
error: "invalid_request",
error_description: "redirect host is not allowed"
}, 400);
}
p.set("_client_name", client.client_name);
return loginPage(p);
}
__name(handleAuthorizeGet, "handleAuthorizeGet");
async function handleAuthorizePost(request, env) {
const form = await request.formData();
const p = new URLSearchParams();
for (const [k, v] of form.entries()) if (k !== "passphrase") p.set(k, v);
const clientId = p.get("client_id");
const redirectUri2 = p.get("redirect_uri");
const raw = clientId ? await env.TESLA_KV.get(`client:${clientId}`) : null;
if (!raw) return json({ error: "invalid_client" }, 400);
const client = JSON.parse(raw);
if (!client.redirect_uris.includes(redirectUri2)) {
return json({ error: "invalid_request", error_description: "redirect_uri mismatch" }, 400);
}
if (!isAllowedRedirect(redirectUri2, env)) {
return json({ error: "invalid_request", error_description: "redirect host is not allowed" }, 400);
}
const pp = passphrase(env);
if (!pp) {
return json({ error: "server_error", error_description: `PROXY_PASSPHRASE is not set, or is shorter than ${MIN_PASSPHRASE} characters.` }, 500);
}
if (await overBudget(env)) {
p.set("_client_name", client.client_name);
return loginPage(p, "Too many attempts. Try again within the hour.");
}
if (!safeEqual(form.get("passphrase") || "", pp)) {
await spendBudget(env);
await pause(1e3);
p.set("_client_name", client.client_name);
return loginPage(p, "Incorrect passphrase.");
}
if (!client.approved) {
await env.TESLA_KV.put(`client:${clientId}`, JSON.stringify({
...client,
approved: true,
approvedAt: (/* @__PURE__ */ new Date()).toISOString()
}));
}
const code = randomToken3(32);
await env.TESLA_KV.put(
await keyFor("code", code),
JSON.stringify({
client_id: clientId,
redirect_uri: redirectUri2,
code_challenge: p.get("code_challenge"),
scope: p.get("scope") || "tesla"
}),
{ expirationTtl: CODE_TTL }
);
const dest = new URL(redirectUri2);
dest.searchParams.set("code", code);
if (p.get("state")) dest.searchParams.set("state", p.get("state"));
return new Response(null, { status: 302, headers: { Location: dest.toString() } });
}
__name(handleAuthorizePost, "handleAuthorizePost");
async function issueTokens(env, clientId, scope) {
const access = randomToken3(32);
const refresh = randomToken3(32);
const payload = JSON.stringify({ client_id: clientId, scope, fp: await passphraseFingerprint(env) });
await env.TESLA_KV.put(await keyFor("tok", access), payload, { expirationTtl: TOKEN_TTL });
await env.TESLA_KV.put(await keyFor("refresh", refresh), payload, { expirationTtl: REFRESH_TTL });
return json({
access_token: access,
token_type: "Bearer",
expires_in: TOKEN_TTL,
refresh_token: refresh,
scope
});
}
__name(issueTokens, "issueTokens");
async function handleToken(request, env) {
const form = await request.formData();
const grant = form.get("grant_type");
if (grant === "authorization_code") {
const code = form.get("code");
const verifier = form.get("code_verifier");
if (!code || !verifier) {
return json(
{ error: "invalid_request", error_description: "code and code_verifier required" },
400
);
}
const codeKey = await keyFor("code", code);
const raw = await env.TESLA_KV.get(codeKey);
if (!raw) return json({ error: "invalid_grant", error_description: "code invalid or expired" }, 400);
await env.TESLA_KV.delete(codeKey);
const rec = JSON.parse(raw);
if (form.get("client_id") && form.get("client_id") !== rec.client_id) {
return json({ error: "invalid_grant", error_description: "client mismatch" }, 400);
}
if (rec.redirect_uri !== form.get("redirect_uri")) {
return json({ error: "invalid_grant", error_description: "redirect_uri mismatch" }, 400);
}
if (await sha256b64url(verifier) !== rec.code_challenge) {
return json({ error: "invalid_grant", error_description: "PKCE verification failed" }, 400);
}
return issueTokens(env, rec.client_id, rec.scope);
}
if (grant === "refresh_token") {
const rt = form.get("refresh_token");
const rtKey = rt ? await keyFor("refresh", rt) : null;
const raw = rtKey ? await env.TESLA_KV.get(rtKey) : null;
if (!raw) return json({ error: "invalid_grant", error_description: "refresh_token invalid" }, 400);
const rec = JSON.parse(raw);
await env.TESLA_KV.delete(rtKey);
if (!rec.fp || rec.fp !== await passphraseFingerprint(env)) {
return json({ error: "invalid_grant", error_description: "revoked: the passphrase has changed" }, 400);
}
return issueTokens(env, rec.client_id, rec.scope);
}
return json({ error: "unsupported_grant_type" }, 400);
}
__name(handleToken, "handleToken");
async function handleRpc(msg, env, origin) {
const { id, method, params } = msg;
const reply = /* @__PURE__ */ __name((result) => ({ jsonrpc: "2.0", id, result }), "reply");
const fail = /* @__PURE__ */ __name((code, message) => ({ jsonrpc: "2.0", id, error: { code, message } }), "fail");
switch (method) {
case "initialize": {
const asked = params?.protocolVersion;
return reply({
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
capabilities: { tools: {} },
serverInfo: { name: "tesla-mcp-bridge", version: BRIDGE_VERSION }
});
}
case "ping":
return reply({});
case "tools/list":
return reply({ tools: listTools() });
case "tools/call": {
if (!params?.name) return fail(-32602, "params.name required");
try {
return reply(await callTool(params.name, params.arguments || {}, env, { origin }));
} catch (err) {
return reply({ content: [{ type: "text", text: `Error: ${err.message}` }], isError: true });
}
}
default:
return fail(-32601, `Method not found: ${method}`);
}
}
__name(handleRpc, "handleRpc");
async function handleMcp(request, env, origin) {
const unauthorized = /* @__PURE__ */ __name((desc) => json({ error: "invalid_token", error_description: desc }, 401, {
"WWW-Authenticate": `Bearer resource_metadata="${origin}/.well-known/oauth-protected-resource"`
}), "unauthorized");
const auth = request.headers.get("Authorization") || "";
if (!auth.startsWith("Bearer ")) return unauthorized("Bearer token required");
const tok = await env.TESLA_KV.get(await keyFor("tok", auth.slice(7).trim()));
if (!tok) return unauthorized("token invalid or expired");
const fp = await passphraseFingerprint(env);
if (!fp || JSON.parse(tok).fp !== fp) return unauthorized("revoked: the passphrase has changed");
let body;
try {
body = await request.json();
} catch {
return json({ jsonrpc: "2.0", id: null, error: { code: -32700, message: "Parse error" } }, 400);
}
const batch = Array.isArray(body) ? body : [body];
const out = [];
for (const msg of batch) {
if (msg?.id === void 0 || msg?.id === null) continue;
out.push(await handleRpc(msg, env, origin));
}
if (out.length === 0) return new Response(null, { status: 202, headers: CORS });
return json(Array.isArray(body) ? out : out[0]);
}
__name(handleMcp, "handleMcp");
// src/index.js
var KEY_PATH = "/.well-known/appspecific/com.tesla.3p.public-key.pem";
var PARTNER_SCOPES = "openid vehicle_device_data vehicle_location vehicle_cmds vehicle_charging_cmds";
var index_default = {
async fetch(request, env, ctx) {
const url = new URL(request.url);
const origin = url.origin;
const path = url.pathname.replace(/\/+$/, "") || "/";
const method = request.method;
if (method === "OPTIONS") return new Response(null, { status: 204, headers: CORS });
if (path === "/") {
return new Response(`tesla-mcp-bridge ${BRIDGE_VERSION}
`, { headers: { "content-type": "text/plain" } });
}
if (!env.TESLA_KV) {
return json({ error: "server_error", error_description: "Storage isn't connected: add a KV namespace binding named TESLA_KV." }, 500);
}
if (path === KEY_PATH) {
const pem = await publicKeyPem(env);
if (!pem) return new Response("No key yet: open /setup on this bridge.", { status: 404 });
return new Response(pem, { headers: { "content-type": "application/x-pem-file", "cache-control": "public, max-age=300" } });
}
const ownerPages = {
"/setup": [SETUP, (form) => setup(env, url, form)],
"/status": [STATUS, () => bridgeStatus(env)],
"/phone": [PHONE, (form) => phoneSetup(env, origin, form)]
};
if (ownerPages[path]) {
const [kind, run] = ownerPages[path];
if (method !== "POST") return html(page(kind, "", kind === SETUP ? setupIntro(origin) : ""));
const form = await request.formData();
const denied = await gate(form, env, kind);
if (denied) return denied;
const out = await run(form);
return html(page(kind, out.text ?? out, out.html));
}
if (path === "/tesla/start") {
if (method !== "POST") return html(page(SIGNIN));
const denied = await gate(await request.formData(), env, SIGNIN);
if (denied) return denied;
return startSignIn(env, url);
}
if (path === "/tesla/callback") {
try {
const result = await finishSignIn(env, url);
return html(page(SIGNIN, result.lines.join("\n")), result.ok ? 200 : 400);
} catch (err) {
return html(page(SIGNIN, `Error: ${err.message}`), 502);
}
}
const decision = path.match(/^\/approval\/([A-Za-z0-9_-]+)\/(approve|deny)$/);
if (decision) {
if (method !== "POST") return approvalPage(env, decision[1], url.searchParams.get("t"));
const r = await handleDecision(env, decision[1], decision[2], url.searchParams.get("t"), ctx);
if ((request.headers.get("accept") || "").includes("text/html")) return html(simplePage("Car command", r.text), r.code);
return new Response(r.text, { status: r.code, headers: { "content-type": "text/plain" } });
}
const view = path.match(/^\/approval\/([A-Za-z0-9_-]+)$/);
if (view) return approvalPage(env, view[1], url.searchParams.get("t"));
if (path === "/telegram/webhook" && method === "POST") {
if (!env.TELEGRAM_BOT_TOKEN) return new Response("not configured", { status: 404 });
return telegramWebhook(env, request, (id, d, token) => handleDecision(env, id, d, token, ctx));
}
if (path === "/.well-known/oauth-authorization-server") return json(authServerMetadata(origin));
if (path === "/.well-known/oauth-protected-resource" || path === "/.well-known/oauth-protected-resource/mcp") {
return json(resourceMetadata(origin));
}
if (path === "/register" && method === "POST") return handleRegister(request, env);
if (path === "/authorize" && method === "GET") return handleAuthorizeGet(url, env);
if (path === "/authorize" && method === "POST") return handleAuthorizePost(request, env);
if (path === "/token" && method === "POST") return handleToken(request, env);
if (path === "/mcp") {
if (method === "POST") return handleMcp(request, env, origin);
return json({ error: "method_not_allowed", error_description: "POST JSON-RPC to /mcp" }, 405, { Allow: "POST, OPTIONS" });
}
return json({ error: "not_found" }, 404);
}
};
async function gate(form, env, kind) {
const pp = passphrase(env);
if (!pp) {
return html(page(kind, `The bridge isn't configured yet: set the secret PROXY_PASSPHRASE (at least ${MIN_PASSPHRASE} characters).`), 500);
}
if (await overBudget(env)) return html(page(kind, "Too many attempts. Try again within the hour."), 429);
if (!safeEqual(String(form.get("passphrase") || "").trim(), pp)) {
await spendBudget(env);
return html(page(kind, "Wrong passphrase."), 403);
}
return null;
}
__name(gate, "gate");
function setupIntro(origin) {
const host = new URL(origin).hostname;
return `<h2>Values for your Tesla developer app</h2>
<table><tr><td>Allowed origin</td><td><code>${escapeHtml2(origin)}</code></td></tr>
<tr><td>Allowed redirect URI</td><td><code>${escapeHtml2(origin)}/tesla/callback</code></td></tr>
<tr><td>Virtual key pairing link (after sign-in)</td><td><code>https://tesla.com/_ak/${escapeHtml2(host)}</code></td></tr></table>`;
}
__name(setupIntro, "setupIntro");
async function setup(env, url) {
const lines = [];
try {
if (!env.TESLA_CLIENT_ID || !env.TESLA_CLIENT_SECRET) {
return "Set TESLA_CLIENT_ID (variable) and TESLA_CLIENT_SECRET (secret) from your Tesla developer app first, then run setup again.";
}
const k = await ensureKey(env);
lines.push(k === "created" ? "App key: created and stored (keep it: replacing it un-pairs your car)." : `App key: OK (${k === "secret" ? "from secret TESLA_PRIVATE_KEY" : "stored"}).`);
const partnerToken = await getPartnerToken(env);
lines.push("Tesla partner token: OK");
const reg = await fleet(env, partnerToken, "POST", "/api/1/partner_accounts", { domain: url.hostname });
lines.push(`Register ${url.hostname}: HTTP ${reg.status}${reg.status === 200 ? " (registered)" : ` ${reg.text.slice(0, 300)}`}`);
const check = await fleet(env, partnerToken, "GET", `/api/1/partner_accounts/public_key?domain=${encodeURIComponent(url.hostname)}`);
let match = "";
try {
const onFile = JSON.parse(check.text).response?.public_key;
const ours = await rawPublicKeyHex(await publicKeyPem(env));
match = onFile ? onFile.toLowerCase() === ours ? " (matches this bridge's key)" : " (DOES NOT match this bridge's key)" : "";
} catch {
}
lines.push(`Public key Tesla has on file: HTTP ${check.status}${match}`);
lines.push("", "Next: open /tesla/start and sign in, then pair the key from your phone:", `https://tesla.com/_ak/${url.hostname}`);
} catch (err) {
lines.push(`Error: ${err.message}`);
}
return lines.join("\n");
}
__name(setup, "setup");
async function rawPublicKeyHex(pem) {
const der = Uint8Array.from(atob(pem.replace(/-----[^-]+-----/g, "").replace(/\s+/g, "")), (c) => c.charCodeAt(0));
return [...der.slice(-65)].map((b) => b.toString(16).padStart(2, "0")).join("");
}
__name(rawPublicKeyHex, "rawPublicKeyHex");
async function bridgeStatus(env) {
const lines = [`Bridge ${BRIDGE_VERSION}`];
const channels = configuredChannels(env).map((c) => c.name);
try {
const token = await getAccessToken(env);
lines.push("Tesla sign-in: OK");
const vehicles = await env.TESLA_KV.get("tesla:vehicles", "json") || [];
if (!vehicles.length) return [...lines, "No vehicles stored. Run /tesla/start again."].join("\n");
const res = await fleet(env, token, "POST", "/api/1/vehicles/fleet_status", { vins: vehicles.map((v) => v.vin) });
const data = JSON.parse(res.text || "{}").response || {};
for (const v of vehicles) {
const tail = String(v.vin).slice(-6);
const paired = (data.key_paired_vins || []).includes(v.vin);
const info = (data.vehicle_info || {})[v.vin] || {};
lines.push(`VIN ending ${tail}: virtual key ${paired ? "PAIRED" : "NOT paired"}` + (info.firmware_version ? `, firmware ${info.firmware_version}` : "") + (info.vehicle_command_protocol_required !== void 0 ? `, signed commands required: ${info.vehicle_command_protocol_required}` : "") + (info.fleet_telemetry_version ? `, telemetry ${info.fleet_telemetry_version}` : ""));
}
if (res.status !== 200) lines.push(`fleet_status: HTTP ${res.status}`);
lines.push(`Wakes used today: ${await wakesUsedToday(env)}`);
} catch (err) {
lines.push(`Error: ${err.message}`);
}
lines.push(`Phone approvals: ${channels.length ? channels.join(", ") : "NONE configured (sensitive commands refuse; see /phone)"}`);
return lines.join("\n");
}
__name(bridgeStatus, "bridgeStatus");
async function phoneSetup(env, origin, form) {
const parts = [];
const names = configuredChannels(env).map((c) => c.name);
if (!names.length) {
parts.push(`<p><b>No approval channel is configured.</b> Sensitive commands (unlock, trunk, remote start, garage and so on) refuse until you set one up. Configure any of: self-hosted ntfy (<code>NTFY_SERVER</code>, <code>NTFY_TOKEN</code>), Telegram (<code>TELEGRAM_BOT_TOKEN</code>), Pushover (<code>PUSHOVER_APP_TOKEN</code>, <code>PUSHOVER_USER_KEY</code>), or email (binding <code>EMAIL</code>, <code>APPROVAL_EMAIL_FROM</code>, <code>APPROVAL_EMAIL_TO</code>).</p>`);
} else {
parts.push(`<p>Approval requests go to: <b>${escapeHtml2(names.join(", "))}</b>. The first Approve or Deny wins.</p>`);
}
if (names.includes("ntfy")) {
const topic = await ntfyTopic(env);
parts.push(`<h2>ntfy</h2><ol>
<li>Install <b>ntfy</b> and open it. Add your server login under Settings \u2192 Manage users, if your server needs one.</li>
<li>Tap <b>+</b>, tick <b>Use another server</b>, enter <code>${escapeHtml2(env.NTFY_SERVER)}</code>, and this topic exactly:<pre>${escapeHtml2(topic)}</pre></li>
<li>Allow notifications, and on Android set the app's battery use to <b>Unrestricted</b>.</li></ol>`);
}
if (names.includes("Telegram")) {
let pairing = "";
if (form?.get("action") === "telegram") {
try {
const p = await telegramStartPairing(env, origin);
pairing = `<p>Open this link on your phone and press <b>Start</b> (valid 15 minutes):<br><a href="${escapeHtml2(p.link)}">${escapeHtml2(p.link)}</a></p>`;
} catch (err) {
pairing = `<p>Telegram error: ${escapeHtml2(err.message)}</p>`;
}
}
const paired = await telegramChatId(env);
parts.push(`<h2>Telegram</h2><p>${paired ? "Paired with a chat." : "Not paired yet."}</p>${pairing}
<form method="post"><input type="hidden" name="action" value="telegram"><input type="password" name="passphrase" placeholder="Passphrase" required> <button>${paired ? "Re-pair Telegram" : "Pair Telegram"}</button></form>`);
}
if (names.includes("Pushover")) parts.push(`<h2>Pushover</h2><p>Configured. Requests arrive as high-priority alerts with a link to approve or deny.</p>`);
if (names.includes("email")) parts.push(`<h2>Email</h2><p>Configured. Requests arrive by email with a link to approve or deny. Email can be slow; requests expire after 90 seconds.</p>`);
return { text: "", html: parts.join("\n") };
}
__name(phoneSetup, "phoneSetup");
async function approvalPage(env, id, token) {
const rec = await peekApproval(env, id, token);
if (!rec) return html(simplePage("Car command", "Unknown or invalid approval link."), 404);
if (rec.expired || rec.status !== "pending") {
return html(simplePage("Car command", rec.expired ? "This request expired. Ask again." : `This request is already ${rec.status}.`), 410);
}
const q = `?t=${encodeURIComponent(token)}`;
return html(`<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><meta name="robots" content="noindex, nofollow">
<title>Approve car command?</title><style>body{font-family:system-ui,sans-serif;max-width:480px;margin:40px auto;padding:0 16px}button{font-size:20px;padding:14px 22px;margin:8px 8px 0 0}</style>
<h1>Approve car command?</h1><p><b>${escapeHtml2(rec.description)}</b></p><p>If you didn't ask for this, deny it.</p>
<form method="post" action="/approval/${escapeHtml2(id)}/approve${escapeHtml2(q)}" style="display:inline"><button>\u2705 Approve</button></form>
<form method="post" action="/approval/${escapeHtml2(id)}/deny${escapeHtml2(q)}" style="display:inline"><button>\u274C Deny</button></form>`);
}
__name(approvalPage, "approvalPage");
async function getPartnerToken(env) {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: env.TESLA_CLIENT_ID,
client_secret: env.TESLA_CLIENT_SECRET,
scope: PARTNER_SCOPES,
audience: fleetBase(env)
});
const res = await fetch(TOKEN_URL, { method: "POST", body });
const data = await res.json().catch(() => ({}));
if (!res.ok || !data.access_token) {
throw new Error(`partner token failed: HTTP ${res.status} ${data.error || ""} ${data.error_description || ""}`.trim());
}
return data.access_token;
}
__name(getPartnerToken, "getPartnerToken");
async function fleet(env, token, method, path, body) {
const res = await fetch(fleetBase(env) + path, {
method,
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
body: body ? JSON.stringify(body) : void 0
});
return { status: res.status, text: await res.text() };
}
__name(fleet, "fleet");
var SETUP = { title: "Tesla bridge setup", intro: "Creates this bridge's app key (first time only) and registers this bridge's address with Tesla. Safe to run again.", button: "Run setup" };
var SIGNIN = { title: "Tesla sign-in", intro: "Signs this bridge in to your Tesla account and grants it access to your car. Run it again any time the bridge reports it's signed out.", button: "Sign in with Tesla" };
var STATUS = { title: "Tesla bridge status", intro: "Read-only: checks the Tesla sign-in, whether your car has this bridge's key paired, and which approval channels are set up. Never wakes or commands the car.", button: "Check status" };
var PHONE = { title: "Phone approvals", intro: "Sets up where approval requests for sensitive commands are sent.", button: "Show phone setup" };
var ESC = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" };
var escapeHtml2 = /* @__PURE__ */ __name((s) => String(s).replace(/[&<>"']/g, (c) => ESC[c]), "escapeHtml");
function page(kind, result = "", extraHtml = "") {
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width">
<meta name="robots" content="noindex, nofollow"><title>${kind.title}</title>
<style>body{font-family:system-ui,sans-serif;max-width:680px;margin:40px auto;padding:0 16px;line-height:1.5}
input,button{font:inherit;padding:8px}pre{background:#f3f3f3;padding:12px;white-space:pre-wrap;word-break:break-word}
td{padding:4px 12px 4px 0;vertical-align:top}code{word-break:break-all}</style>
<h1>${kind.title}</h1>
<p>${kind.intro}</p>
<form method="post"><input type="password" name="passphrase" placeholder="Passphrase" autocomplete="current-password" required>
<button>${kind.button}</button></form>
${result ? `<pre>${escapeHtml2(result)}</pre>` : ""}${extraHtml || ""}`;
}
__name(page, "page");
function simplePage(title, text2) {
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width"><title>${escapeHtml2(title)}</title>
<body style="font-family:system-ui,sans-serif;max-width:480px;margin:40px auto;padding:0 16px"><h1>${escapeHtml2(title)}</h1><p>${escapeHtml2(text2)}</p>`;
}
__name(simplePage, "simplePage");
function html(body, status = 200) {
return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8", "cache-control": "no-store" } });
}
__name(html, "html");
export {
index_default as default
};
Version 0.7.1. Scroll the box to read it first; you don't need to change anything in it.
- Sign in to dash.cloudflare.com, or create a free account.
- Open Workers & Pages, choose Create application, and start from the Hello World template. Name it something like
tesla-bridge, then deploy it. - Choose Edit code. Select everything in the editor, delete it, paste in the bridge code, and deploy again.
- Open your Worker's address, shown on its overview page. It looks like
https://tesla-bridge.your-name.workers.dev.
The page should show one line, tesla-mcp-bridge 0.7.1. That's your bridge running. I'll call its address "your bridge address" from here on.
Pick an address you're happy to keep. Tesla registers your bridge by its address, and your car pairs with it. Changing the Worker's name or your workers.dev subdomain later means redoing Steps 3 to 7. A brand-new address can also show a security error for about a minute while its certificate is issued. Wait, then reload.
2Add storage and your passphrase
- Open Workers KV (under Storage & databases), choose Create instance, give it any name, and create it.
- Back on your Worker, open the Bindings tab and choose Add binding, then KV namespace. For Variable name, enter exactly
TESLA_KV, select the storage you just created, and choose Add binding. - Open Settings → Variables and Secrets and choose Add. Set the type to Secret, name it
PROXY_PASSPHRASE, and paste a long passphrase, such as five or six random words. It must be at least 16 characters; the bridge refuses to work with anything shorter. Save it in your password manager. It's what stops anyone else from connecting an assistant to your car.
Open your bridge address/setup. It shows a small form and a table of three values. Keep this tab open for the next step.
3Create your Tesla developer app
- Go to developer.tesla.com and sign in with your normal Tesla account (the one that owns the car). If it says an account already exists when you try to register, choose Sign in instead. Turn on two-factor sign-in if Tesla asks.
- Open the Dashboard and choose Create New Application.
- Give it a name and description. For purpose, something like "Personal use for my own vehicle. Lets my AI assistant read its status and send commands I request."
- For OAuth Grant Type, choose Authorization code and machine-to-machine. The bridge needs both.
- For Allowed Origin and Allowed Redirect URI, copy the values from your bridge's
/setuppage: your bridge address, and your bridge address followed by/tesla/callback. Leave Allowed Returned URL empty. - For scopes, tick Vehicle Information, Vehicle Location, Vehicle Commands and Vehicle Charging Management.
- Add your billing details if Tesla asks, and create the app.
The app page now shows a Client ID and a Client Secret.
4Give the bridge your Tesla app details
- On your Worker, open Settings → Variables and Secrets and choose Add.
- Type Text, name
TESLA_CLIENT_ID, value: your Client ID. - Choose Add again. Type Secret, name
TESLA_CLIENT_SECRET, value: your Client Secret. Use the copy button next to it on Tesla's page. - Deploy, or save, so the new values take effect.
Outside North America? Add one more Text variable, FLEET_API_BASE. For Europe, the Middle East and Africa, use https://fleet-api.prd.eu.vn.cloud.tesla.com. North America and Asia-Pacific (except China) use the default, so there's nothing to add. I've only tested North America.
5Run setup on the bridge
- Back on your bridge's
/setuppage, enter your passphrase and choose Run setup.
The first time, the bridge creates its own app key (a key pair, like the one in your phone key) and stores it in your Cloudflare storage. Then it registers your bridge address with Tesla. You should see Register ...: HTTP 200 (registered) and Public key Tesla has on file: HTTP 200 (matches this bridge's key). It's safe to run again; it never replaces the key.
6Sign in to Tesla
- Open
your bridge address/tesla/start, enter your passphrase and choose Sign in with Tesla. - Sign in to Tesla. On the consent screen, make sure every permission is ticked, especially Vehicle Location and Vehicle Commands, and allow it.
You land back on the bridge, which shows Signed in to Tesla, the permissions you granted, and your car (by the last six digits of its VIN). Do this before Step 7: Tesla won't let your car accept the bridge's key until you've granted the app access.
7Pair the key with your car
- Go to your car with your phone. The Tesla app must be signed in to the same account.
- On your phone, open
https://tesla.com/_ak/followed by your bridge's host name, for examplehttps://tesla.com/_ak/tesla-bridge.your-name.workers.dev. The exact link is on your/setuppage. - It opens the Tesla app with an Add Virtual Key prompt for your app. Approve it, then tap your key card on the card reader when the car asks.
To check it, open your bridge address/status and choose Check status. It should say virtual key PAIRED. In the car, the key also appears under Controls → Locks.
8Set up phone approvals (choose one or more)
Unlock, remote start, opening the trunk or frunk, venting the windows, turning Sentry off, the garage (HomeLink), Valet Mode, PIN to Drive, Speed Limit Mode and Guest Mode are sensitive. Your assistant can ask for them, but they only run after you tap Approve on your phone within 90 seconds. Each request works once, for that exact command. Set up any of these. If you set up several, requests go to all of them, and the first tap wins.
- ntfy (self-hosted, free): Approve and Deny buttons right in the notification. Tested by me on the car.
- Telegram (free): Approve and Deny buttons in a private chat with your own bot. Built and tested against a simulated Telegram, not on the car.
- Pushover (one-time app purchase): a priority alert that opens an Approve or Deny page. Built and tested against a simulated Pushover, not on the car.
- Email (free, needs a domain on Cloudflare): an email that opens an Approve or Deny page. The slowest option. Built and tested offline, not on the car.
Telegram (the easiest free option):
- In Telegram, message @BotFather, send
/newbot, and follow the prompts. It gives you a bot token. - On your Worker, add a Secret named
TELEGRAM_BOT_TOKENwith that token, and deploy. - Open
your bridge address/phone, enter your passphrase, choose Show phone setup, then Pair Telegram. Open the link it shows on your phone and press Start. The bot replies that it's paired. Only that chat can approve.
ntfy (needs a computer that's always on):
- Run your own ntfy server with logins required, and give it a public HTTPS address. I use a home server with ntfy's own install guide and Tailscale Funnel for the address. Create a login for yourself, and a separate user with an access token that may only publish to topics starting with
car-. - On your Worker, add a Text variable
NTFY_SERVER(your server's address) and a SecretNTFY_TOKEN(that access token). - Open
/phoneon your bridge to see your topic, then subscribe to it in the ntfy app with your own login. On Android, set the ntfy app's battery use to Unrestricted.
Don't use the public ntfy.sh server for this. In my testing it often refused messages from Cloudflare, with errors 522 and 429 (a known issue), so approvals never arrived.
Pushover: buy the Pushover app, create an application at pushover.net, then add the Secrets PUSHOVER_APP_TOKEN (the application's token) and PUSHOVER_USER_KEY (your user key).
Email: needs a domain whose DNS is on Cloudflare with Email Routing turned on, and your own address added as a verified destination. On your Worker, add a Send Email binding named EMAIL, plus Text variables APPROVAL_EMAIL_FROM (an address on that domain) and APPROVAL_EMAIL_TO (your address).
Your /status page lists which channels are set up.
9Connect your assistant
Every assistant uses the same address: your bridge address/mcp, for example https://tesla-bridge.your-name.workers.dev/mcp. When you connect, your bridge shows an Authorize page. Check that it names the assistant you're actually using, then enter your passphrase. The steps for each assistant are in the next section.
10Try it
Start with something harmless: "What's my car's status?" This never wakes the car; if it's asleep, you get the last known data and how old it is. Then try "flash the lights", which is the first real signed command. If the lights flash, everything works.
Connect your assistant
Claude (web, desktop and mobile)
- Go to Customize → Connectors and choose Add custom connector. On a Team or Enterprise plan, an Owner adds it under Organization settings → Connectors.
- Enter your
/mcpaddress. If it asks how Claude should identify itself, choose Register automatically and leave the client ID and secret empty. - Connect, and approve on your bridge's Authorize page (it says
claude.ai). Connectors carry over to the Claude mobile and desktop apps.
Tested by me on the car.
ChatGPT
- Custom connectors need Developer mode, on Plus, Pro, Business, Enterprise or Edu plans. Open Settings → Connectors → Advanced and turn on Developer mode. OpenAI moves this toggle now and then; if it isn't there, look under Settings for Developer mode.
- Choose Add custom connector (or Create), give it a name, enter your
/mcpaddress, and choose OAuth for authentication. - Approve on your bridge's Authorize page (it says
chatgpt.com). To use it, start a new chat, click +, open Developer mode and pick your connector.
Uses the same standard sign-in as Claude and Grok. I checked it against OpenAI's documentation but haven't tested it myself.
Gemini
- Custom apps currently need you to be 18 or over and in the US, using a personal Google account with Keep Activity turned on. You can only add them in the Gemini web app.
- On a computer, go to gemini.google.com → Settings → Connected apps. Under Custom apps, choose Add a custom app, enter your
/mcpaddress and choose Next. - Approve on your bridge's Authorize page (it names Google's sign-in relay,
oauth-redirect.googleusercontent.com). Once connected, it works in the Gemini mobile app too.
Uses the same standard sign-in. I checked it against Google's documentation but haven't tested it myself.
Grok (app, web and in the car)
- Go to grok.com/connectors and choose New Connector, then Custom.
- Enter a name and your
/mcpaddress, then approve on your bridge's Authorize page (it saysgrok.com). - Connectors you add to your Grok account carry over to Grok in your Tesla once you're signed in there.
Tested by me in the Grok app (it flashed the lights). For Grok in the car, see my Grok and Google Home guide, where in-car connectors are tested.
What you can say
- Status: "Is the car locked?", "How much charge do I have?", "Where's my car?", "What's the tire pressure?"
- Climate: "Warm the car up to 70", "Turn on dog mode", "Heat the driver's seat on high", "Defrost the car"
- Charging: "Set the charge limit to 80 percent", "Stop charging", "Open the charge port"
- Navigation: "Send Whole Foods in Gainesville to my car", "Navigate to the nearest Supercharger"
- Locks and alerts: "Lock the car", "Close the trunk" (powered trunks), "Honk the horn", "Flash the lights"
- With phone approval: "Unlock the car", "Open the trunk", "Open the garage"
Under the hood there are 12 tools: status, wake, climate, charging, navigation, access (locks, trunks, windows), security modes, alerts, media, vehicle settings, nearby chargers, and checking an approval.
How it treats your car (sleep, cost, navigation)
- Status never wakes the car. If it's asleep, you get the last known data and its age. Commands wake it once if needed. Wakes are capped at 50 a day (change it with a Text variable
WAKE_BUDGET_PER_DAY). - Cost. Tesla bills Fleet API use, with a $10 monthly credit. At the time of writing, a wake costs about 2 cents, a status read about 0.2 cents and a command about 0.1 cents (Tesla's current prices). Normal personal use stays inside the credit. Wakes are the expensive part, which is why status reads never wake.
- Destinations sent while nobody's in the car wait. Like sharing an address from your phone, the car shows it as "drive to begin" and applies it when someone gets in. Once you're in the car, a new destination replaces the current one right away.
- The current route only shows up while you're in Drive. In Park, Tesla doesn't report the active route, so your assistant may say nothing is set even when it is.
- One car. The bridge uses the first car on your Tesla account.
Troubleshooting: Tesla AI connector not working
"Adding a virtual key is forbidden because you have not granted ... access to your account"
The Tesla app shows this when you pair (Step 7) before signing in (Step 6). Open /tesla/start, sign in and allow every permission, then open the pairing link again.
"This bridge's key isn't paired with the car. Pair it again from the Tesla app."
The car doesn't have your bridge's key. Redo Step 7 at the car, and check /status says PAIRED.
"The bridge isn't configured yet: set the secret PROXY_PASSPHRASE (at least 16 characters)."
The passphrase secret is missing or too short. Set PROXY_PASSPHRASE as a Secret (Step 2), deploy, and reload.
"Storage isn't connected: add a KV namespace binding named TESLA_KV."
The storage binding is missing or misspelled. It must be exactly TESLA_KV, on the Bindings tab (Step 2).
Setup says "partner token failed" or the key "DOES NOT match"
"partner token failed" usually means the Client ID or Secret is wrong: paste them again (Step 4). If the key doesn't match, your bridge address isn't the one in your Tesla app's Allowed Origin, or the app was registered with an older key. Check that the Allowed Origin is exactly your bridge address, then run setup again.
"Too many attempts. Try again within the hour."
The bridge locks its sign-in pages for the rest of the hour after repeated wrong passphrases, to stop guessing. Wait, then use the passphrase from your password manager.
"Phone approvals aren't set up on this bridge"
You asked for a sensitive command, but no approval channel is configured, so nothing was sent to the car. Set one up (Step 8), or use the Tesla app.
"Couldn't send the phone notification (ntfy: ntfy HTTP 403 ...)"
Your ntfy server refused the bridge's token. The publishing user needs write permission for topics starting with car-, and the token must belong to that user. A 522 or 429 means the server couldn't be reached from Cloudflare; see the ntfy.sh warning in Step 8.
The approval arrives, but tapping Approve says "expired"
Requests expire after 90 seconds. Ask again and tap sooner. Email can arrive too late to use; Telegram or ntfy are faster.
"The car is asleep" or "The car didn't wake up within 45 seconds"
The car may have no signal (a garage with no reception, for example). Status still shows the last known data. Try again when the car is somewhere with signal.
Your assistant doesn't show a new action after an update
Assistants remember a connector's tools from when you added it. Remove the connector and add it again. Your passphrase is all you need.
Adding the connector fails, or mentions "redirect host not allowed"
The bridge only sends sign-ins back to known assistants: Claude, ChatGPT, Gemini and Grok. If you use a different MCP app, add its sign-in host to a Text variable ALLOWED_REDIRECT_HOSTS (comma-separated). That replaces the built-in list, so include the ones you still use.
Security and privacy
- It runs in your accounts. The bridge is your own Cloudflare Worker. Your Tesla sign-in and the app key live in your Cloudflare storage and secrets, never on my servers.
- Your passphrase gates every assistant. No assistant can connect without your passphrase on the bridge's Authorize page, and it only sends sign-ins back to the assistants listed above. Tokens are stored hashed.
- Sensitive commands need your tap. Unlock, remote start, trunks, garage and the others only run after you approve on your phone. Your assistant can't approve for you, and neither can a message or email that tricks your assistant into asking. Each approval works once, for that exact command, for 90 seconds.
- What it can never do. It has no command to erase the car's data, to add or remove keys, or to share access to your car.
- Every command is signed. Commands are signed by your bridge's key and checked by the car itself. A command that isn't signed by a key your car paired does nothing.
- What others see. Tesla sees your API requests, as with any Tesla app. Your AI assistant's company sees what the tools return, including your car's location when you ask for it. Your approval channel sees the request text, like "Unlock the car".
If you think someone else has access
- Change your passphrase (the
PROXY_PASSPHRASEsecret). This disconnects every assistant at once; reconnect yours with the new passphrase. - Remove the key from your car under Controls → Locks. The bridge can't send commands until you pair again.
- Revoke the app's access to your Tesla account in your Tesla account settings, then sign in again at
/tesla/startwhen you're ready. - Regenerate the Client Secret on your Tesla developer app and update
TESLA_CLIENT_SECRET.
Disclaimer
Use at your own risk. The bridge is provided as-is, without warranty of any kind, under the MIT license included in the code. It is not a security or safety system. Don't rely on it to secure your car or to protect people, pets or property.
Things outside my control can change. Tesla, Cloudflare and the AI companies can change or stop the services this depends on at any time. A few commands use protocol extensions that Tesla doesn't officially document, so they could stop working without notice. This project isn't affiliated with or endorsed by Tesla.
You're responsible for your setup: your accounts, your credentials, your car, and any Tesla API charges.
FAQ
Do I need to know how to code?
No. You paste the code into Cloudflare once and configure everything in dashboards. You never edit the code.
What does it cost?
Cloudflare's free plan is enough. Tesla bills Fleet API use but gives a $10 monthly credit, which normal personal use stays inside. Your AI assistant needs a plan with custom connectors (Claude works on any plan; ChatGPT needs Plus or higher). The approval channel is free, except Pushover's one-time app purchase.
Which cars does it work with?
Any Tesla that works with the Fleet API and the Tesla app. I tested a 2023 Model 3. Model 3, Model Y and 2021 or newer Model S and X need signed commands, which the bridge does. Older Model S and X may not need signing; I haven't tested them.
Can my assistant unlock my car?
Only if you tap Approve on your phone within 90 seconds. Without an approval channel set up, unlock is refused.
Will it drain my battery?
No. Status reads never wake the car. Only commands wake it, and at most 50 times a day.
Can I use it with more than one assistant at the same time?
Yes. Connect each one with the same /mcp address and passphrase.
Does it work outside North America?
It should in Europe, the Middle East and Africa with FLEET_API_BASE set (Step 4), but I've only tested North America. China uses a separate Tesla system and isn't supported.
Why does it need a passphrase if the assistants have their own logins?
Anyone can find a workers.dev address. The passphrase makes sure only you can connect an assistant to your bridge, and so to your car.
Version history
If a security fix ships, it's listed here. To update, copy the code again and paste it over the old code. Your settings, key and pairing stay.
| Version | Changes |
|---|---|
| 0.7.1 | First public release. |
The bridge is MIT licensed; its protocol schema comes from Tesla's and Teslemetry's Apache-2.0 protocol definitions (see the notice in the code). Questions? contact@fullyautomatedthings.com