Add LiftMaster accessory shim

Refactoring
This commit is contained in:
Nick Farina
2014-11-30 23:07:32 -08:00
parent b4fe5421b8
commit 26dc3be0f8
9 changed files with 789 additions and 531 deletions

View File

@@ -82,7 +82,7 @@ At this point, you should be able to tell Siri to control your devices. However,
Also, remember that HomeKit is not very robust yet, and it is common for it to fail intermittently ("Sorry, I wasn't able to control your devices" kind of thing) then start working again for no reason. Also I've noticed that it will get cranky and stop working altogether sometimes. The usual voodoo applies here: reboot your device, or run the BetterHomeKit app and poke around, etc.
One final thing to remember is that Siri will almost always prefer its default phrase handling over HomeKit devices. For instance, if you name your Sonos device "Radio" and try saying "Siri, turn on the Radio" then Siri will probably start playing an iTunes Radio station on your phone. Even if you name it "Sonos" and say "Siri, turn on the Sonos", Siri will probably just launch the Sonos app instead. This is why, for instance, the suggested `siri_name` for the Sonos shim in `config-samples.json` is "Speakers".
One final thing to remember is that Siri will almost always prefer its default phrase handling over HomeKit devices. For instance, if you name your Sonos device "Radio" and try saying "Siri, turn on the Radio" then Siri will probably start playing an iTunes Radio station on your phone. Even if you name it "Sonos" and say "Siri, turn on the Sonos", Siri will probably just launch the Sonos app instead. This is why, for instance, the suggested `name` for the Sonos shim in `config-samples.json` is "Speakers".
# Final Notes

View File

@@ -3,7 +3,7 @@ var carwings = require("carwingsjs");
function CarwingsAccessory(log, config) {
this.log = log;
this.siriName = config["siri_name"];
this.name = config["name"];
this.username = config["username"];
this.password = config["password"];
}
@@ -41,17 +41,16 @@ CarwingsAccessory.prototype = {
});
},
accessoryData: function() {
getServices: function() {
var that = this;
return {
services: [{
return [{
sType: types.ACCESSORY_INFORMATION_STYPE,
characteristics: [{
cType: types.NAME_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.siriName,
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of the accessory",
@@ -104,7 +103,7 @@ CarwingsAccessory.prototype = {
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.siriName,
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of service",
@@ -120,8 +119,7 @@ CarwingsAccessory.prototype = {
manfDescription: "Change the power state of the car",
designedMaxLength: 1
}]
}]
}
}];
}
};

262
accessories/LiftMaster.js Normal file
View File

@@ -0,0 +1,262 @@
var types = require("../lib/HAP-NodeJS/accessories/types.js");
var request = require("request");
// This seems to be the "id" of the official LiftMaster iOS app
var APP_ID = "JVM/G9Nwih5BwKgNCjLxiFUQxQijAebyyg8QUHr7JOrP+tuPb8iHfRHKwTmDzHOu"
function LiftMasterAccessory(log, config) {
this.log = log;
this.name = config["name"];
this.username = config["username"];
this.password = config["password"];
}
LiftMasterAccessory.prototype = {
setState: function(state) {
this.targetState = state;
this.login();
},
login: function() {
var that = this;
// reset our logged-in state hint until we're logged in
this.deviceId = null;
// querystring params
var query = {
appId: APP_ID,
username: this.username,
password: this.password,
culture: "en"
};
// login to liftmaster
request.get({
url: "https://myqexternal.myqdevice.com/api/user/validatewithculture",
qs: query
}, function(err, response, body) {
if (!err && response.statusCode == 200) {
// parse and interpret the response
var json = JSON.parse(body);
that.userId = json["UserId"];
that.securityToken = json["SecurityToken"];
that.log("Logged in with user ID " + that.userId);
that.getDevice();
}
else {
that.log("Error '"+err+"' logging in: " + body);
}
});
},
// find your garage door ID
getDevice: function() {
var that = this;
// querystring params
var query = {
appId: APP_ID,
SecurityToken: this.securityToken,
filterOn: "true"
};
// some necessary duplicated info in the headers
var headers = {
MyQApplicationId: APP_ID,
SecurityToken: this.securityToken
};
// request details of all your devices
request.get({
url: "https://myqexternal.myqdevice.com/api/v4/userdevicedetails/get",
qs: query,
headers: headers
}, function(err, response, body) {
if (!err && response.statusCode == 200) {
// parse and interpret the response
var json = JSON.parse(body);
var devices = json["Devices"];
// look through the array of devices for an opener
for (var i=0; i<devices.length; i++) {
var device = devices[i];
if (device["MyQDeviceTypeName"] == "GarageDoorOpener") {
that.deviceId = device.MyQDeviceId;
break;
}
}
if (that.deviceId) {
that.log("Found an opener with ID " + that.deviceId +". Ready to open.");
that.setTargetState();
}
}
else {
that.log("Error '"+err+"' getting devices: " + body);
}
});
},
setTargetState: function() {
var that = this;
var liftmasterState = (this.targetState + "") == "1" ? "0" : "1";
// querystring params
var query = {
appId: APP_ID,
SecurityToken: this.securityToken,
filterOn: "true"
};
// some necessary duplicated info in the headers
var headers = {
MyQApplicationId: APP_ID,
SecurityToken: this.securityToken
};
// PUT request body
var body = {
AttributeName: "desireddoorstate",
AttributeValue: liftmasterState,
ApplicationId: APP_ID,
SecurityToken: this.securityToken,
MyQDeviceId: this.deviceId
};
// send the state request to liftmaster
request.put({
url: "https://myqexternal.myqdevice.com/api/v4/DeviceAttribute/PutDeviceAttribute",
qs: query,
headers: headers,
body: body,
json: true
}, function(err, response, json) {
if (!err && response.statusCode == 200) {
if (json["ReturnCode"] == "0")
that.log("State was successfully set.");
else
that.log("Bad return code: " + json["ReturnCode"]);
}
else {
that.log("Error '"+err+"' setting door state: " + JSON.stringify(json));
}
});
},
getServices: function() {
var that = this;
return [{
sType: types.ACCESSORY_INFORMATION_STYPE,
characteristics: [{
cType: types.NAME_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of the accessory",
designedMaxLength: 255
},{
cType: types.MANUFACTURER_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: "LiftMaster",
supportEvents: false,
supportBonjour: false,
manfDescription: "Manufacturer",
designedMaxLength: 255
},{
cType: types.MODEL_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: "Rev-1",
supportEvents: false,
supportBonjour: false,
manfDescription: "Model",
designedMaxLength: 255
},{
cType: types.SERIAL_NUMBER_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: "A1S2NASF88EW",
supportEvents: false,
supportBonjour: false,
manfDescription: "SN",
designedMaxLength: 255
},{
cType: types.IDENTIFY_CTYPE,
onUpdate: null,
perms: ["pw"],
format: "bool",
initialValue: false,
supportEvents: false,
supportBonjour: false,
manfDescription: "Identify Accessory",
designedMaxLength: 1
}]
},{
sType: types.GARAGE_DOOR_OPENER_STYPE,
characteristics: [{
cType: types.NAME_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: "Garage Door Opener Control",
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of service",
designedMaxLength: 255
},{
cType: types.CURRENT_DOOR_STATE_CTYPE,
onUpdate: function(value) { that.log("Update current state to " + value); },
perms: ["pr","ev"],
format: "int",
initialValue: 0,
supportEvents: false,
supportBonjour: false,
manfDescription: "BlaBla",
designedMinValue: 0,
designedMaxValue: 4,
designedMinStep: 1,
designedMaxLength: 1
},{
cType: types.TARGET_DOORSTATE_CTYPE,
onUpdate: function(value) { that.setState(value); },
perms: ["pr","pw","ev"],
format: "int",
initialValue: 0,
supportEvents: false,
supportBonjour: false,
manfDescription: "BlaBla",
designedMinValue: 0,
designedMaxValue: 1,
designedMinStep: 1,
designedMaxLength: 1
},{
cType: types.OBSTRUCTION_DETECTED_CTYPE,
onUpdate: function(value) { that.log("Obstruction detected: " + value); },
perms: ["pr","ev"],
format: "bool",
initialValue: false,
supportEvents: false,
supportBonjour: false,
manfDescription: "BlaBla"
}]
}];
}
};
module.exports.accessory = LiftMasterAccessory;

View File

@@ -3,7 +3,7 @@ var request = require("request");
function LockitronAccessory(log, config) {
this.log = log;
this.siriName = config["siri_name"];
this.name = config["name"];
this.lockID = config["lock_id"];
this.accessToken = config["api_token"];
}
@@ -34,17 +34,16 @@ LockitronAccessory.prototype = {
});
},
accessoryData: function() {
getServices: function() {
var that = this;
return {
services: [{
return [{
sType: types.ACCESSORY_INFORMATION_STYPE,
characteristics: [{
cType: types.NAME_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.siriName,
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of the accessory",
@@ -162,8 +161,7 @@ LockitronAccessory.prototype = {
manfDescription: "BlaBla",
designedMaxLength: 255
}]
}]
};
}];
}
};

View File

@@ -3,7 +3,7 @@ var sonos = require('sonos');
function SonosAccessory(log, config) {
this.log = log;
this.siriName = config["siri_name"];
this.name = config["name"];
this.playVolume = config["play_volume"];
this.device = null;
this.search();
@@ -61,17 +61,16 @@ SonosAccessory.prototype = {
}
},
accessoryData: function() {
getServices: function() {
var that = this;
return {
services: [{
return [{
sType: types.ACCESSORY_INFORMATION_STYPE,
characteristics: [{
cType: types.NAME_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.siriName,
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of the accessory",
@@ -140,8 +139,7 @@ SonosAccessory.prototype = {
manfDescription: "Change the playback state of the sonos",
designedMaxLength: 1
}]
}]
}
}];
}
};

View File

@@ -3,7 +3,7 @@ var wemo = require('wemo');
function WeMoAccessory(log, config) {
this.log = log;
this.siriName = config["siri_name"];
this.name = config["name"];
this.wemoName = config["wemo_name"];
this.device = null;
this.log("Searching for WeMo device with exact name '" + this.wemoName + "'...");
@@ -16,8 +16,13 @@ WeMoAccessory.prototype = {
var that = this;
wemo.Search(this.wemoName, function(err, device) {
if (!err && device) {
that.log("Found '"+that.wemoName+"' device at " + device.ip);
that.device = new wemo(device.ip, device.port);
}
else {
that.log("Error finding device '" + that.wemoName + "': " + err);
}
});
},
@@ -43,17 +48,16 @@ WeMoAccessory.prototype = {
});
},
accessoryData: function() {
getServices: function() {
var that = this;
return {
services: [{
return [{
sType: types.ACCESSORY_INFORMATION_STYPE,
characteristics: [{
cType: types.NAME_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.siriName,
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of the accessory",
@@ -106,7 +110,7 @@ WeMoAccessory.prototype = {
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.siriName,
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of service",
@@ -122,8 +126,7 @@ WeMoAccessory.prototype = {
manfDescription: "Change the power state of the WeMo",
designedMaxLength: 1
}]
}]
}
}];
}
};

View File

@@ -4,7 +4,7 @@ var xmldoc = require("xmldoc");
function XfinityHomeAccessory(log, config) {
this.log = log;
this.siriName = config["siri_name"];
this.name = config["name"];
this.email = config["email"];
this.password = config["password"];
this.dsig = config["dsig"];
@@ -144,17 +144,16 @@ XfinityHomeAccessory.prototype = {
});
},
accessoryData: function() {
getServices: function() {
var that = this;
return {
services: [{
return [{
sType: types.ACCESSORY_INFORMATION_STYPE,
characteristics: [{
cType: types.NAME_CTYPE,
onUpdate: null,
perms: ["pr"],
format: "string",
initialValue: this.siriName,
initialValue: this.name,
supportEvents: false,
supportBonjour: false,
manfDescription: "Name of the accessory",
@@ -269,8 +268,7 @@ XfinityHomeAccessory.prototype = {
manfDescription: "Turn on the Stay alarm",
designedMaxLength: 1
}]
}]
}
}];
}
};

64
app.js
View File

@@ -35,21 +35,20 @@ function loadAccessories() {
var accessoryModule = require('./accessories/' + accessoryName + ".js"); // like "./accessories/WeMo.js"
var accessoryConstructor = accessoryModule.accessory; // like "WeMoAccessory", a JavaScript constructor
// Create a custom logging function that prepends the Siri name for debugging
var siriName = accessoryConfig["siri_name"];
var log = function(siriName) { return function(s) { console.log("[" + siriName + "] " + s); }; }(siriName);
// Create a custom logging function that prepends the device display name for debugging
var name = accessoryConfig["name"];
var log = function(name) { return function(s) { console.log("[" + name + "] " + s); }; }(name);
log("Initializing " + accessoryName + " accessory...");
var accessory = new accessoryConstructor(log, accessoryConfig);
accessories.push(accessory);
// Extract the raw "accessoryData" for this accessory which is a big object-blob describing the various
// Extract the raw "services" for this accessory which is a big array of objects describing the various
// hooks in and out of HomeKit for the HAP-NodeJS server.
var accessoryData = accessory.accessoryData();
accessoryData["displayName"] = siriName;
var services = accessory.getServices();
// Create the HAP server for this accessory
createHAPServer(accessoryData);
createHAPServer(name, services);
}
}
@@ -70,52 +69,47 @@ var accessoryServers = [];
var accessoryControllers = [];
var usernames = {};
function createHAPServer(data) {
function createHAPServer(name, services) {
var accessoryController = new accessoryController_Factor.AccessoryController();
//loop through services
for (var j = 0; j < data.services.length; j++) {
var service = new service_Factor.Service(data.services[j].sType);
for (var j = 0; j < services.length; j++) {
var service = new service_Factor.Service(services[j].sType);
//loop through characteristics
for (var k = 0; k < data.services[j].characteristics.length; k++) {
for (var k = 0; k < services[j].characteristics.length; k++) {
var options = {
type: data.services[j].characteristics[k].cType,
perms: data.services[j].characteristics[k].perms,
format: data.services[j].characteristics[k].format,
initialValue: data.services[j].characteristics[k].initialValue,
supportEvents: data.services[j].characteristics[k].supportEvents,
supportBonjour: data.services[j].characteristics[k].supportBonjour,
manfDescription: data.services[j].characteristics[k].manfDescription,
designedMaxLength: data.services[j].characteristics[k].designedMaxLength,
designedMinValue: data.services[j].characteristics[k].designedMinValue,
designedMaxValue: data.services[j].characteristics[k].designedMaxValue,
designedMinStep: data.services[j].characteristics[k].designedMinStep,
unit: data.services[j].characteristics[k].unit
type: services[j].characteristics[k].cType,
perms: services[j].characteristics[k].perms,
format: services[j].characteristics[k].format,
initialValue: services[j].characteristics[k].initialValue,
supportEvents: services[j].characteristics[k].supportEvents,
supportBonjour: services[j].characteristics[k].supportBonjour,
manfDescription: services[j].characteristics[k].manfDescription,
designedMaxLength: services[j].characteristics[k].designedMaxLength,
designedMinValue: services[j].characteristics[k].designedMinValue,
designedMaxValue: services[j].characteristics[k].designedMaxValue,
designedMinStep: services[j].characteristics[k].designedMinStep,
unit: services[j].characteristics[k].unit
};
var characteristic = new characteristic_Factor.Characteristic(options, data.services[j].characteristics[k].onUpdate);
var characteristic = new characteristic_Factor.Characteristic(options, services[j].characteristics[k].onUpdate);
service.addCharacteristic(characteristic);
}
accessoryController.addService(service);
}
// grab the intended name for Siri
var displayName = data["displayName"];
console.log(displayName);
// create a unique "username" for this accessory based on the default Siri name
var username = createUsername(displayName);
// create a unique "username" for this accessory based on the default display name
var username = createUsername(name);
if (usernames[username]) {
console.log("Cannot create another accessory with the same name '" + displayName + "'. The 'siri_name' property must be unique for each accessory.");
console.log("Cannot create another accessory with the same name '" + name + "'. The 'name' property must be unique for each accessory.");
return;
}
// remember that we used this name already
usernames[username] = displayName;
usernames[username] = name;
// increment ports for each accessory
nextPort = nextPort + (nextServer*2);
@@ -123,7 +117,7 @@ function createHAPServer(data) {
// hardcode the PIN to something random (same PIN as HAP-NodeJS sample accessories)
var pincode = "031-45-154";
var accessory = new accessory_Factor.Accessory(displayName, username, storage, parseInt(nextPort), pincode, accessoryController);
var accessory = new accessory_Factor.Accessory(name, username, storage, parseInt(nextPort), pincode, accessoryController);
accessoryServers[nextServer] = accessory;
accessoryControllers[nextServer] = accessoryController;
accessory.publishAccessory();
@@ -134,7 +128,7 @@ function createHAPServer(data) {
// Creates a unique "username" for HomeKit from a hash of the given string
function createUsername(str) {
// Hash siri_name into something like "098F6BCD4621D373CADE4E832627B4F6"
// Hash str into something like "098F6BCD4621D373CADE4E832627B4F6"
var hash = crypto.createHash('md5').update(str).digest("hex").toUpperCase();
// Turn it into a MAC-address-looking "username" for HomeKit

View File

@@ -4,34 +4,41 @@
"accessories": [
{
"accessory": "WeMo",
"description": "This shim supports Belkin WeMo devices on the same network as this server. You can create duplicate entries for this device and change the 'siri_name' attribute to reflect what device is plugged into the WeMo, for instance 'Air Conditioner' or 'Coffee Maker'. This name will be used by Siri. Make sure to update the 'wemo_name' attribute with the EXACT name of the device in the WeMo app itself. This can be the same value as 'siri_name' but it doesn't have to be.",
"siri_name": "Coffee Maker",
"name": "Coffee Maker",
"description": "This shim supports Belkin WeMo devices on the same network as this server. You can create duplicate entries for this device and change the 'name' attribute to reflect what device is plugged into the WeMo, for instance 'Air Conditioner' or 'Coffee Maker'. This name will be used by Siri. Make sure to update the 'wemo_name' attribute with the EXACT name of the device in the WeMo app itself. This can be the same value as 'name' but it doesn't have to be.",
"wemo_name": "CoffeeMaker"
},
{
"accessory": "LiftMaster",
"name": "Garage Door",
"description": "This shim supports LiftMaster garage door openers that are already internet-connected to the 'MyQ' service.",
"username": "your-liftmaster-username",
"password" : "your-liftmaster-password"
},
{
"accessory": "Sonos",
"name": "Speakers",
"description": "This shim supports Sonos devices on the same network as this server. It acts as a simple switch that calls play() or pause() on the Sonos, so it's only useful for pausing and resuming tracks or radio stations that are already in the queue. When 'play_volume' is nonzero, the volume will be reset to that value when it turns the Sonos on.",
"siri_name": "Speakers",
"play_volume": 25
},
{
"accessory": "Lockitron",
"name": "Front Door",
"description": "This shim supports Lockitron locks. It uses the Lockitron cloud API, so the Lockitron must be 'awake' for locking and unlocking to actually happen. You can wake up Lockitron after issuing an lock/unlock command by knocking on the door.",
"siri_name": "Front Door",
"lock_id": "your-lock-id",
"api_token" : "your-lockitron-api-access-token"
},
{
"accessory": "Carwings",
"name": "Leaf",
"description": "This shim supports controlling climate control on Nissan cars with Carwings. Note that Carwings is super slow and it may take up to 5 minutes for your command to be processed by the Carwings system.",
"siri_name": "Leaf",
"username": "your-carwings-username",
"password" : "your-carwings-password"
},
{
"accessory": "XfinityHome",
"name": "Xfinity Home",
"description": "This shim supports the 'Xfinity Home' security system. Unfortunately I don't know how to generate the 'dsig' property, so you'll need to figure yours out by running the Xfinity Home app on your iOS device while connected to a proxy server like Charles. If you didn't understand any of that, sorry! I welcome any suggestions for how to figure out dsig automatically.",
"siri_name": "Xfinity Home",
"email": "your-comcast-email@example.com",
"password": "your-comcast-password",
"dsig": "your-digital-signature",