UNPKG

homebridge-meross

Version:

Homebridge plugin to control Meross devices.

823 lines (751 loc) 29.9 kB
/* jshint node: true,esversion: 9, -W014, -W033 */ /* eslint-disable new-cap */ 'use strict' // Packages and constant variables for this class const axios = require('axios') const crypto = require('crypto') const devicesInHB = new Map() const plugin = require('./../package.json') // Variables for this class to use later let cloudClient = false // Create the platform class class MerossPlatform { constructor (log, config, api) { // Don't load the plugin if these aren't accessible for any reason if (!log || !api) { return } // Begin plugin initialisation try { this.api = api this.consts = require('./utils/constants') this.funcs = require('./utils/functions') this.log = log // Configuration objects for accessories this.deviceConf = {} this.hideChannels = [] this.hideMasters = [] this.ignoredDevices = [] this.localUUIDs = [] // Retrieve the user's chosen language file this.lang = require('./utils/lang-en') // Make sure user is running Homebridge v1.3 or above if (!api.versionGreaterOrEqual || !api.versionGreaterOrEqual('1.3.0')) { throw new Error(this.lang.hbVersionFail) } // Check the user has configured the plugin if (!config) { throw new Error(this.lang.pluginNotConf) } // Log some environment info for debugging this.log( '%s v%s | Node %s | HB v%s%s...', this.lang.initialising, plugin.version, process.version, api.serverVersion, config.plugin_map ? ' | HOOBS v3' : require('os') .hostname() .includes('hoobs') ? ' | HOOBS v4' : '' ) // Apply the user's configuration this.config = this.consts.defaultConfig this.applyUserConfig(config) // Set up the Homebridge events this.api.on('didFinishLaunching', () => this.pluginSetup()) this.api.on('shutdown', () => this.pluginShutdown()) } catch (err) { // Catch any errors during initialisation const eText = this.funcs.parseError(err, [this.lang.hbVersionFail, this.lang.pluginNotConf]) log.warn('***** %s. *****', this.lang.disabling) log.warn('***** %s. *****', eText) } } applyUserConfig (config) { // These shorthand functions save line space during config parsing const logDefault = (k, def) => { this.log.warn('%s [%s] %s %s.', this.lang.cfgItem, k, this.lang.cfgDef, def) } const logDuplicate = k => { this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgDup) } const logIgnore = k => { this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgIgn) } const logIgnoreItem = k => { this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgIgnItem) } const logIncrease = (k, min) => { this.log.warn('%s [%s] %s %s.', this.lang.cfgItem, k, this.lang.cfgLow, min) } const logQuotes = k => { this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgQts) } const logRemove = k => { this.log.warn('%s [%s] %s.', this.lang.cfgItem, k, this.lang.cfgRmv) } // Begin applying the user's config for (const [key, val] of Object.entries(config)) { switch (key) { case 'cloudRefreshRate': case 'refreshRate': { if (typeof val === 'string') { logQuotes(key) } const intVal = parseInt(val) if (isNaN(intVal)) { logDefault(key, this.consts.defaultValues[key]) } else if (intVal !== 0 && intVal < this.consts.minValues[key]) { logIncrease(key, this.consts.minValues[key]) } else { this.config[key] = intVal } break } case 'debug': case 'disableDeviceLogging': case 'disablePlugin': if (typeof val === 'string') { logQuotes(key) } this.config[key] = val === 'false' ? false : !!val break case 'devices': case 'diffuserDevices': case 'garageDevices': case 'lightDevices': case 'multiDevices': case 'rollerDevices': case 'sensorDevices': case 'singleDevices': if (Array.isArray(val) && val.length > 0) { val.forEach(x => { if (!x.serialNumber || !x.name || !x.model) { logIgnoreItem(key) return } const id = x.serialNumber.toLowerCase().replace(/[^a-z0-9]+/g, '') if (Object.keys(this.deviceConf).includes(id)) { logDuplicate(key + '.' + id) return } if (key === 'devices') { this.log.warn('[%s] %s.', id, this.lang.cfgMoveEntry) } const entries = Object.entries(x) if (entries.length === 1) { logRemove(key + '.' + id) return } this.deviceConf[id] = {} for (const [k, v] of entries) { if (!this.consts.allowed[key].includes(k)) { logRemove(key + '.' + id + '.' + k) continue } switch (k) { case 'adaptiveLightingShift': case 'brightnessStep': case 'garageDoorOpeningTime': case 'inUsePowerThreshold': case 'lowBattThreshold': { if (typeof v === 'string') { logQuotes(key + '.' + id + '.' + k) } const intVal = parseInt(v) if (isNaN(intVal)) { logDefault(key + '.' + id + '.' + k, this.consts.defaultValues[k]) this.deviceConf[id][k] = this.consts.defaultValues[k] } else if (intVal < this.consts.minValues[k]) { logIncrease(key + '.' + id + '.' + k, this.consts.minValues[k]) this.deviceConf[id][k] = this.consts.minValues[k] } else { this.deviceConf[id][k] = intVal } break } case 'overrideLogging': case 'showAs': { const inSet = this.consts.allowed[k].includes(v) if (typeof v !== 'string' || !inSet) { logIgnore(key + '.' + id + '.' + k) } else { this.deviceConf[id][k] = inSet ? v : this.consts.defaultValues[k] } break } case 'deviceUrl': case 'firmwareRevision': case 'model': case 'name': case 'serialNumber': if (typeof v !== 'string' || v === '') { logIgnore(key + '.' + id + '.' + k) } else { this.deviceConf[id][k] = v if (k === 'deviceUrl') { this.localUUIDs.push(id) } } break case 'hideChannels': { if (typeof v !== 'string' || v === '') { logIgnore(key + '.' + id + '.' + k) } else { const channels = v.split(',') channels.forEach(channel => { this.hideChannels.push(id + channel.replace(/[^0-9]+/g, '')) this.deviceConf[id][k] = v }) } break } case 'ignoreDevice': if (typeof v === 'string') { logQuotes(key + '.' + id + '.' + k) } if (!!v && v !== 'false') { this.ignoredDevices.push(id) } break case 'reversePolarity': if (typeof v === 'string') { logQuotes(key + '.' + id + '.' + k) } this.deviceConf[id][k] = v === 'false' ? false : !!v break } } }) } else { logIgnore(key) } break case 'name': case 'platform': case 'plugin_map': break case 'password': case 'userkey': case 'username': if (typeof val !== 'string') { logIgnore(key) } else { this.config[key] = val } break default: logRemove(key) break } } } async pluginSetup () { // Plugin has finished initialising so now onto setup try { // Log that the plugin initialisation has been successful this.log('%s.', this.lang.initialised) // If the user has disabled the plugin then remove all accessories if (this.config.disablePlugin) { devicesInHB.forEach(accessory => this.removeAccessory(accessory)) throw new Error(this.lang.disabled) } // Require any libraries that the accessory instances use this.colourUtils = require('./utils/colour-utils') this.cusChar = new (require('./utils/custom-chars'))(this.api) this.eveChar = new (require('./utils/eve-chars'))(this.api) // If the user has configured cloud username and password then get a device list let cloudDevices = [] try { if (!this.config.username || !this.config.password) { throw new Error(this.lang.missingCreds) } cloudClient = new (require('./connection/http'))(this) this.accountDetails = await cloudClient.login() cloudDevices = await cloudClient.getDevices() // Initialise the cloud configured devices into Homebridge cloudDevices.forEach(device => this.initialiseDevice(device)) } catch (err) { const eText = this.funcs.parseError(err, [this.lang.missingCreds]) this.log.warn('%s %s.', this.lang.disablingCloud, eText) cloudClient = false this.accountDetails = { key: this.config.userkey } } // Check if a user key has been configured if the credentials aren't present if (cloudClient || this.config.userkey) { // Initialise the local configured devices into Homebridge Object.values(this.deviceConf) .filter(el => el.deviceUrl) .forEach(device => this.initialiseDevice(device)) } else { // Cloud client disabled and no user key - plugin will be useless throw new Error(this.lang.noCredentials) } // Check for redundant accessories or those that have been ignored but exist devicesInHB.forEach(accessory => { switch (accessory.context.connection) { case 'cloud': if (!cloudDevices.some(el => el.uuid === accessory.context.serialNumber)) { this.removeAccessory(accessory) } break case 'local': if (!this.localUUIDs.includes(accessory.context.serialNumber)) { this.removeAccessory(accessory) } break default: // Should be a never case this.removeAccessory(accessory) break } }) // Log that plugin setup is complete this.log('%s.', this.lang.complete) } catch (err) { // Catch any errors during setup const eText = this.funcs.parseError(err, [this.lang.disabled, this.lang.noCredentials]) this.log.warn('***** %s. *****', this.lang.disabling) this.log.warn('***** %s. *****', eText) this.pluginShutdown() } } pluginShutdown () { // A function that is called when the plugin fails to load or Homebridge restarts try { // Close the mqtt connection for the accessories with an open connection if (cloudClient) { devicesInHB.forEach(accessory => { if (accessory.mqtt) { accessory.mqtt.disconnect() } }) cloudClient.logout() } } catch (err) { // No need to show errors at this point } } async initialiseDevice (device) { try { // Local devices don't have the uuid already set if (device.deviceUrl) { // Rename some properties to fit the format of a cloud device device.uuid = device.serialNumber device.deviceType = device.model.toUpperCase().replace(/[-]+/g, '') device.devName = device.name device.channels = [] // Retrieve how many channels this device has const channelCount = this.funcs.hasProperty( this.consts.models.switchMulti, device.deviceType ) ? this.consts.models.switchMulti[device.deviceType] : 1 // Create a list of channels to fit the format of a cloud device if (channelCount > 1) { for (let index = 0; index <= channelCount; index++) { device.channels.push({}) } } } // Get any user configured entry for this device const deviceConf = this.deviceConf[device.uuid] || {} // Generate a unique id for the accessory const hbUUID = this.api.hap.uuid.generate(device.uuid) device.firmware = deviceConf.firmwareRevision || device.fmwareVersion device.hbDeviceId = device.uuid device.model = device.deviceType.toUpperCase().replace(/[-]+/g, '') let accessory // Add context information for the plugin-ui and instance to use const context = { channel: 0, channelCount: device.channels.length, connection: device.deviceUrl ? 'local' : 'cloud', deviceUrl: deviceConf.deviceUrl, domain: device.domain, firmware: device.firmware, hidden: false, isOnline: false, model: device.model, options: { adaptiveLightingShift: deviceConf.adaptiveLightingShift, brightnessStep: deviceConf.brightnessStep, hideChannels: deviceConf.hideChannels, inUsePowerThreshold: deviceConf.inUsePowerThreshold, lowBattThreshold: deviceConf.lowBattThreshold, operationTime: deviceConf.garageDoorOpeningTime, reversePolarity: deviceConf.reversePolarity }, serialNumber: device.uuid } // Set the logging level for this device context.enableLogging = !this.config.disableDeviceLogging context.enableDebugLogging = this.config.debug switch (deviceConf.overrideLogging) { case 'standard': context.enableLogging = true context.enableDebugLogging = false break case 'debug': context.enableLogging = true context.enableDebugLogging = true break case 'disable': context.enableLogging = false context.enableDebugLogging = false break } // Find the correct instance determined by the device model if (this.consts.models.switchSingle.includes(device.model)) { /**************** SWITCHES (SINGLE) ****************/ let instance = deviceConf.showAs || 'switch' if (instance === 'default') { instance = 'switch' } // Set up the accessory and instance accessory = devicesInHB.get(hbUUID) || this.addAccessory(device) accessory.context = { ...accessory.context, ...context } accessory.control = new (require('./device/' + instance + '-single'))(this, accessory) /***************/ } else if (this.funcs.hasProperty(this.consts.models.switchMulti, device.model)) { /*************** SWITCHES (MULTI) ***************/ let instance = deviceConf.showAs || 'switch' if (instance === 'default') { instance = 'switch' } // Loop through the channels const devName = device.devName for (const index in device.channels) { // Check the entry exists if (!this.funcs.hasProperty(device.channels, index)) { return } const extraContext = {} // Generate the Homebridge UUID from the device uuid and channel index const uuidSub = device.uuid + index const hbUUIDSub = this.api.hap.uuid.generate(uuidSub) device.hbDeviceId = uuidSub // Supply a device name for the channel accessories if (index > 0) { device.devName = devName + ' SW' + index } // Check if the user has chosen to hide any channels for this device let subAcc if (this.hideChannels.includes(device.uuid + index)) { // The user has hidden this channel so if it exists then remove it if (devicesInHB.has(hbUUIDSub)) { this.removeAccessory(devicesInHB.get(hbUUIDSub)) } // If this is the main channel then add it to the array of hidden masters if (index === 0) { this.hideMasters.push(device.uuid) } // Add the sub accessory, but hidden, to Homebridge extraContext.hidden = true extraContext.enableLogging = false extraContext.enableDebugLogging = false subAcc = this.addAccessory(device, true) } else { // The user has not hidden this channel subAcc = devicesInHB.get(hbUUIDSub) || this.addAccessory(device) } // Add the context information to the accessory extraContext.channel = parseInt(index) subAcc.context = { ...subAcc.context, ...context, ...extraContext } // Create the device type instance for this accessory subAcc.control = new (require('./device/' + instance + '-multi'))( this, subAcc, devicesInHB ) // This is used for later in this function for logging if (index === '0') { accessory = subAcc } else { // Update any changes to the accessory to the platform this.api.updatePlatformAccessories(plugin.name, plugin.alias, [subAcc]) devicesInHB.set(subAcc.UUID, subAcc) } } /**************/ } else if (this.consts.models.lightDimmer.includes(device.model)) { /************** LIGHTS (DIMMER) **************/ accessory = devicesInHB.get(hbUUID) || this.addAccessory(device) accessory.context = { ...accessory.context, ...context } accessory.control = new (require('./device/light-dimmer'))(this, accessory) /*************/ } else if (this.consts.models.lightRGB.includes(device.model)) { /*********** LIGHTS (RGB) ***********/ accessory = devicesInHB.get(hbUUID) || this.addAccessory(device) accessory.context = { ...accessory.context, ...context } accessory.control = new (require('./device/light-rgb'))(this, accessory) /**********/ } else if (this.consts.models.garage.includes(device.model)) { /*********** GARAGE DOORS ***********/ accessory = devicesInHB.get(hbUUID) || this.addAccessory(device) accessory.context = { ...accessory.context, ...context } accessory.control = new (require('./device/garage'))(this, accessory) /**********/ } else if (this.consts.models.roller.includes(device.model)) { /************* ROLLING MOTORS *************/ accessory = devicesInHB.get(hbUUID) || this.addAccessory(device) accessory.context = { ...accessory.context, ...context } accessory.control = new (require('./device/roller'))(this, accessory) /***********/ } else if (this.consts.models.diffuser.includes(device.model)) { /******** DIFFUSERS ********/ accessory = devicesInHB.get(hbUUID) || this.addAccessory(device) accessory.context = { ...accessory.context, ...context } accessory.control = new (require('./device/diffuser'))(this, accessory) /******/ } else if (this.consts.models.hubMain.includes(device.model)) { /********** SENSOR HUBS **********/ // At the moment, cloud connection is necessary to get a subdevice list if (!cloudClient) { throw new Error(this.lang.sensorNoCloud) } // First, setup the main, hidden, accessory that will process the incoming updates accessory = this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context, ...{ hidden: true } } accessory.control = new (require('./device/hub-main'))(this, accessory, devicesInHB) // Then request and initialise a list of subdevices const subdevices = await cloudClient.getSubDevices(device) if (!Array.isArray(subdevices)) { throw new Error(this.lang.sensorNoSubs) } subdevices.forEach(subdevice => { try { // Create an object to mimic the addAccessory data const subdeviceObj = device const uuidSub = device.uuid + subdevice.subDeviceId const hbUUIDSub = this.api.hap.uuid.generate(uuidSub) subdeviceObj.devName = subdevice.subDeviceName || subdevice.subDeviceId subdeviceObj.hbDeviceId = uuidSub subdeviceObj.model = subdevice.subDeviceType.toUpperCase().replace(/[-]+/g, '') if (!this.consts.models.hubSub.includes(subdeviceObj.model)) { throw new Error(this.lang.sensorUnsupported) } const subAcc = devicesInHB.get(hbUUIDSub) || this.addAccessory(subdeviceObj) subAcc.context = { ...subAcc.context, ...context, ...{ subSerialNumber: subdevice.subDeviceId } } // Create the device type instance for this accessory subAcc.control = new (require('./device/hub-sensor'))(this, subAcc) // Update any changes to the accessory to the platform this.api.updatePlatformAccessories(plugin.name, plugin.alias, [subAcc]) devicesInHB.set(subAcc.UUID, subAcc) } catch (err) { const eText = this.funcs.parseError(err, [this.lang.sensorUnsupported]) this.log.warn('[%s] %s %s.', subdevice.subDeviceName, this.lang.devNotAdd, eText) } }) /*********/ } else { /******************** UNSUPPORTED AS OF YET ********************/ this.log.warn('[%s] %s:\n%s', device.devName, this.lang.notSupp, JSON.stringify(device)) return /*******************/ } // Log the device initialisation this.log('[%s] %s [%s].', accessory.displayName, this.lang.devInit, device.uuid) // Update any changes to the accessory to the platform this.api.updatePlatformAccessories(plugin.name, plugin.alias, [accessory]) devicesInHB.set(accessory.UUID, accessory) } catch (err) { // Catch any errors during device initialisation const eText = this.funcs.parseError(err, [ this.lang.accNotFound, this.lang.sensorNoCloud, this.lang.sensorNoSubs ]) this.log.warn('[%s] %s %s.', device.devName, this.lang.devNotInit, eText) } } addAccessory (device, hidden = false) { // Add an accessory to Homebridge try { const accessory = new this.api.platformAccessory( device.devName, this.api.hap.uuid.generate(device.hbDeviceId) ) // If it isn't a hidden device then set the accessory characteristics if (!hidden) { accessory .getService(this.api.hap.Service.AccessoryInformation) .setCharacteristic(this.api.hap.Characteristic.SerialNumber, device.uuid) .setCharacteristic(this.api.hap.Characteristic.Manufacturer, this.lang.brand) .setCharacteristic(this.api.hap.Characteristic.Model, device.model) .setCharacteristic( this.api.hap.Characteristic.FirmwareRevision, device.firmware || plugin.version ) .setCharacteristic(this.api.hap.Characteristic.Identify, true) } // Register the accessory if it hasn't been hidden by the user if (!hidden) { this.api.registerPlatformAccessories(plugin.name, plugin.alias, [accessory]) this.log('[%s] %s.', device.devName, this.lang.devAdd) } // Configure for good practice this.configureAccessory(accessory) // Return the new accessory return accessory } catch (err) { // Catch any errors during add const eText = this.funcs.parseError(err) this.log.warn('[%s] %s %s.', device.devName, this.lang.devNotAdd, eText) return false } } configureAccessory (accessory) { // Function is called to retrieve each accessory from the cache on startup try { if (!this.log) { return } // A function for when the identify button is pressed in HomeKit apps accessory.on('identify', (paired, callback) => { callback() this.log('[%s] %s.', accessory.displayName, this.lang.identify) }) // Set the correct firmware version if we can if (this.api && accessory.context.firmware) { accessory .getService(this.api.hap.Service.AccessoryInformation) .updateCharacteristic( this.api.hap.Characteristic.FirmwareRevision, accessory.context.firmware ) } // Add the configured accessory to our global map devicesInHB.set(accessory.UUID, accessory) } catch (err) { // Catch any errors during retrieve const eText = this.funcs.parseError(err) this.log.warn('[%s] %s %s.', accessory.displayName, this.lang.devNotConf, eText) } } updateAccessory (accessory) { this.api.updatePlatformAccessories(plugin.name, plugin.alias, [accessory]) } removeAccessory (accessory) { try { // Remove an accessory from Homebridge this.api.unregisterPlatformAccessories(plugin.name, plugin.alias, [accessory]) devicesInHB.delete(accessory.UUID) this.log('[%s] %s.', accessory.displayName, this.lang.devRemove) } catch (err) { // Catch any errors during remove const eText = this.funcs.parseError(err) this.log.warn('[%s] %s %s.', accessory.displayName, this.lang.devNotRemove, eText) } } async sendUpdate (accessory, toSend) { // Variable res is the response from either the cloud mqtt update or local http request let res // Generate the method variable determined from an empty payload or not toSend.method = Object.keys(toSend.payload).length === 0 ? 'GET' : 'SET' // Switch between either if the device is cloud control or local control switch (accessory.context.connection) { case 'cloud': { res = await accessory.mqtt.sendUpdate(toSend) break } case 'local': { // Check we have the user key if (!this.accountDetails.key) { throw new Error(this.lang.noUserKey) } // Generate the timestamp, messageId and sign from the userkey const timestamp = Math.floor(Date.now() / 1000) const messageId = this.funcs.generateRandomString(16) const sign = crypto .createHash('md5') .update(messageId + this.accountDetails.key + timestamp) .digest('hex') // Generate the payload to send const data = { header: { from: 'http://' + accessory.context.deviceUrl + '/config', messageId, method: toSend.method, namespace: toSend.namespace, payloadVersion: 1, sign, timestamp, triggerSrc: 'iOSLocal', uuid: accessory.context.serialNumber }, payload: toSend.payload || {} } // Log the update if user enabled if (accessory.context.enableDebugLogging) { this.log( '[%s] %s: %s.', accessory.displayName, this.lang.sendUpdate, JSON.stringify(data) ) } // Send the request to the device res = await axios({ url: 'http://' + accessory.context.deviceUrl + '/config', method: 'post', headers: { 'content-type': 'application/json' }, data, responseType: 'json', timeout: toSend.method === 'GET' ? 4000 : 9000 }) // Check the response properties based on whether it is a control or request update switch (toSend.method) { case 'GET': { // Validate the response, checking for payload property if (!res.data || !res.data.payload) { throw new Error('invalid response received') } break } case 'SET': { // Check the response if (!res.data || !res.data.header || res.data.header.method === 'ERROR') { throw new Error('request failed - ' + JSON.stringify(res.data.payload.error)) } break } } break } } // Return the response return res } } // Export the plugin to Homebridge module.exports = hb => hb.registerPlatform(plugin.alias, MerossPlatform)