Yesterday my 2nd Gen Amazon Echo Dot arrived (check out my kids review of the Echo Dot here) and I thought it’d be fun to try and hook it up to my Renault ZOE so that I could check battery status and preheat it without having to load the clunky app.

Alexa Skills can be hosted on AWS Lambda and there’s an easy-to-follow tutorial in the dev docs. The Renault ZOE doesn’t have an official API but last year I’d used Fiddler to sniff the mobile app and been able to emulate that. Since then their app has just become a webview wrapper so I was a little worried I wouldn’t be able to do this - however, SPAs to the rescue… the app is written in Angular and calls the backend in a really simple JSON API (way better than the previous XML abomination!).

I had a quick Google and found that Terence Eden had already pasted all of the request/response info online which saved me a little effort trawling through. I whipped up a quick node script that calls the API to login and request battery status / preheating. It’s relatively simple, not much code at all (though could do with a little refactoring).

"use strict";

let https = require("https");

let token, vin, zoeUsername, zoePassword, loginFailureCallback;

function sendRequest(action, requestData, successCallback, failureCallback) {
	const options = {
		hostname: "www.services.renault-ze.com",
		port: 443,
		path: "/api" + action,
		method: requestData ? "POST" : "GET",
		headers: {
			"Content-Type": "application/json",
			"Authorization": (token ? "Bearer " + token : "")
		}
	};

	const req = https.request(options, resp => {
		if (resp.statusCode < 200 || resp.statusCode > 300) {
			console.log(`Failed to send request ${action} (${resp.statusCode}: ${resp.statusMessage})`);
			if (failureCallback)
				failureCallback();
			return;
		}

		console.log(`Successful request ${action} (${resp.statusCode}: ${resp.statusMessage})`);
		let respData = "";

		resp.on("data", c => {
			//console.log("<== " + c.toString());
			respData += c.toString();
		});
		resp.on("end", () => {
			if (successCallback)
				successCallback(respData && respData.length ? JSON.parse(respData) : null);
		});
	});
	if (requestData && JSON.stringify(requestData) !== '{}')
		req.write(JSON.stringify(requestData));
	req.end();
}

function login(successCallback) {
	sendRequest("/user/login", {
		username: zoeUsername,
		password: zoePassword
	}, loginResponse => {
		token = loginResponse.token;
		vin = loginResponse.user.vehicle_details.VIN;
		successCallback();
	}, loginFailureCallback);
}

exports.setLogin = (username, password, failureCallback) => {
	zoeUsername = username;
	zoePassword = password;
	loginFailureCallback = failureCallback;
}

exports.getBatteryStatus = (successCallback, failureCallback) => {
	login(() => sendRequest("/vehicle/" + vin + "/battery", null, successCallback, failureCallback));
}

exports.sendPreheatCommand = (successCallback, failureCallback) => {
	login(() => sendRequest("/vehicle/" + vin + "/air-conditioning", {}, successCallback, failureCallback));
}

Next was wiring this up to work the Alexa API. I created a separate JS file that require'd the one above and just handled two simple intents (preheat and battery status). If you ask for help or launch without an intent it just tells you what the options are. Most requests return a card too, so you can see them inside the Alexa app.

Since we have two cars (two ZOEs!), I actually set this up to respond to two different app IDs and connect to the correct account (and rejected any other apps, so if you jokesters find the ID you can’t burn my battery ;)). The usernames/passwords come from environment variables I would set up in Lambda to make it easier to share code without removing them.

"use strict";

const dannyAlexaApp = "amzn1.ask.skill.xxxxx";
const heatherAlexaApp = "amzn1.ask.skill.yyyyy";

let car = require("./car");

function buildResponse(output, card, shouldEndSession) {
	return {
		version: "1.0",
		response: {
			outputSpeech: {
				type: "PlainText",
				text: output,
			},
			card,
			shouldEndSession
		}
	};
}

// Helper to build the text response from battery information.
function buildBatteryStatus(battery) {
	let response = `You have ${battery.charge_level}% battery which will get you approximately ${Math.round(battery.remaining_range * 0.621371)} miles. `;

	if (battery.plugged)
		response += "The car is plugged in";
	else
		response += "The car is not plugged in";

	if (battery.charging)
		response += " and charging";

	return response + ".";
}

exports.handler = (event, context) => {
	// Helper to return a response with a card.		
	const sendResponse = (title, text) => {
		context.succeed(buildResponse(text, {
			"type": "Simple",
			"title": title,
			"content": text
		}));
	};
	
	try {
		console.log(`event.session.application.applicationId=${event.session.application.applicationId}`);

		// Shared callbacks.
		const exitCallback = () => context.succeed(buildResponse("Goodbye!"));
		const helpCallback = () => context.succeed(buildResponse("What would you like to do? You can preheat the car or ask for battery status.", null, false));
		const loginFailureCallback = () => sendResponse("Authorisation Failure", "Unable to login to Renault Z.E. Services, please check your login credentials.");

		// Set login based on the Alexa app ID.
		//   We have two cars, so two activation names ("my car" and "Heather's car").
		//   If you're not either of these apps, you're not allowed to control our cars!
		if (event.session.application.applicationId === dannyAlexaApp) {
			car.setLogin(process.env.ZOE_USER_DANNY, process.env.ZOE_PASS_DANNY, loginFailureCallback);
		} else if (event.session.application.applicationId === heatherAlexaApp) {
			car.setLogin(process.env.ZOE_USER_HEATHER, process.env.ZOE_PASS_HEATHER, loginFailureCallback);
		} else {
			sendResponse("Invalid Application ID", "You are not allowed to use this service.");
			return;
		}

		// Handle launches without intents by just asking what to do.		
		if (event.request.type === "LaunchRequest") {
			helpCallback();
		} else if (event.request.type === "IntentRequest") {
			// Handle different intents by sending commands to the API and providing callbacks.
			switch (event.request.intent.name) {
				case "PreheatIntent":
					car.sendPreheatCommand(
						response => sendResponse("Car Preheat", "The car will begin preheating shortly."),
						() => sendResponse("Car Preheat", "Unable to begin preheating. Have you already done this recently?")
					);
					break;
				case "GetBatteryStatusIntent":
					car.getBatteryStatus(
						battery => sendResponse("Car Battery Status", buildBatteryStatus(battery)),
						() => sendResponse("Car Battery Status", "Unable to get car battery status, please check your login details.")
					);
					break;
				case "AMAZON.HelpIntent":
					helpCallback();
					break;
				case "AMAZON.StopIntent":
				case "AMAZON.CancelIntent":
					exitCallback();
					break;
			}
		} else if (event.request.type === "SessionEndedRequest") {
			exitCallback();
		}
	} catch (err) {
		console.error(err.message);
		sendResponse("Error Occurred", "An error occurred. Fire the programmer! " + err.message);
	}
};

I created two apps in Alexa (both using the same Lambda function) and set up the intent schema:

{
  "intents": [
    { "intent": "PreheatIntent" },
    { "intent": "GetBatteryStatusIntent" },
    { "intent": "AMAZON.HelpIntent" },
    { "intent": "AMAZON.StopIntent" }
  ]
}

… and some utterances:

PreheatIntent pre heat
PreheatIntent preheat
PreheatIntent heat
PreheatIntent heater
PreheatIntent turn the heater on
PreheatIntent warm up
PreheatIntent heat up
PreheatIntent preheat for me
PreheatIntent start the heaters
PreheatIntent turn the heater on
PreheatIntent turn on the heater
PreheatIntent turn the heat on
PreheatIntent turn on the heat
PreheatIntent warm up
PreheatIntent make it toasty for me
PreheatIntent it's cold outside
PreheatIntent heat my seat up
GetBatteryStatusIntent battery status
GetBatteryStatusIntent status
GetBatteryStatusIntent battery
GetBatteryStatusIntent level
GetBatteryStatusIntent battery level
GetBatteryStatusIntent how much range
GetBatteryStatusIntent range
GetBatteryStatusIntent what is your battery status
GetBatteryStatusIntent tell me your status
GetBatteryStatusIntent are you charged
GetBatteryStatusIntent how do your batteries look
GetBatteryStatusIntent what is your battery level
GetBatteryStatusIntent how are your batteries
GetBatteryStatusIntent is there any charge left
GetBatteryStatusIntent how much charge is left
GetBatteryStatusIntent how much range is left
GetBatteryStatusIntent what is your range
GetBatteryStatusIntent how many miles can we drive
GetBatteryStatusIntent what is the range left in the battery
GetBatteryStatusIntent how much range is left in the battery
GetBatteryStatusIntent how far can we drive

Finally, I created a little dummy script that lets me test the functions without having to go through the Echo for a faster dev cycle. I didn’t go as far as mocking the HTTP requests but I did modify the code temporarily to ensure the error handling worked properly (detecting invalid username/password, or if you try to pre-heat too often).

"use strict";

let zoe = require('./lambda/index');

let batteryStatusEvent = {
	"session": {
		"application": {
			"applicationId": "amzn1.ask.skill.zzz"
		}
	},
	"version": "1.0",
	"request": {
		"type": "IntentRequest",
		"intent": { "name": "GetBatteryStatusIntent" }
	}
};

let preheatEvent = {
	"session": {
		"application": {
			"applicationId": "amzn1.ask.skill.zzz"
		}
	},
	"version": "1.0",
	"request": {
		"type": "IntentRequest",
		"intent": { "name": "PreheatIntent" }
	}
};

let context = {
	succeed: function (resp) {
		console.log("\n\nResult:\n" + JSON.stringify(resp, null, 4));
	}
};

zoe.handler(batteryStatusEvent, context);
zoe.handler(preheatEvent, context);

After some successfully tests, I zipped the files up and uploaded them to Lambda to replace the favourite-color sample I’d been testing with. The final result is in the video at the top of this post!

You’re free to do as you please with this code. If you improve anything significantly, do leave a comment! If you’ve done anything similar with your Echo Dot, do leave a comment!

Discuss on Reddit | Hacker News | Lobsters