sucks
Version:
Sucks Python to Javscript port
799 lines (716 loc) • 24.4 kB
JavaScript
const https = require('https')
, URL = require('url').URL
, crypto = require('crypto')
, EventEmitter = require('events')
, fs = require('fs')
, Element = require('ltx').Element
, countries = require('./countries.js');
String.prototype.format = function () {
if (arguments.length == 0) {
return this;
}
var args = arguments['0'];
return this.replace(/{(\w+)}/g, function (match, number) {
return typeof args[number] != 'undefined' ? args[number] : match;
});
};
class EcoVacsAPI {
constructor(device_id, country, continent) {
envLog("[EcoVacsAPI] Setting up EcoVacsAPI");
if (!device_id) {
throw "No Device ID provided";
}
if (!country) {
throw "No Country code provided";
}
if (!continent) {
throw "No Continent provided";
}
this.meta = {
'country': country,
'lang': 'en',
'deviceId': device_id,
'appCode': 'i_eco_e',
'appVersion': '1.3.5',
'channel': 'c_googleplay',
'deviceType': '1'
};
this.resource = device_id.substr(0, 8);
this.country = country;
this.continent = continent;
}
connect(account_id, password_hash) {
return new Promise((resolve, reject) => {
let login_info = null;
this.__call_main_api('user/login', {'account': EcoVacsAPI.encrypt(account_id), 'password': EcoVacsAPI.encrypt(password_hash)}).then((info) => {
login_info = info;
this.uid = login_info.uid;
this.login_access_token = login_info.accessToken;
this.__call_main_api('user/getAuthCode', {'uid': this.uid, 'accessToken': this.login_access_token}).then((token) => {
this.auth_code = token['authCode'];
this.__call_login_by_it_token().then((login) => {
this.user_access_token = login['token'];
this.uid = login['userId'];
envLog("[EcoVacsAPI] EcoVacsAPI connection complete");
resolve("ready");
}).catch((e) => {
envLog("[EcoVacsAPI]", e);
reject(e);
});
}).catch((e) => {
envLog("[EcoVacsAPI]", e);
reject(e);
});
}).catch((e) => {
envLog("[EcoVacsAPI]", e);
reject(e);
});
});
}
__sign(params) {
let result = JSON.parse(JSON.stringify(params));
result['authTimespan'] = Date.now();
result['authTimeZone'] = 'GMT-8';
let sign_on = JSON.parse(JSON.stringify(this.meta));
for (var key in result) {
if (result.hasOwnProperty(key)) {
sign_on[key] = result[key];
}
}
let sign_on_text = EcoVacsAPI.CLIENT_KEY;
let keys = Object.keys(sign_on);
keys.sort();
for (let i = 0; i < keys.length; i++) {
let k = keys[i];
sign_on_text += k + "=" + sign_on[k];
}
sign_on_text += EcoVacsAPI.SECRET;
result['authAppkey'] = EcoVacsAPI.CLIENT_KEY;
result['authSign'] = EcoVacsAPI.md5(sign_on_text);
return EcoVacsAPI.paramsToQueryList(result);
}
__call_main_api(func, args) {
return new Promise((resolve, reject) => {
envLog("[EcoVacsAPI] calling main api %s with %s", func, JSON.stringify(args));
let params = {};
for (var key in args) {
if (args.hasOwnProperty(key)) {
params[key] = args[key];
}
}
params['requestId'] = EcoVacsAPI.md5(Number.parseFloat(Date.now() / 1000).toFixed(0));
let url = (EcoVacsAPI.MAIN_URL_FORMAT + "/" + func).format(this.meta);
url = new URL(url);
url.search = this.__sign(params).join('&');
envLog(`[EcoVacsAPI] Calling ${url.href}`);
https.get(url.href, (res) => {
const {statusCode} = res;
const contentType = res.headers['content-type'];
let error;
if (statusCode !== 200) {
error = new Error('Request Failed.\n' +
`Status Code: ${statusCode}`);
}
if (error) {
console.error("[EcoVacsAPI] " + error.message);
res.resume();
return;
}
res.setEncoding('utf8');
let rawData = '';
res.on('data', (chunk) => {
rawData += chunk;
});
res.on('end', () => {
try {
const json = JSON.parse(rawData);
envLog("[EcoVacsAPI] got %s", JSON.stringify(json));
if (json.code == '0000') {
resolve(json.data);
} else if (json.code == '1005') {
envLog("[EcoVacsAPI] incorrect email or password");
throw new Error("incorrect email or password");
} else {
envLog("[EcoVacsAPI] call to %s failed with %s", func, JSON.stringify(json));
throw new Error("failure code {msg} ({code}) for call {func} and parameters {param}".format({
msg: json['msg'],
code: json['code'],
func: func,
param: JSON.stringify(args)
}));
}
} catch (e) {
console.error("[EcoVacsAPI] " + e.message);
reject(e);
}
});
}).on('error', (e) => {
console.error(`[EcoVacsAPI] Got error: ${e.message}`);
reject(e);
});
});
}
__call_user_api(func, args) {
return new Promise((resolve, reject) => {
envLog("[EcoVacsAPI] calling user api %s with %s", func, JSON.stringify(args));
let params = {'todo': func};
for (let key in args) {
if (args.hasOwnProperty(key)) {
params[key] = args[key];
}
}
let url = EcoVacsAPI.USER_URL_FORMAT.format({continent: this.continent});
url = new URL(url);
envLog(`[EcoVacsAPI] Calling ${url.href}`);
const reqOptions = {
hostname: url.hostname,
port: url.port,
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Content-Length': Buffer.byteLength(JSON.stringify(params))
}
};
envLog("[EcoVacsAPI] Sending POST to", JSON.stringify(reqOptions));
const req = https.request(reqOptions, (res) => {
res.setEncoding('utf8');
let rawData = '';
res.on('data', (chunk) => {
rawData += chunk;
});
res.on('end', () => {
try {
const json = JSON.parse(rawData);
envLog("[EcoVacsAPI] got %s", JSON.stringify(json));
if (json['result'] == 'ok') {
resolve(json);
} else {
envLog("[EcoVacsAPI] call to %s failed with %s", func, JSON.stringify(json));
throw "failure code {errno} ({error}) for call {func} and parameters {params}".format({
errno: json['errno'],
error: json['error'],
func: func,
params: JSON.stringify(args)
});
}
} catch (e) {
console.error("[EcoVacsAPI] " + e.message);
reject(e);
}
});
});
req.on('error', (e) => {
console.error(`[EcoVacsAPI] problem with request: ${e.message}`);
reject(e);
});
// write data to request body
envLog("[EcoVacsAPI] Sending", JSON.stringify(params));
req.write(JSON.stringify(params));
req.end();
});
}
__call_login_by_it_token() {
return this.__call_user_api('loginByItToken',
{
'country': this.meta['country'].toUpperCase(),
'resource': this.resource,
'realm': EcoVacsAPI.REALM,
'userId': this.uid,
'token': this.auth_code
}
);
}
devices() {
return new Promise((resolve, reject) => {
this.__call_user_api('GetDeviceList', {
'userid': this.uid,
'auth': {
'with': 'users',
'userid': this.uid,
'realm': EcoVacsAPI.REALM,
'token': this.user_access_token,
'resource': this.resource
}
}).then((data) => {
resolve(data['devices']);
}).catch((e) => {
reject(e);
});
});
}
static md5(text) {
return crypto.createHash('md5').update(text).digest("hex");
}
static encrypt(text) {
return crypto.publicEncrypt({key: EcoVacsAPI.PUBLIC_KEY, padding: crypto.constants.RSA_PKCS1_PADDING}, new Buffer(text)).toString('base64');
}
static paramsToQueryList(params) {
let query = [];
for (let key in params) {
if (params.hasOwnProperty(key)) {
query.push(key + "=" + encodeURIComponent(params[key]));
}
}
return query;
}
}
EcoVacsAPI.CLIENT_KEY = "eJUWrzRv34qFSaYk";
EcoVacsAPI.SECRET = "Cyu5jcR4zyK6QEPn1hdIGXB5QIDAQABMA0GC";
EcoVacsAPI.PUBLIC_KEY = fs.readFileSync(__dirname + "/key.pem", "utf8");
EcoVacsAPI.MAIN_URL_FORMAT = 'https://eco-{country}-api.ecovacs.com/v1/private/{country}/{lang}/{deviceId}/{appCode}/{appVersion}/{channel}/{deviceType}';
EcoVacsAPI.USER_URL_FORMAT = 'https://users-{continent}.ecouser.net:8000/user.do';
EcoVacsAPI.REALM = 'ecouser.net';
class VacBot {
constructor(user, hostname, resource, secret, vacuum, continent, server_address = null) {
this.vacuum = vacuum;
this.clean_status = null;
this.charge_status = null;
this.battery_status = null;
this.ping_interval = null;
this.xmpp = new EcoVacsXMPP(this, user, hostname, resource, secret, continent, server_address);
this.xmpp.on("ready", () => {
envLog("[VacBot] Ready event!");
});
}
connect_and_wait_until_ready() {
this.xmpp.connect_and_wait_until_ready();
this.ping_interval = setInterval(() => {
this.xmpp.send_ping(this._vacuum_address());
}, 30000);
}
on(name, func) {
this.xmpp.on(name, func);
}
_handle_clean_report(iq) {
this.clean_status = iq.attrs['type'];
envLog("[VacBot] *** clean_status = " + this.clean_status);
}
_handle_battery_info(iq) {
try {
if (iq.name !== "battery") {
throw "Not a battery state";
}
this.battery_status = parseFloat(iq.attrs['power']) / 100;
envLog("[VacBot] *** battery_status = %d\%", this.battery_status * 100);
} catch (e) {
console.error("[VacBot] couldn't parse battery status ", iq);
}
}
_handle_charge_state(iq) {
try {
if (iq.name !== "charge") {
throw "Not a charge state";
}
let report = iq.attrs['type'];
switch (report.toLowerCase()) {
case "going":
this.charge_status = 'returning';
break;
case "slotcharging":
this.charge_status = 'charging';
break;
case "idle":
this.charge_status = 'idle';
break;
default:
console.error("[VacBot] Unknown charging status '%s'", report);
break;
}
envLog("[VacBot] *** charge_status = " + this.charge_status)
} catch (e) {
console.error("[VacBot] couldn't parse charge status ", iq);
}
}
_vacuum_address() {
return this.vacuum['did'] + '@' + this.vacuum['class'] + '.ecorobot.net/atom'
}
send_command(command) {
envLog("[VacBot] Sending command `%s`", command.name);
this.xmpp.send_command(command, this._vacuum_address());
}
run(action) {
switch (action) {
case "Clean":
case "clean":
var args = Array.prototype.slice.call(arguments, 1);
if (args.length == 0) {
this.send_command(new Clean());
} else if (args.length == 1) {
this.send_command(new Clean(args[0]));
} else {
this.send_command(new Clean(args[0], args[1]));
}
break;
case "Edge":
case "edge":
this.send_command(new Edge());
break;
case "Spot":
case "spot":
this.send_command(new Spot());
break;
case "Stop":
case "stop":
this.send_command(new Stop());
break;
case "Charge":
case "charge":
this.send_command(new Charge());
break;
case "Move":
case "move":
var args = Array.prototype.slice.call(arguments, 1);
if (args.length < 1) {
return;
}
this.send_command(new Move(args[0]));
break;
case "Left":
case "left":
this.run("Move", "left");
break;
case "Right":
case "right":
this.run("Move", "right");
break;
case "Forward":
case "forward":
this.run("Move", "forward");
break;
case "turn_around":
case "TurnAround":
case "turnaround":
this.run("Move", "turn_around");
break;
case "GetDeviceInfo":
case "getdeviceinfo":
case "deviceinfo":
this.send_command(new GetDeviceInfo());
break;
case "GetCleanState":
case "getcleanstate":
case "cleanstate":
this.send_command(new GetCleanState());
break;
case "GetChargeState":
case "getchargestate":
case "chargestate":
this.send_command(new GetChargeState());
break;
case "GetBatteryState":
case "getbatterystate":
case "batterystate":
this.send_command(new GetBatteryState());
break;
case "GetLifeSpan":
case "getlifespan":
case "lifespan":
var args = Array.prototype.slice.call(arguments, 1);
if (args.length < 1) {
return;
}
this.send_command(new GetLifeSpan(args[0]));
break;
case "SetTime":
case "settime":
case "time":
var args = Array.prototype.slice.call(arguments, 1);
if (args.length < 2) {
return;
}
this.send_command(new SetTime(args[0], args[1]));
break;
}
}
disconnect() {
this.xmpp.disconnect();
}
}
class EcoVacsXMPP extends EventEmitter {
constructor(bot, user, hostname, resource, secret, continent, server_address, server_port) {
super();
this.simpleXmpp = require('simple-xmpp');
this.bot = bot;
this.user = user;
this.hostname = hostname;
this.resource = resource;
this.secret = secret;
this.continent = continent;
this.iter = 1;
if (!server_address) {
this.server_address = 'msg-{continent}.ecouser.net'.format({continent: continent});
} else {
this.server_address = server_address;
}
if (!server_port) {
this.server_port = 5223
} else {
this.server_port = server_port;
}
this.simpleXmpp.on('online', (event) => {
this.session_start(event);
});
this.simpleXmpp.on('close', () => {
envLog('[EcoVacsXMPP] I\'m disconnected :(');
this.emit("closed");
});
this.simpleXmpp.on('chat', (from, message) => {
envLog('[EcoVacsXMPP] Chat from %s: %s', from, message);
});
this.simpleXmpp.on('stanza', (stanza) => {
//envLog('[EcoVacsXMPP] Received stanza:', JSON.stringify(stanza));
envLog('[EcoVacsXMPP] Received stanza XML:', stanza.toString());
if (stanza.name == "iq" && stanza.attrs.type == "set" && !!stanza.children[0] && stanza.children[0].name == "query" && !!stanza.children[0].children[0] /*&& !!stanza.children[0].children[0].children[0]*/) {
envLog('[EcoVacsXMPP] Response for %s:, %s', stanza.children[0].children[0].attrs.td, JSON.stringify(stanza.children[0].children[0]));
switch (stanza.children[0].children[0].attrs.td) {
case "PushRobotNotify":
let type = stanza.children[0].children[0].attrs['type'];
let act = stanza.children[0].children[0].attrs['act'];
this.emit(stanza.children[0].children[0].attrs.td, {type: type, act: act});
this.emit("stanza", {type: stanza.children[0].children[0].attrs.td, value: {type: type, act: act}});
break;
case "DeviceInfo":
envLog("[EcoVacsXMPP] Received an DeviceInfo Stanza");
break;
case "ChargeState":
this.bot._handle_charge_state(stanza.children[0].children[0].children[0]);
this.emit(stanza.children[0].children[0].attrs.td, this.bot.charge_status);
this.emit("stanza", {type: stanza.children[0].children[0].attrs.td, value: this.bot.charge_status});
break;
case "BatteryInfo":
this.bot._handle_battery_info(stanza.children[0].children[0].children[0]);
this.emit(stanza.children[0].children[0].attrs.td, this.bot.battery_status);
this.emit("stanza", {type: stanza.children[0].children[0].attrs.td, value: this.bot.battery_status});
break;
case "CleanReport":
this.bot._handle_clean_report(stanza.children[0].children[0].children[0]);
this.emit(stanza.children[0].children[0].attrs.td, this.bot.clean_status);
this.emit("stanza", {type: stanza.children[0].children[0].attrs.td, value: this.bot.clean_status});
break;
case "WKVer":
envLog("[EcoVacsXMPP] Received an WKVer Stanza");
break;
case "Error":
case "error":
envLog("[EcoVacsXMPP] Received an error for action '%s': %s", stanza.children[0].children[0].attrs.action, stanza.children[0].children[0].attrs.error);
break;
case "OnOff":
envLog("[EcoVacsXMPP] Received an OnOff Stanza");
break;
case "Sched":
envLog("[EcoVacsXMPP] Received an Sched Stanza");
break;
case "LifeSpan":
envLog("[EcoVacsXMPP] Received an LifeSpan Stanza");
break;
default:
envLog("[EcoVacsXMPP] Unknown response type received");
break;
}
} else if (stanza.name == "iq" && stanza.attrs.type == "error" && !!stanza.children[0] && stanza.children[0].name == "error" && !!stanza.children[0].children[0]) {
envLog('[EcoVacsXMPP] Response Error for request %s', stanza.attrs.id);
switch (stanza.children[0].attrs.code) {
case "404":
console.error("[EcoVacsXMPP] Couldn't reach the vac :[%s] %s", stanza.children[0].attrs.code, stanza.children[0].children[0].name);
break;
default:
console.error("[EcoVacsXMPP] Unknown error received: %s", JSON.stringify(stanza.children[0]));
break;
}
}
});
this.simpleXmpp.on('error', (e) => {
envLog('[EcoVacsXMPP] Error:', e);
});
}
session_start(event) {
envLog("[EcoVacsXMPP] ----------------- starting session ----------------")
envLog("[EcoVacsXMPP] event = {event}".format({event: JSON.stringify(event)}));
this.emit("ready", event);
}
subscribe_to_ctls(func) {
envLog("[EcoVacsXMPP] Adding listener to ready event");
this.on("ready", func);
}
send_command(xml, recipient) {
let c = this._wrap_command(xml, recipient);
envLog('[EcoVacsXMPP] Sending xml:', c.toString());
this.simpleXmpp.conn.send(c);
}
_wrap_command(ctl, recipient) {
let id = this.iter++;
let q = new Element('iq', {id: id, to: recipient, from: this._my_address(), type: 'set'});
q.c('query', {xmlns: 'com:ctl'}).cnode(ctl.to_xml());
return q;
}
_my_address() {
return this.user + '@' + this.hostname + '/' + this.resource;
}
send_ping(to) {
let id = this.iter++;
envLog("[EcoVacsXMPP] *** sending ping ***");
var e = new Element('iq', {id: id, to: to, from: this._my_address(), type: 'get'});
e.c('query', {xmlns: 'urn:xmpp:ping'});
envLog("[EcoVacsXMPP] Sending ping XML:", e.toString());
this.simpleXmpp.conn.send(e);
}
connect_and_wait_until_ready() {
envLog("[EcoVacsXMPP] Connecting as %s to %s", this.user + '@' + this.hostname, this.server_address + ":" + this.server_port);
this.simpleXmpp.connect({
jid: this.user + '@' + this.hostname
, password: '0/' + this.resource + '/' + this.secret
, host: this.server_address
, port: this.server_port
});
this.on("ready", (event) => {
this.send_ping(this.bot._vacuum_address());
});
}
}
class VacBotCommand {
constructor(name, args = null) {
if (args == null) {
args = {}
}
this.name = name;
this.args = args;
}
to_xml() {
let ctl = new Element('ctl', {td: this.name});
for (let key in this.args) {
if (this.args.hasOwnProperty(key)) {
let value = this.args[key];
if (isObject(value)) {
ctl.c(key, value);
} else {
ctl.attr(key, value);
}
}
}
return ctl;
}
toString() {
return this.command_name() + " command";
}
command_name() {
return this.name.toLowerCase();
}
}
VacBotCommand.CLEAN_MODE = {
'auto': 'auto',
'edge': 'border',
'spot': 'spot',
'single_room': 'singleroom',
'stop': 'stop'
};
VacBotCommand.FAN_SPEED = {
'normal': 'standard',
'high': 'strong'
};
VacBotCommand.CHARGE_MODE = {
'return': 'go',
'returning': 'Going',
'charging': 'SlotCharging',
'idle': 'Idle'
};
VacBotCommand.COMPONENT = {
'main_brush': 'Brush',
'side_brush': 'SideBrush',
'filter': 'DustCaseHeap'
};
VacBotCommand.ACTION = {
'forward': 'forward',
'left': 'SpinLeft',
'right': 'SpinRight',
'turn_around': 'TurnAround',
'stop': 'stop'
};
class Clean extends VacBotCommand {
constructor(mode = "auto", speed = "normal") {
super("Clean", {'clean': {'type': VacBotCommand.CLEAN_MODE[mode], 'speed': VacBotCommand.FAN_SPEED[speed]}});
}
}
class Edge extends Clean {
constructor() {
super('edge', 'high')
}
}
class Spot extends Clean {
constructor() {
super('spot', 'high')
}
}
class Stop extends Clean {
constructor() {
super('stop', 'normal')
}
}
class Charge extends VacBotCommand {
constructor() {
super("Charge", {'charge': {'type': VacBotCommand.CHARGE_MODE['return']}});
}
}
class Move extends VacBotCommand {
constructor(action) {
super("Move", {'move': {'action': VacBotCommand.ACTION[action]}});
}
}
class GetDeviceInfo extends VacBotCommand {
constructor() {
super("GetDeviceInfo");
}
}
class GetCleanState extends VacBotCommand {
constructor() {
super("GetCleanState");
}
}
class GetChargeState extends VacBotCommand {
constructor() {
super("GetChargeState");
}
}
class GetBatteryState extends VacBotCommand {
constructor() {
super("GetBatteryInfo");
}
}
class GetLifeSpan extends VacBotCommand {
constructor(component) {
super("GetLifeSpan", {'type': VacBotCommand.COMPONENT[component]});
}
}
class SetTime extends VacBotCommand {
constructor(timestamp, timezone) {
super("SetTime", {'time': {'t': timestamp, 'tz': timezone}});
}
}
function isObject(val) {
if (val === null) {
return false;
}
return ((typeof val === 'function') || (typeof val === 'object'));
}
envLog = function () {
if (process.env.NODE_ENV == "development" || process.env.NODE_ENV == "dev") {
console.log.apply(this, arguments);
}
}
module.exports.EcoVacsAPI = EcoVacsAPI;
module.exports.VacBot = VacBot;
module.exports.EcoVacsXMPP = EcoVacsXMPP;
module.exports.Clean = Clean;
module.exports.Edge = Edge;
module.exports.Spot = Spot;
module.exports.Stop = Stop;
module.exports.Charge = Charge;
module.exports.Move = Move;
module.exports.GetDeviceInfo = GetDeviceInfo;
module.exports.GetCleanState = GetCleanState;
module.exports.GetChargeState = GetChargeState;
module.exports.GetBatteryState = GetBatteryState;
module.exports.GetLifeSpan = GetLifeSpan;
module.exports.SetTime = SetTime;
module.exports.isObject = isObject;
module.exports.countries = countries;