homebridge-dyson-link
Version:
Homebridge Plugin for Dyson Link
231 lines (209 loc) • 11.3 kB
JavaScript
;
const https = require('https');
const crypto = require('crypto');
const DysonLinkAccessoryModule = require("./DysonLinkAccessory");
const DysonLinkDevice = require("./DysonLinkDevice").DysonLinkDevice;
const DysonLinkAccessory = DysonLinkAccessoryModule.DysonLinkAccessory;
var Accessory, Service, Characteristic, UUIDGen;
module.exports = function(homebridge) {
console.log("homebridge API version: " + homebridge.version);
// Accessory must be created from PlatformAccessory Constructor
Accessory = homebridge.platformAccessory;
// Service and Characteristic are from hap-nodejs
Service = homebridge.hap.Service;
Characteristic = homebridge.hap.Characteristic;
UUIDGen = homebridge.hap.uuid;
DysonLinkAccessoryModule.setHomebridge(homebridge);
// For platform plugin to be considered as dynamic platform plugin,
// registerPlatform(pluginName, platformName, constructor, dynamic), dynamic must be true
homebridge.registerPlatform("homebridge-dyson-link", "DysonPlatform", DysonPlatform, true);
}
class DysonPlatform {
constructor(log, config, api) {
this.log = log;
this.config = config;
this.accessories = [];
if (api) {
// Save the API object as plugin needs to register new accessory via this object.
this.api = api;
var platform = this;
// Listen to event "didFinishLaunching", this means homebridge already finished loading cached accessories
// Platform Plugin should only register new accessory that doesn't exist in homebridge after this event.
// Or start discover new accessories
this.api.on('didFinishLaunching', () => {
platform.log("Finished launching. Start to create accessory from config");
// Check if the accessories is null as this may be called from second instance of homebrdige too
if (this.config && this.config.accessories) {
let accountPassword = this.config.password || process.env.DYSON_PASSWORD;
let accountEmail = this.config.email || process.env.DYSON_EMAIL;
this.getDevicesFromAccount(accountEmail, accountPassword, config.country, (accountDevices) => {
this.config.accessories.forEach((accessory) => {
let nightModeVisible = accessory.nightModeVisible;
if(nightModeVisible == null || nightModeVisible == undefined) {
platform.log.debug("no night mode visible value, default to true");
nightModeVisible = true;
}
let focusModeVisible = accessory.focusModeVisible;
if(focusModeVisible == null || focusModeVisible == undefined) {
platform.log.debug("no focus mode visible value, default to true");
focusModeVisible = true;
}
let autoModeVisible = accessory.autoModeVisible;
if(autoModeVisible == null || autoModeVisible == undefined) {
platform.log.debug("no auto mode visible value, default to true");
autoModeVisible = true;
}
let deviceInfo = accountDevices[accessory.serialNumber];
var password = ''
if (deviceInfo) {
platform.log("Use device password from account");
password = deviceInfo.password;
accessory.serialNumber = 'DYSON-'+accessory.serialNumber+'-'+deviceInfo.ProductType;
}
else if (accessory.password) {
platform.log("Use device password from config file");
password = crypto.createHash('sha512').update(accessory.password, "utf8").digest("base64");
}
else {
platform.log.error("Missing password for device with serial number " + accessory.serialNumber + ", devices found on your account: " + Object.keys(accountDevices).join(", "));
return;
}
platform.log(accessory.displayName + " IP:" + accessory.ip + " Serial Number:" + accessory.serialNumber);
let device = new DysonLinkDevice(accessory.displayName, accessory.ip, accessory.serialNumber, password, platform.log);
if (device.valid) {
platform.log("Device serial number format valids");
let uuid = UUIDGen.generate(accessory.serialNumber);
// Check if the accessory got cached
let cachedAccessory = platform.accessories.find((item) => item.UUID === uuid);
if (!cachedAccessory) {
platform.log("Device not cached. Create a new one");
let dysonAccessory = new Accessory(accessory.displayName, uuid);
new DysonLinkAccessory(accessory.displayName, device, dysonAccessory, platform.log, nightModeVisible, focusModeVisible, autoModeVisible);
platform.api.registerPlatformAccessories("homebridge-dyson-link", "DysonPlatform", [dysonAccessory]);
platform.accessories.push(accessory);
} else {
platform.log("Device cached. Try to update this");
cachedAccessory.displayName = accessory.displayName;
new DysonLinkAccessory(accessory.displayName, device, cachedAccessory, platform.log, nightModeVisible, focusModeVisible, autoModeVisible);
platform.api.updatePlatformAccessories([cachedAccessory]);
}
}
});
});
}
else{
platform.log.error("Unable to find config or accessories");
}
});
}
}
configureAccessory(accessory) {
this.log(accessory.displayName, "Configure Accessory");
accessory.reachable = true;
accessory.on('identify', (paired, callback) => {
this.log(accessory.displayName, "Identify!!!");
callback();
});
this.accessories.push(accessory);
}
getDevicesFromAccount(email, password, country, callback) {
if (!email || !password) {
this.log("Dyson email/pass not found, v2 devices may not work")
callback({});
return;
}
// Adapted from: https://github.com/CharlesBlonde/libpurecoollink/blob/master/libpurecoollink/utils.py
const decryptPassword = (encryptedPassword) => {
let key = Uint8Array.from(Array(32), (val, index) => index + 1);
let init_vector = new Uint8Array(16);
var decipher = crypto.createDecipheriv('aes-256-cbc', key, init_vector);
var decryptedPassword = decipher.update(encryptedPassword, 'base64', 'utf8');
decryptedPassword = decryptedPassword + decipher.final('utf8');
return decryptedPassword
};
if (!country) {
country = "US"
}
let DYSON_API_URL = "appapi.cp.dyson.com";
if (country == "CN"){
DYSON_API_URL = "appapi.cp.dyson.cn"
this.log.info("Country code is CN. Changed to use CN server -" + DYSON_API_URL);
}
let postData = {
Email: email,
Password: password
};
let postBody = JSON.stringify(postData);
var options = {
hostname: DYSON_API_URL,
port: 443,
path: '/v1/userregistration/authenticate?country=' + country,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': postBody.length,
'User-Agent': 'android client'
},
rejectUnauthorized: false
};
// Initial request with email/pass to get authorization tokens of Account and Password
var req = https.request(options, (res) => {
var data = "";
res.setEncoding('utf-8');
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
if (!data) {
this.log.error("Could not login to Dyson")
callback({});
return;
}
let credentials = JSON.parse(data);
let account = credentials.Account;
let password = credentials.Password;
let auth = 'Basic ' + Buffer.from(account + ':' + password).toString('base64');
var options = {
hostname: DYSON_API_URL,
port: 443,
path: '/v2/provisioningservice/manifest',
headers: {
"Authorization": auth,
'User-Agent': 'android client'
},
rejectUnauthorized: false
};
// Request devices in user's account to get local credentials
var req = https.get(options, (res) => {
var data = "";
res.setEncoding('utf-8');
res.on('data', (chunk) => data += chunk);
res.on('end', () => {
if (!data || data.length == 0) {
this.log.error("Could not login to Dyson");
callback({});
return;
}
let devices = JSON.parse(data);
var devicesBySerial = {};
devices.forEach((device) => {
if (device.LocalCredentials) {
let decrypted = JSON.parse(decryptPassword(device.LocalCredentials));
device.password = decrypted.apPasswordHash;
devicesBySerial[device.Serial] = device
}
});
callback(devicesBySerial);
});
});
req.on('error', function(err) {
this.log.error("Error logging in, check Dyson email, password, and country - "+err);
});
req.end();
});
});
req.on('error', function(err) {
this.log.error("Error logging in, check Dyson email, password, and country - "+err);
});
req.write(postBody);
req.end();
}
}