UNPKG

homebridge-xiaomi-roborock-vacuum

Version:

Xiaomi Vacuum Cleaner - 1st (Mi Robot), 2nd (Roborock S50 + S55), 3rd Generation (Roborock S6) and S5 Max - plugin for Homebridge.

598 lines (505 loc) 16 kB
"use strict"; const EventEmitter = require("events"); const dgram = require("dgram"); const debug = require("debug"); const Packet = require("./packet"); const tokens = require("./tokens"); const safeishJSON = require("./safeishJSON"); const PORT = 54321; const ERRORS = { "-5001": (method, args, err) => err.message === "invalid_arg" ? "Invalid argument" : err.message, "-5005": (method, args, err) => err.message === "params error" ? "Invalid argument" : err.message, "-10000": (method) => "Method `" + method + "` is not supported", }; /** * Class for keeping track of the current network of devices. This is used to * track a few things: * * 1) Mapping between adresses and device identifiers. Used when connecting to * a device directly via IP or hostname. * * 2) Mapping between id and detailed device info such as the model. * */ class Network extends EventEmitter { constructor() { super(); this.packet = new Packet(true); this.addresses = new Map(); this.devices = new Map(); this.references = 0; this.debug = debug("miio:network"); } search() { this.packet.handshake(); const data = Buffer.from(this.packet.raw); this.socket.send(data, 0, data.length, PORT, "255.255.255.255"); // Broadcast an extra time in 500 milliseconds in case the first brodcast misses a few devices setTimeout(() => { this.socket.send(data, 0, data.length, PORT, "255.255.255.255"); }, 500); } findDevice(id, rinfo) { // First step, check if we know about the device based on id let device = this.devices.get(id); if (!device && rinfo) { // If we have info about the address, try to resolve again device = this.addresses.get(rinfo.address); if (!device) { // No device found, keep track of this one device = new DeviceInfo(this, id, rinfo.address, rinfo.port); this.devices.set(id, device); this.addresses.set(rinfo.address, device); return device; } } return device; } findDeviceViaAddress(options) { if (!this.socket) { throw new Error( "Implementation issue: Using network without a reference" ); } let device = this.addresses.get(options.address); if (!device) { // No device was found at the address, try to discover it device = new DeviceInfo( this, null, options.address, options.port || PORT ); this.addresses.set(options.address, device); } // Update the token if we have one if (typeof options.token === "string") { device.token = Buffer.from(options.token, "hex"); } else if (options.token instanceof Buffer) { device.token = options.token; } // Set the model if provided if (!device.model && options.model) { device.model = options.model; } // Perform a handshake with the device to see if we can connect return device .handshake() .catch((err) => { if (err.code === "missing-token") { // Supress missing tokens - enrich should take care of that return; } throw err; }) .then(() => { if (!this.devices.has(device.id)) { // This is a new device, keep track of it this.devices.set(device.id, device); return device; } else { // Sanity, make sure that the device in the map is returned return this.devices.get(device.id); } }) .then((device) => { /* * After the handshake, call enrich which will fetch extra * information such as the model. It will also try to check * if the provided token (or the auto-token) works correctly. */ return device.enrich(); }) .then(() => device); } createSocket() { this._socket = dgram.createSocket("udp4"); // Bind the socket and when it is ready mark it for broadcasting this._socket.bind(); this._socket.on("listening", () => { this._socket.setBroadcast(true); const address = this._socket.address(); this.debug("Network bound to port", address.port); }); // On any incoming message, parse it, update the discovery this._socket.on("message", (msg, rinfo) => { const buf = Buffer.from(msg); try { this.packet.raw = buf; } catch (ex) { this.debug("Could not handle incoming message"); return; } if (!this.packet.deviceId) { this.debug("No device identifier in incoming packet"); return; } const device = this.findDevice(this.packet.deviceId, rinfo); device.onMessage(buf); if (!this.packet.data) { if (!device.enriched) { // This is the first time we see this device device .enrich() .then(() => { this.emit("device", device); }) .catch((err) => { this.emit("device", device); }); } else { this.emit("device", device); } } }); } list() { return this.devices.values(); } /** * Get a reference to the network. Helps with locking of a socket. */ ref() { this.debug("Grabbing reference to network"); this.references++; this.updateSocket(); let released = false; let self = this; return { release() { if (released) return; self.debug("Releasing reference to network"); released = true; self.references--; self.updateSocket(); }, }; } /** * Update wether the socket is available or not. Instead of always keeping * a socket we track if it is available to allow Node to exit if no * discovery or device is being used. */ updateSocket() { if (this.references === 0) { // No more references, kill the socket if (this._socket) { this.debug("Network no longer active, destroying socket"); this._socket.close(); this._socket = null; } } else if (this.references === 1 && !this._socket) { // This is the first reference, create the socket this.debug("Making network active, creating socket"); this.createSocket(); } } get socket() { if (!this._socket) { throw new Error( "Network communication is unavailable, device might be destroyed" ); } return this._socket; } } module.exports = new Network(); class DeviceInfo { constructor(parent, id, address, port) { this.parent = parent; this.packet = new Packet(); this.address = address; this.port = port; // Tracker for all promises associated with this device this.promises = new Map(); this.lastId = 0; this.id = id; this.debug = id ? debug("thing:miio:" + id) : debug("thing:miio:pending"); // Get if the token has been manually changed this.tokenChanged = false; } get token() { return this.packet.token; } set token(t) { this.debug("Using manual token:", t.toString("hex")); this.packet.token = t; this.tokenChanged = true; } /** * Enrich this device with detailed information about the model. This will * simply call miIO.info. */ enrich() { if (!this.id) { throw new Error("Device has no identifier yet, handshake needed"); } if (this.model && !this.tokenChanged && this.packet.token) { // This device has model info and a valid token return Promise.resolve(); } if (this.enrichPromise) { // If enrichment is already happening return this.enrichPromise; } // Check if there is a token available, otherwise try to resolve it let promise; if (!this.packet.token) { // No automatic token found - see if we have a stored one this.debug( "Loading token from storage, device hides token and no token set via options" ); this.autoToken = false; promise = tokens.get(this.id).then((token) => { this.debug("Using stored token:", token); this.packet.token = Buffer.from(token, "hex"); this.tokenChanged = true; }); } else { if (this.tokenChanged) { this.autoToken = false; } else { this.autoToken = true; this.debug("Using automatic token:", this.packet.token.toString("hex")); } promise = Promise.resolve(); } return (this.enrichPromise = promise .then(() => this.call("miIO.info")) .then((data) => { this.enriched = true; this.model = data.model; this.tokenChanged = false; this.enrichPromise = null; }) .catch((err) => { this.enrichPromise = null; this.enriched = true; if (err.code === "missing-token") { // Rethrow some errors err.device = this; throw err; } if (this.packet.token) { // Could not call the info method, this might be either a timeout or a token problem const e = new Error( "Could not connect to device, token might be wrong" ); e.code = "connection-failure"; e.device = this; throw e; } else { const e = new Error( "Could not connect to device, token needs to be specified" ); e.code = "missing-token"; e.device = this; throw e; } })); } onMessage(msg) { try { this.packet.raw = msg; } catch (ex) { this.debug("<- Unable to parse packet", ex); return; } let data = this.packet.data; if (data === null) { this.debug("<-", "Handshake reply:", this.packet.checksum); this.packet.handleHandshakeReply(); if (this.handshakeResolve) { this.handshakeResolve(); } } else { // Handle null-terminated strings if (data[data.length - 1] === 0) { data = data.slice(0, data.length - 1); } // Parse and handle the JSON message let str = data.toString("utf8"); // Remove non-printable characters to help with invalid JSON from devices str = str.replace(/[\x00-\x09\x0B-\x0C\x0E-\x1F\x7F-\x9F]/g, ""); // eslint-disable-line this.debug("<- Message: `" + str + "`"); try { let object = safeishJSON(str); const p = this.promises.get(object.id); if (!p) return; if (typeof object.result !== "undefined") { p.resolve(object.result); } else { p.reject(object.error); } } catch (ex) { this.debug("<- Invalid JSON", ex); } } } handshake() { if (!this.packet.needsHandshake) { return Promise.resolve(this.token); } // If a handshake is already in progress use it if (this.handshakePromise) { return this.handshakePromise; } return (this.handshakePromise = new Promise((resolve, reject) => { // Create and send the handshake data this.packet.handshake(); const data = this.packet.raw; this.parent.socket.send( data, 0, data.length, this.port, this.address, (err) => err && reject(err) ); // Handler called when a reply to the handshake is received this.handshakeResolve = () => { clearTimeout(this.handshakeTimeout); this.handshakeResolve = null; this.handshakeTimeout = null; this.handshakePromise = null; if (this.id !== this.packet.deviceId) { // Update the identifier if needed this.id = this.packet.deviceId; this.debug = debug("thing:miio:" + this.id); this.debug("Identifier of device updated"); } if (this.packet.token) { resolve(); } else { const err = new Error( "Could not connect to device, token needs to be specified" ); err.code = "missing-token"; reject(err); } }; // Timeout for the handshake this.handshakeTimeout = setTimeout(() => { this.handshakeResolve = null; this.handshakeTimeout = null; this.handshakePromise = null; const err = new Error("Could not connect to device, handshake timeout"); err.code = "timeout"; reject(err); }, 2000); })); } call(method, args, options) { if (typeof args === "undefined") { args = []; } const request = { method: method, params: args, }; if (options && options.sid) { // If we have a sub-device set it (used by Lumi Smart Home Gateway) request.sid = options.sid; } return new Promise((resolve, reject) => { let resolved = false; // Handler for incoming messages const promise = { resolve: (res) => { resolved = true; this.promises.delete(request.id); resolve(res); }, reject: (err) => { resolved = true; this.promises.delete(request.id); if (!(err instanceof Error) && typeof err.code !== "undefined") { const code = err.code; const handler = ERRORS[code]; let msg; if (handler) { msg = handler(method, args, err.message); } else { msg = err.message || err.toString(); } err = new Error(msg); err.code = code; } reject(err); }, }; let retriesLeft = (options && options.retries) || 5; const retry = () => { if (retriesLeft-- > 0) { send(); } else { this.debug("Reached maximum number of retries, giving up"); const err = new Error("Call to device timed out"); err.code = "timeout"; promise.reject(err); } }; const send = () => { if (resolved) return; this.handshake() .catch((err) => { if (err.code === "timeout") { this.debug("<- Handshake timed out"); retry(); return false; } else { throw err; } }) .then((token) => { // Token has timed out - handled via retry if (!token) return; // Assign the identifier before each send let id; if (request.id) { /* * This is a failure, increase the last id. Should * increase the chances of the new request to * succeed. Related to issues with the vacuum * not responding such as described in issue #94. */ id = this.lastId + 100; // Make sure to remove the failed promise this.promises.delete(request.id); } else { id = this.lastId + 1; } // Check that the id hasn't rolled over if (id >= 10000) { this.lastId = id = 1; } else { this.lastId = id; } // Assign the identifier request.id = id; // Store reference to the promise so reply can be received this.promises.set(id, promise); // Create the JSON and send it const json = JSON.stringify(request); this.debug("-> (" + retriesLeft + ")", json); this.packet.data = Buffer.from(json, "utf8"); const data = this.packet.raw; this.parent.socket.send( data, 0, data.length, this.port, this.address, (err) => err && promise.reject(err) ); // Queue a retry in 2 seconds setTimeout(retry, 2000); }) .catch(promise.reject); }; send(); }); } }