node-red-contrib-tesla
Version:
Node red module to control Tesla vehicles and devices
299 lines (271 loc) • 13.8 kB
JavaScript
const axios = require('axios').default;
const tjs = require('teslajs');
module.exports = function (RED) {
const STATE_ASLEEP = 'asleep';
let localUserCache = {};
const isTokenExpired = (tokenObj) => tokenObj.created_at + tokenObj.expires_in < Math.round(new Date() / 1000);
const isTokenValid = tokenObj => tokenObj && tokenObj.access_token && !isTokenExpired(tokenObj);
const getNewTokenFromApi = async (email, refreshToken) => {
console.log('Tesla API: doing api call to fetch new access token');
try {
const response = await axios.post('https://auth.tesla.com/oauth2/v3/token', {
"grant_type": "refresh_token",
"client_id": "ownerapi",
"refresh_token": refreshToken,
"scope": "openid email offline_access"
});
const tokenObj = {
access_token: response.data.access_token,
expires_in: response.data.expires_in,
created_at: Math.round(new Date() / 1000)
};
if (isTokenValid(tokenObj)) {
localUserCache[email] = tokenObj;
return tokenObj;
} else {
throw Error('Tesla API: Get access token failed, the new token appears to be invalid from some reason.');
}
} catch (err) {
throw Error('Tesla API: Get access token failed. err: '.err);
}
};
const getAccessToken = async (email, refresh_token) => {
if (!localUserCache.hasOwnProperty(email)) {
console.debug('Tesla API: No token found. Getting new token now...');
return getNewTokenFromApi(email, refresh_token).then(token => token.access_token);
} else {
if (isTokenValid(localUserCache[email])) {
return Promise.resolve(localUserCache[email]).then(token => token.access_token);
} else {
console.debug('Tesla API: Token found but expired. Getting new token now...');
return getNewTokenFromApi(email, refresh_token).then(token => token.access_token);
}
}
};
const wakeUp = async (authToken, vehicleID, retry = 0) => {
let state;
if (!retry) {
const vehicleData = await tjs.vehicleAsync({authToken, vehicleID});
state = vehicleData.state;
}
if (retry > 0 || state === STATE_ASLEEP) {
console.debug('Tesla API: trying to wakeup the car. retry: ' + retry);
const response = await tjs.wakeUpAsync({authToken, vehicleID});
if (response.state === STATE_ASLEEP) {
await new Promise((resolve => setTimeout(() => resolve(), 5000)));
retry++;
if (retry < 5) {
console.debug('Tesla API: Wakeup retry: ' + retry);
await wakeUp(authToken, vehicleID, retry);
}
}
}
}
const doCommandAndAutoWake = async (command, authToken, vehicleID, autoWakeUp, commandArgs) => {
const commandsNoWakeup = ['vehicle', 'wakeUp']; // these commands do not need the car to be awake
if (autoWakeUp && !commandsNoWakeup.includes(command)) {
await wakeUp(authToken, vehicleID);
}
switch (command) {
case 'vehicle':
return tjs.vehicleAsync({authToken, vehicleID});
case 'vehicleData':
return tjs.vehicleDataAsync({authToken, vehicleID});
case 'chargeState':
return tjs.chargeStateAsync({authToken, vehicleID});
case 'climateState':
return tjs.climateStateAsync({authToken, vehicleID});
case 'vehicleConfig':
return tjs.vehicleConfigAsync({authToken, vehicleID});
case 'vehicleState':
return tjs.vehicleStateAsync({authToken, vehicleID});
case 'driveState':
return tjs.driveStateAsync({authToken, vehicleID});
case 'guiSettings':
return tjs.guiSettingsAsync({authToken, vehicleID});
case 'wakeUp':
return tjs.wakeUpAsync({authToken, vehicleID});
case 'chargeStandard':
return tjs.chargeStandardAsync({authToken, vehicleID});
case 'chargeMaxRange':
return tjs.chargeMaxRangeAsync({authToken, vehicleID});
case 'doorLock':
return tjs.doorLockAsync({authToken, vehicleID});
case 'doorUnlock':
return tjs.doorUnlockAsync({authToken, vehicleID});
case 'climateStart':
return tjs.climateStartAsync({authToken, vehicleID});
case 'climateStop':
return tjs.climateStopAsync({authToken, vehicleID});
case 'flashLights':
return tjs.flashLightsAsync({authToken, vehicleID});
case 'honkHorn':
return tjs.honkHornAsync({authToken, vehicleID});
case 'maxDefrost':
return tjs.maxDefrostAsync({authToken, vehicleID});
case 'mediaTogglePlayback':
return tjs.mediaTogglePlaybackAsync({authToken, vehicleID});
case 'mediaPlayNext':
return tjs.mediaPlayNextAsync({authToken, vehicleID});
case 'mediaPlayPrevious':
return tjs.mediaPlayPreviousAsync({authToken, vehicleID});
case 'mediaPlayNextFavorite':
return tjs.mediaPlayNextFavoriteAsync({authToken, vehicleID});
case 'mediaPlayPreviousFavorite':
return tjs.mediaPlayPreviousFavoriteAsync({authToken, vehicleID});
case 'mediaVolumeUp':
return tjs.mediaVolumeUpAsync({authToken, vehicleID});
case 'mediaVolumeDown':
return tjs.mediaVolumeDownAsync({authToken, vehicleID});
case 'mobileEnabled':
return tjs.mobileEnabledAsync({authToken, vehicleID});
case 'navigationRequest':
return tjs.navigationRequestAsync({
authToken,
vehicleID
}, commandArgs.subject, commandArgs.text, commandArgs.locale);
case 'nearbyChargers':
return tjs.nearbyChargersAsync({authToken, vehicleID});
case 'openChargePort':
return tjs.openChargePortAsync({authToken, vehicleID});
case 'openFrunk':
return tjs.openTrunkAsync({authToken, vehicleID}, "frunk");
case 'openTrunk':
return tjs.openTrunkAsync({authToken, vehicleID}, "trunk");
case 'remoteStart':
return tjs.remoteStartAsync({authToken, vehicleID}, commandArgs.password);
case 'resetValetPin':
return tjs.resetValetPinAsync({authToken, vehicleID});
case 'scheduleSoftwareUpdate':
return tjs.scheduleSoftwareUpdateAsync({authToken, vehicleID}, commandArgs.offset);
case 'seatHeater':
return tjs.seatHeaterAsync({authToken, vehicleID}, commandArgs.heater, commandArgs.level);
case 'setChargeLimit':
return tjs.setChargeLimitAsync({authToken, vehicleID}, commandArgs.amt);
case 'setChargingAmps':
return tjs.setChargingAmpsAsync({authToken, vehicleID}, commandArgs.amps);
case 'setScheduledCharging':
return tjs.setScheduledChargingAsync({authToken, vehicleID}, commandArgs.enable, commandArgs.time);
case 'setScheduledDeparture':
return tjs.setScheduledDepartureAsync({
authToken,
vehicleID
}, commandArgs.enable, commandArgs.departure_time, commandArgs.preconditioning_enabled, commandArgs.preconditioning_weekdays_only, commandArgs.off_peak_charging_enabled, commandArgs.off_peak_charging_weekdays_only, commandArgs.end_off_peak_time);
case 'setSentryMode':
return tjs.setSentryModeAsync({authToken, vehicleID}, commandArgs.onoff);
case 'setTemps':
return tjs.setTempsAsync({authToken, vehicleID}, commandArgs.driver, commandArgs.pass);
case 'setValetMode':
return tjs.setValetModeAsync({authToken, vehicleID}, commandArgs.onoff, commandArgs.pin);
case 'speedLimitActivate':
return tjs.speedLimitActivateAsync({authToken, vehicleID}, commandArgs.pin);
case 'speedLimitDeactivate':
return tjs.speedLimitDeactivateAsync({authToken, vehicleID}, commandArgs.pin);
case 'speedLimitClearPin':
return tjs.speedLimitClearPinAsync({authToken, vehicleID}, commandArgs.pin);
case 'speedLimitSetLimit':
return tjs.speedLimitSetLimitAsync({authToken, vehicleID}, commandArgs.limit);
case 'startCharge':
return tjs.startChargeAsync({authToken, vehicleID});
case 'steeringHeater':
return tjs.steeringHeaterAsync({authToken, vehicleID}, commandArgs.level);
case 'stopCharge':
return tjs.stopChargeAsync({authToken, vehicleID});
case 'sunRoofControl':
return tjs.sunRoofControlAsync({authToken, vehicleID}, commandArgs.state);
case 'sunRoofMove':
return tjs.sunRoofMoveAsync({authToken, vehicleID}, commandArgs.percent);
case 'windowControl':
return tjs.windowControlAsync({authToken, vehicleID}, commandArgs.command);
case 'vinDecode':
return tjs.vinDecode(await tjs.vehicleAsync({authToken, vehicleID}));
case 'getModel':
return tjs.getModel(await tjs.vehicleAsync({authToken, vehicleID}));
case 'getPaintColor':
return tjs.getPaintColor(await tjs.vehicleAsync({authToken, vehicleID}));
default:
throw Error(`Tesla API: Invalid command specified: ${command}`);
}
};
function TeslaConfigNode(node) {
RED.nodes.createNode(this, node);
this.email = node.email;
if (this.email && this.credentials.refresh_token) {
getAccessToken(this.email, this.credentials.refresh_token)
.then(() => console.debug('Tesla API: access token OK'))
.catch(err => console.error('Tesla API: getting access token failed, context:', err));
}
}
RED.nodes.registerType("tesla-config", TeslaConfigNode, {
credentials: {
refresh_token: {type: "password"}
}
});
function TeslaApiNode(config) {
RED.nodes.createNode(this, config);
const node = this;
this.teslaConfig = RED.nodes.getNode(config.teslaConfig);
if (this.teslaConfig) {
node.status({fill: "blue", shape: "ring", text: "Idle"});
node.on('input', async (msg, send, done) => {
send = send || function () {
node.send.apply(node, arguments)
};
const vehicleID = msg.vehicleID || config.vehicleID;
const command = msg.command || config.command;
const autoWakeUp = msg.autoWakeUp || config.autoWakeUp || false;
const email = config.email;
const {refresh_token} = node.teslaConfig.credentials;
const {commandArgs} = msg;
try {
node.status({fill: "blue", shape: "dot", text: "Working"});
const authToken = await getAccessToken(email, refresh_token);
if (command === 'vehicles') {
msg.payload = await tjs.vehiclesAsync({authToken});
} else {
msg.payload = await doCommandAndAutoWake(command, authToken, vehicleID, autoWakeUp, commandArgs);
}
send(msg);
node.status({fill: "green", shape: "ring", text: "Idle"});
} catch (err) {
node.status({fill: "red", shape: "dot", text: String(err).substring(0, 25)});
if (done) {
// Node-RED 1.0 compatible
done(err);
} else {
// Node-RED 0.x compatible
node.error(err, msg);
}
}
});
} else {
node.warn('Tesla API: No tesla config defined');
node.status({fill: "red", shape: "ring", text: "Invalid config"});
}
}
RED.nodes.registerType("tesla-api", TeslaApiNode);
RED.httpAdmin.get("/getvehicles/:node_id", RED.auth.needsPermission("flows.write"), async function (req, res) {
const node = RED.nodes.getNode(req.params.node_id);
if (!node) {
res.json([]);
return;
}
const email = node.teslaConfig.email;
const {refresh_token} = node.teslaConfig.credentials;
if (email && refresh_token) {
const authToken = await getAccessToken(email, refresh_token);
const response = await tjs.vehiclesAsync({authToken});
if (response.length) {
res.json(response.map(item => {
const vehicleId = item.id_s;
const vehicleName = item.display_name
return {id: vehicleId, name: vehicleName};
}));
} else {
console.warn('Tesla API: Error getting vehicle list')
}
} else {
res.json([]);
}
});
};