UNPKG

homebridge-miot

Version:

Homebridge plugin for devices supporting the miot protocol

739 lines (620 loc) 24 kB
const fs = require('fs'); const path = require('path'); const miio = require('miio'); const EventEmitter = require('events'); const MiotProperty = require('./MiotProperty.js'); const MiotAction = require('./MiotAction.js'); const MiCloud = require('./MiCloud.js'); const Properties = require('../constants/Properties.js'); const DevTypes = require('../constants/DevTypes.js'); const PropFormat = require('../constants/PropFormat.js'); const PropUnit = require('../constants/PropUnit.js'); const PropAccess = require('../constants/PropAccess.js'); const Constants = require('../constants/Constants.js'); const Events = require('../constants/Events.js'); const COMMAND_GET = 'get_properties'; const COMMAND_SET = 'set_properties'; const COMMAND_ACTION = 'action'; const ALL_PROP_REQUEST_CHUNK_SIZE = 14; const MAX_POLL_RETRIES = 4; // DEVICES: http://miot-spec.org/miot-spec-v2/instances?status=all // device types: http://miot-spec.org/miot-spec-v2/spec/devices // service types: http://miot-spec.org/miot-spec-v2/spec/services class MiotDevice extends EventEmitter { constructor(model, deviceId, name, logger) { super(); // config this.deviceId = deviceId; this.model = model; this.name = name; this.logger = logger; this.miCloudConfig = {}; this.pollingInterval = Constants.DEFAULT_POLLING_INTERVAL; if (!this.model) { this.logger.error(`Missing model information!`); } //device info this.miioDevice = undefined; this.deviceInfo = {}; this.miCloud = undefined; this.miCloudDeviceInfo = {}; // prepare the variables this.properties = {}; this.actions = {}; this.updateDevicePropertiesInterval = undefined; this.pollRetries = 0; // init the device this._initDevice(); } /*----------========== DEVICE INFO ==========----------*/ static getDeviceModel() { return null; } getDeviceName() { return "Unknown"; } getDeviceMiotSpec() { return null; } /*----------========== INIT ==========----------*/ _initDevice() { // init device properties this.logger.info(`Initializing device properties`); this.initDeviceProperties(); this.logger.debug(`Device properties: ${JSON.stringify(Object.keys(this.properties), null, 2)}`); // init device actions this.logger.info(`Initializing device actions`); this.initDeviceActions(); if (this.hasActions()) { this.logger.info(`Device actions: ${JSON.stringify(Object.keys(this.actions), null, 2)}`); } } /*----------========== SETUP ==========----------*/ async _setupDeviceInternal() { this.logger.info(`Setting up device!`); // get the device info this._fetchDeviceInfo(); // get the device deviceId if not specified if (!this.deviceId) { this.deviceId = this.getDeviceId(); this.logger.info(`Did not specified. Got did: ${this.deviceId} from device!`); } // make sure we have the did, soft warning to the user if not this._checkDid(); // do a device specific device setup this.logger.info(`Doing device specific setup`); this.deviceSpecificSetup(); // if device requires a MiCloud connection then try to connect if (this.requiresMiCloud()) { this.logger.info(`Device requires MiCloud! Trying to connect!`); try { await this._connectToMiCloud(); } catch (err) { this.logger.warn(err); this.disconnectAndDestroyMiioDevice(); let reconnectTimeout = this.pollingInterval * 6; this.logger.debug(`Trying to reconnect in ${reconnectTimeout/1000} seconds...`); setTimeout(() => { this.emit(Events.DEVICE_DISCONNECTED, this); }, reconnectTimeout); throw new Error(`MiCloud is required but login to MiCloud failed!`); } } // get device info from MiCloud if specified if (!this.miCloudDeviceInfo || Object.keys(this.miCloudDeviceInfo).length === 0) { this._connectToMiCloud().then(() => { if (this.miCloud && this.miCloud.isLoggedIn()) { this.logger.info(`Getting device info from MiCloud!`); this.miCloud.getDevice(this.deviceId).then((result) => { if (result !== undefined) { this.miCloudDeviceInfo = result; this.logger.debug(`Got device info from MiCloud: \n${JSON.stringify(result, null, 2)}`); } else { throw new Error(`The device with id ${this.deviceId} could not be found on the ${this.miCloudConfig.country || 'cn'} server! Please make sure that the specified micloud country is correct!`); } }).catch((err) => { this.logger.debug(`Could not retrieve device info from MiCloud!`); if (this.requiresMiCloud()) { this.logger.warn(`${err}`); } else { this.logger.debug(`${err}`); } }); } }); } // initial properties fetch this._doInitialPropertiesFetch(); // start device property polling this._pollDeviceProperties(); this.logger.info(`Device setup finished! Device ready, you can now control your device!`); } _fetchDeviceInfo() { // get the device info if (!this.deviceInfo || Object.keys(this.deviceInfo).length === 0) { this.logger.debug(`Fetching device info.`); this.miioDevice.management.info().then((info) => { this.deviceInfo = info; this.logger.debug(`Got device info! Device firmware: ${this.deviceInfo.fw_ver}`); this.logger.deepDebug(`Full device info: \n${JSON.stringify(this.deviceInfo, null, 2)}`); }).catch(err => { this.logger.debug(`Could not retrieve device info: ${err}`); }); } } _checkDid() { // make sure that we have the deviceId, not sure if this is required for local calls even on the miot protocol(maybe only required for cloud calls) // just a soft warning since locally the control works also without did try { if (!this.getDeviceId()) throw new Error(`Could not find deviceId for ${this.name}! This may cause issues! Please specify a deviceId in the 'config.json' file!`); } catch (error) { this.logger.warn(error); } } async _connectToMiCloud() { if (this.miCloudConfig && this.miCloudConfig.username && this.miCloudConfig.password) { if (!this.miCloud) { let requestTimeout = parseInt(this.miCloudConfig.timeout) || Constants.DEFAULT_MICLOUD_REQUEST_TIMEOUT; requestTimeout = requestTimeout > this.pollingInterval ? this.pollingInterval : requestTimeout; // make sure we do not exceed polling interval this.miCloud = new MiCloud(this.logger); this.miCloud.setRequestTimeout(requestTimeout); await this.miCloud.login(this.miCloudConfig.username, this.miCloudConfig.password, this.miCloudConfig.country); this.logger.info(`Successfully connected to MiCloud!`); } } else if (this.requiresMiCloud()) { throw new Error(`The device requires a MiCloud connection! In order to control the device please specify a MiCloud username and password! Canceling setup!`); } } /*----------========== DEVICE LIFECYCLE ==========----------*/ _doInitialPropertiesFetch() { this.logger.info(`Doing initial property fetch`); // initial properties fetch this.requestAllProperties().then((result) => { // on initial connection log the retrieved properties this.logger.debug(`Got initial device properties: \n${this.getBeautifiedAllPropNameValues()}`); this._gotInitialPropertiesFromDevice(); }).catch(err => { this.logger.debug(`Error on initial property request! ${err}`); }); } _gotInitialPropertiesFromDevice() { // log the total use time if the device supports it if (this.supportsUseTimeReporting()) { this.logger.info(`Device total use time: ${this.getUseTime()} minutes.`); } // devices actions this.initialPropertyFetchDone(); } _pollDeviceProperties() { this.updateDevicePropertiesInterval = setInterval(() => { this.pollProperties().then(result => { //TODO: result is empty here and above, there should be a list of updated properties which i can then react to //this.logger.debug(`Poll successful! Got data from miot device!`); this.emit(Events.DEVICE_ALL_PROPERTIES_UPDATED, this); this.logger.deepDebug(`Device properties updated: \n${this.getBeautifiedAllPropNameValues()}`); this.pollRetries = 0; }).catch(err => { this.pollRetries++; if (this.updateDevicePropertiesInterval && this.pollRetries >= MAX_POLL_RETRIES) { this.logger.warn(`Poll failed ${this.pollRetries} times in a row! Stopping polling and trying to reconnect! Reason: ${err}`); clearInterval(this.updateDevicePropertiesInterval); this.updateDevicePropertiesInterval = undefined; this.pollRetries = 0; this.disconnectAndDestroyMiioDevice(); this.logger.debug(`Trying to reconnect...`); this.emit(Events.DEVICE_DISCONNECTED, this); } else { this.logger.debug(`Poll failed! No response from device! ${err}`); } }); }, this.pollingInterval); } /*----------========== DEVICE CONTROL ==========----------*/ setMiCloudConfig(newMiCloudConfig) { this.miCloudConfig = newMiCloudConfig; } setPollingInterval(newPollingInterval) { if (newPollingInterval >= 1000) { this.pollingInterval = newPollingInterval; } } async setupDevice(newMiioDevice) { if (newMiioDevice === undefined) { this.logger.warn(`Missing miio device! Cannot proceed with setup!`); return; } try { if (!this.miioDevice) { this.miioDevice = newMiioDevice; await this._setupDeviceInternal(); // run setup only for the first time } else { //TODO: what to do with this? is actaully never called since miotcontroller destroys the miiodevice calling the bloew method this.miioDevice = newMiioDevice; this.logger.info(`Reconnected to device!`); } this.emit(Events.DEVICE_CONNECTED, this); } catch (error) { this.logger.warn(`Setup failed!`); this.logger.warn(error); } } disconnectAndDestroyMiioDevice() { if (this.miioDevice) { this.miioDevice.destroy(); } this.miioDevice = undefined; // if required, also refresh the micloud connection if (this.miCloud && this.requiresMiCloud()) { if (this.miCloud.isLoggedIn()) { this.miCloud.logout(); } this.miCloud = null; } } async pollProperties() { if (this.isConnected()) { return this.requestAllProperties(); } return new Promise((resolve, reject) => { reject(new Error('Device not connected')); }); } /*----------========== DEVICE OVERRIDES ==========----------*/ initDeviceProperties() { // implemented by devices } initDeviceActions() { // implemented by devices } deviceSpecificSetup() { // implemented by devices } initialPropertyFetchDone() { // implemented by devices } /*----------========== CONFIG ==========----------*/ requiresMiCloud() { return false || this.miCloudConfig.forceMiCloud === true; } /*----------========== INFO ==========----------*/ isConnected() { if (this.requiresMiCloud()) { return this.miioDevice && this.miCloud && this.miCloud.isLoggedIn(); } return this.miioDevice !== undefined; } getModel() { if (this.isConnected()) { return this.miioDevice.miioModel; } return this.model; } getType() { return DevTypes.UNKNOWN; } getDeviceInfo() { return this.deviceInfo; } getDeviceId() { if (this.miioDevice && this.miioDevice.id) { return this.miioDevice.id.replace(/^miio:/, ''); } return this.deviceId; } /*----------========== METADATA ==========----------*/ // properties addProperty(name, siid, piid, format, access, unit, valueRange, valueList) { if (!name) { this.logger.warn(`Missing name! Cannot create property!`); return; } if (!siid || !piid) { this.logger.warn(`Missing siid or piid for ${name} property! Cannot create!`); return; } let newProp = new MiotProperty(name, siid, piid, format, access, unit, valueRange, valueList, this); this.properties[name] = newProp; return newProp; } hasProperty(propName) { if (!propName || propName.length === 0) { return false; } return this.properties[propName] !== undefined; } getProperty(propName) { let prop = this.properties[propName]; if (prop) { return prop; } this.logger.warn(`The property ${propName} was not found on this deivce!`); return null; } getAllProperties() { return this.properties; } getBeautifiedAllPropNameValues() { // only readable properties let readablePropKeys = Object.keys(this.properties).filter(key => this.properties[key].isReadable()); let propNameValues = readablePropKeys.map(key => this.properties[key].getNameValueString()); return JSON.stringify(propNameValues, null, 2); } // actions addAction(name, siid, aiid, inDef) { if (!name) { this.logger.warn(`Missing name! Cannot create action!`); return; } if (!siid || !aiid) { this.logger.warn(`Missing siid or aiid for ${name} action! Cannot create!`); return; } let newActions = new MiotAction(name, siid, aiid, inDef, this); this.actions[name] = newActions; return newActions; } hasAction(actionName) { return this.actions[actionName] !== undefined; } hasActions() { return this.actions && Object.keys(this.actions).length > 0; } getAction(actionName) { let action = this.actions[actionName]; if (action) { return action; } this.logger.warn(`The action ${actionName} was not found on this deivce!`); return null; } getAllActions() { return this.actions; } /*----------========== PROPERTY HELPERS ==========----------*/ getPropertyValue(propName) { let prop = this.getProperty(propName); if (prop) { return prop.getValue(); } return undefined; } getSafePropertyValue(propName) { let prop = this.getProperty(propName); if (prop) { return prop.getSafeValue(); } return 0; } getPropertyValueRange(propName) { if (this.hasProperty(propName)) { let prop = this.getProperty(propName); if (prop.hasValueRange()) { return prop.getValueRange(); } } return []; } getPropertyValueList(propName) { if (this.hasProperty(propName)) { let prop = this.getProperty(propName); if (prop.hasValueList()) { return prop.getValueList(); } } return []; } async setPropertyValue(propName, value) { let prop = this.getProperty(propName); if (prop) { let adjustedValue = prop.adjustValueToPropRange(value); if (adjustedValue !== value) { let propRange = prop.getValueRange(); this.logger.debug(`Trying to set ${prop.getName()} property with an out of range value: ${value} Range: ${JSON.stringify(propRange)}. Adjusting value to: ${adjustedValue}`); } if (prop.getValue() !== adjustedValue) { return this.setProperty(prop, adjustedValue); } else { this.logger.debug(`Property ${prop.getName()} seems to have already the value: ${adjustedValue}. Set not needed! Skipping...`); } } } getPropertyUnit(propName) { if (this.hasProperty(propName)) { let prop = this.getProperty(propName); return prop.getUnit(); } return PropUnit.NONE; } /*----------========== ACTION HELPERS ==========----------*/ async fireAction(actionName, paramValues = []) { let action = this.getAction(actionName); if (action) { return this.sendAction(action, paramValues); } } /*----------========== PROTOCOL ==========----------*/ // properties // seems like there is a limit on how many props the device can process at once, observed a limit of 16 // for this reason we are going to split all props into chunks and do sepearte requests for each chunk async requestAllProperties() { if (this.isConnected()) { let propRequestPromises = []; let propKeyChunks = this.getAllReadablePropKeysChunks(); this.logger.debug(`Splitting properties into chunks. Number of chunks: ${propKeyChunks.length}. Chunk size: ${ALL_PROP_REQUEST_CHUNK_SIZE}`); this.logger.deepDebug(`Chunks: ${JSON.stringify(propKeyChunks, null, 1)}`); propKeyChunks.forEach((propChunk) => { propRequestPromises.push(this.requestPropertyChunk(propChunk)); }); return Promise.all(propRequestPromises); } else { return this.createErrorPromise(`Cannot poll all properties! Device not connected!`); } } async requestPropertyChunk(propKeys) { if (this.isConnected()) { let protcolProps = propKeys.map(key => this.getProperty(key).getReadProtocolObjForDid(this.deviceId)); return this.getMiotProperties(protcolProps) .then(result => { // if no response or response empty then throw an error so that the the poll will fail if (!result || result.length === 0) { throw new Error(`No response or response empty!`) } // all good, process the data const obj = {}; for (let i = 0; i < result.length; i++) { this.updatePropertyValueFromDevice(obj, propKeys[i], result[i]); } return obj; }); // no catch here, catch has to be handled by caller, in that case the property polling } else { return this.createErrorPromise(`Cannot request property chunk values! Device not connected!`); } } // currently not used, but can be used to retrieve a single property value async requestProperty(prop) { if (prop) { if (this.isConnected()) { if (prop.isReadable()) { let propDef = prop.getReadProtocolObjForDid(this.deviceId); this.logger.deepDebug(`Request ${prop.getName()} property! RAW: ${JSON.stringify(propDef)}`); return this.getMiotProperties([propDef]) .then(result => { this.logger.debug(`Successfully updated property ${prop} value! Result: ${JSON.stringify(result)}`); const obj = {}; this.updatePropertyValueFromDevice(obj, prop.getName(), result[0]); this.emit(Events.DEVICE_PROPERTY_UPDATED, prop); return obj; }).catch(err => { this.logger.debug(`Error while requesting property ${prop.getName()}! ${err}`); }); } else { return this.createErrorPromise(`Cannot update property ${prop.getName()}! Property is write only!`); } } else { return this.createErrorPromise(`Cannot update property ${prop.getName()}! Device not connected!`); } } else { return this.createErrorPromise(`Missing property! Cannot execute read request!`); } } // set property async setProperty(prop, value) { if (prop) { if (this.isConnected()) { let propDef = prop.getWriteProtocolObjForDid(this.deviceId, value); this.logger.deepDebug(`Set ${prop.getName()} property request! RAW: ${JSON.stringify(propDef)}`); return this.setMiotProperties([propDef]).then(result => { if (this.isResponseValid(result[0])) { this.logger.debug(`Successfully set property ${prop.getName()} to value ${value}! Response: ${JSON.stringify(result)}`); prop.updateInternalValue(value); // do not wait for poll, update the local prop and notifiy listeners after successful set this.emit(Events.DEVICE_PROPERTY_UPDATED, prop); } else { throw new Error(`Invalid response. Response: ${JSON.stringify(result)}`); } }).catch(err => { this.logger.debug(`Error while setting property ${prop.getName()} to value ${value}! ${err}`); }); } else { return this.createErrorPromise(`Cannot set property ${prop.getName()} to value ${value}! Device not connected!`); } } else { return this.createErrorPromise(`Missing property! Cannot set the value!`); } } // actions async sendAction(action, paramValues = []) { if (action) { if (this.isConnected()) { let actionDef = action.getProtocolAction(this.deviceId, paramValues); this.logger.deepDebug(`Send action! RAW: ${JSON.stringify(actionDef)}`); return this.miotAction(actionDef).then(result => { if (this.isResponseValid(result)) { this.logger.debug(`Successfully executed action ${action.getName()} with params ${paramValues}! Result: ${JSON.stringify(result)}`); this.emit(Events.DEVICE_ACTION_EXECUTED, action); } else { throw new Error(`Invalid response. Response: ${JSON.stringify(result)}`); } action.setLastResult(result); return action; }).catch(err => { this.logger.debug(`Error while executing action ${action.getName()} with params ${paramValues}! ${err}`); }); } else { return this.createErrorPromise(`Cannot execute action ${action.getName()} with params ${paramValues}! Device not connected!`); } } else { return this.createErrorPromise(`Missing action! Cannot execute action request!`); } } /*----------========== PROTOCOL CALLS ==========----------*/ async getMiotProperties(params = []) { if (this.requiresMiCloud()) { return this.miCloud.miotGetProps(params); } else { return this.miioDevice.call(COMMAND_GET, params); } } async setMiotProperties(params = []) { if (this.requiresMiCloud()) { return this.miCloud.miotSetProps(params); } else { return this.miioDevice.call(COMMAND_SET, params); } } async miotAction(param = {}) { if (this.requiresMiCloud()) { return this.miCloud.miotAction(param); } else { return this.miioDevice.call(COMMAND_ACTION, param); } } /*----------========== PROTOCOL HELPERS ==========----------*/ // get only properties which are reabadle getAllReadableProps() { let tmpAllReadableProps = {}; let allPropKeys = Object.keys(this.properties); allPropKeys.forEach((key) => { let tmpProp = this.properties[key]; if (tmpProp && tmpProp.isReadable()) { tmpAllReadableProps[key] = tmpProp; } }); return tmpAllReadableProps; } // split all readable props into smaller chunks getAllReadablePropKeysChunks() { let propertyChunks = []; let allReadableProps = this.getAllReadableProps(); let readablePropKeys = Object.keys(allReadableProps); for (let i = 0; i < readablePropKeys.length; i += ALL_PROP_REQUEST_CHUNK_SIZE) { let propChunk = readablePropKeys.slice(i, i + ALL_PROP_REQUEST_CHUNK_SIZE); propertyChunks.push(propChunk); } return propertyChunks; } // updates the property value with the value retrieved from the device updatePropertyValueFromDevice(result, propName, response) { if (this.isResponseValid(response)) { this.getProperty(propName).updateInternalValue(response.value); result[propName] = response.value; } else { this.logger.debug(`Error while parsing response from device for property ${propName}. Response: ${JSON.stringify(response)}`); } } isResponseValid(response) { if (response && response.code === 0) { return true; } else { return false; } } /*----------========== HELPERS ==========----------*/ createErrorPromise(msg) { return new Promise((resolve, reject) => { reject(new Error(msg)); }).catch(err => { this.logger.debug(err); }); } } module.exports = MiotDevice;