homebridge-meross
Version:
Homebridge plugin to integrate Meross devices into HomeKit.
1,258 lines (1,153 loc) • 47.8 kB
JavaScript
import { createHash } from 'node:crypto'
import { existsSync, mkdirSync } from 'node:fs'
import { createRequire } from 'node:module'
import { join } from 'node:path'
import process from 'node:process'
import axios from 'axios'
import storage from 'node-persist'
import httpClient from './connection/http.js'
import deviceTypes from './device/index.js'
import eveService from './fakegato/fakegato-history.js'
import platformConsts from './utils/constants.js'
import platformChars from './utils/custom-chars.js'
import eveChars from './utils/eve-chars.js'
import { generateRandomString, hasProperty, parseError } from './utils/functions.js'
import platformLang from './utils/lang-en.js'
const require = createRequire(import.meta.url)
const plugin = require('../package.json')
export default class {
constructor(log, config, api) {
if (!log || !api) {
return
}
// Begin plugin initialisation
try {
this.api = api
this.log = log
this.isBeta = plugin.version.includes('beta')
this.cloudClient = false
this.deviceConf = {}
this.devicesInHB = new Map()
this.hideChannels = []
this.hideMasters = []
this.ignoredDevices = []
this.localUUIDs = []
// Make sure user is running Homebridge v1.4 or above
if (!api?.versionGreaterOrEqual('1.4.0')) {
throw new Error(platformLang.hbVersionFail)
}
// Check the user has configured the plugin
if (!config) {
throw new Error(platformLang.pluginNotConf)
}
// Log some environment info for debugging
this.log(
'%s v%s | System %s | Node %s | HB v%s | HAPNodeJS v%s...',
platformLang.initialising,
plugin.version,
process.platform,
process.version,
api.serverVersion,
api.hap.HAPLibraryVersion(),
)
// Apply the user's configuration
this.config = platformConsts.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 = parseError(err, [platformLang.hbVersionFail, platformLang.pluginNotConf])
log.warn('***** %s. *****', platformLang.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.', platformLang.cfgItem, k, platformLang.cfgDef, def)
}
const logDuplicate = (k) => {
this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgDup)
}
const logIgnore = (k) => {
this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgn)
}
const logIgnoreItem = (k) => {
this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgIgnItem)
}
const logIncrease = (k, min) => {
this.log.warn('%s [%s] %s %s.', platformLang.cfgItem, k, platformLang.cfgLow, min)
}
const logQuotes = (k) => {
this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgQts)
}
const logRemove = (k) => {
this.log.warn('%s [%s] %s.', platformLang.cfgItem, k, platformLang.cfgRmv)
}
// Begin applying the user's config
Object.entries(config).forEach((entry) => {
const [key, val] = entry
switch (key) {
case 'babyDevices':
case 'diffuserDevices':
case 'fanDevices':
case 'garageDevices':
case 'humidifierDevices':
case 'lightDevices':
case 'multiDevices':
case 'purifierDevices':
case 'rollerDevices':
case 'sensorDevices':
case 'singleDevices':
case 'thermostatDevices':
if (Array.isArray(val) && val.length > 0) {
val.forEach((x) => {
if (
!(
x.serialNumber
&& x.name
&& (
(config.connection !== 'local' && x.connection !== 'local')
|| (config.connection !== 'local' && x.connection === 'local' && x.deviceUrl)
|| (config.connection === 'local' && x.model && x.deviceUrl)
)
)
) {
logIgnoreItem(key)
return
}
const id = x.serialNumber.toLowerCase().replace(/[^a-z\d]+/g, '')
if (Object.keys(this.deviceConf).includes(id)) {
logDuplicate(`${key}.${id}`)
return
}
const entries = Object.entries(x)
if (entries.length < 3) {
logRemove(`${key}.${id}`)
return
}
this.deviceConf[id] = {}
entries.forEach((subEntry) => {
const [k, v] = subEntry
switch (k) {
case 'adaptiveLightingShift':
case 'brightnessStep':
case 'garageDoorOpeningTime':
case 'inUsePowerThreshold':
case 'lowBattThreshold': {
if (typeof v === 'string') {
logQuotes(`${key}.${id}.${k}`)
}
const intVal = Number.parseInt(v, 10)
if (Number.isNaN(intVal)) {
logDefault(`${key}.${id}.${k}`, platformConsts.defaultValues[k])
this.deviceConf[id][k] = platformConsts.defaultValues[k]
} else if (intVal < platformConsts.minValues[k]) {
logIncrease(`${key}.${id}.${k}`, platformConsts.minValues[k])
this.deviceConf[id][k] = platformConsts.minValues[k]
} else {
this.deviceConf[id][k] = intVal
}
break
}
case 'connection':
case 'showAs': {
const inSet = platformConsts.allowed[k].includes(v)
if (typeof v !== 'string' || !inSet) {
logIgnore(`${key}.${id}.${k}`)
} else {
this.deviceConf[id][k] = v === 'default' ? platformConsts.defaultValues[k] : v
}
break
}
case 'deviceUrl':
case 'firmwareRevision':
case 'ignoreSubdevices':
case 'model':
case 'name':
case 'serialNumber':
case 'temperatureSource':
case 'userkey':
if (typeof v !== 'string' || v === '') {
logIgnore(`${key}.${id}.${k}`)
} else {
this.deviceConf[id][k] = v.trim()
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(/\D+/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
default:
logRemove(`${key}.${id}.${k}`)
}
})
})
} else {
logIgnore(key)
}
break
case 'cloudRefreshRate':
case 'refreshRate': {
if (typeof val === 'string') {
logQuotes(key)
}
const intVal = Number.parseInt(val, 10)
if (Number.isNaN(intVal)) {
logDefault(key, platformConsts.defaultValues[key])
} else if (intVal !== 0 && intVal < platformConsts.minValues[key]) {
logIncrease(key, platformConsts.minValues[key])
} else if (intVal === 0 || intVal > 600) {
this.config[key] = 600
} else {
this.config[key] = intVal
}
break
}
case 'connection': {
const inSet = platformConsts.allowed[key].includes(val)
if (typeof val !== 'string' || !inSet) {
logIgnore(key)
} else {
this.config[key] = val === 'default' ? platformConsts.defaultValues[key] : val
}
break
}
case 'disableDeviceLogging':
case 'ignoreHKNative':
case 'ignoreMatter':
case 'showUserKey':
if (typeof val === 'string') {
logQuotes(key)
}
this.config[key] = val === 'false' ? false : !!val
break
case 'domain':
case 'mfaCode':
case 'password':
case 'username':
if (typeof val !== 'string') {
logIgnore(key)
} else {
this.config[key] = val
}
break
case 'name':
case 'platform':
break
case 'userkey':
if (typeof val !== 'string') {
logIgnore(key)
} else {
const userkey = val.toLowerCase().replace(/[^a-z\d]+/g, '')
if (userkey.length === 32) {
this.config[key] = userkey
} else {
logIgnore(key)
}
}
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.', platformLang.initialised)
// Sort out some logging functions
if (this.isBeta) {
this.log.debug = this.log
this.log.debugWarn = this.log.warn
// Log that using a beta will generate a lot of debug logs
if (this.isBeta) {
const divide = '*'.repeat(platformLang.beta.length + 1) // don't forget the full stop (+1!)
this.log.warn(divide)
this.log.warn(`${platformLang.beta}.`)
this.log.warn(divide)
}
} else {
this.log.debug = () => {}
this.log.debugWarn = () => {}
}
// Require any libraries that the accessory instances use
this.cusChar = new platformChars(this.api)
this.eveChar = new eveChars(this.api)
this.eveService = eveService(this.api)
const cachePath = join(this.api.user.storagePath(), '/bwp91_cache')
// Create folders if they don't exist
if (!existsSync(cachePath)) {
mkdirSync(cachePath)
}
// Persist files are used to store device info that can be used by my other plugins
try {
this.storageData = storage.create({
dir: cachePath,
forgiveParseErrors: true,
})
await this.storageData.init()
this.storageClientData = true
} catch (err) {
this.log.debugWarn('%s %s.', platformLang.storageSetupErr, parseError(err))
}
// If the user has configured cloud username and password then get a device list
this.accountDetails = {}
let cloudDevices = []
try {
if (!this.config.username || !this.config.password) {
throw new Error(platformLang.missingCreds)
}
// Try and get token from the cache to get a device list
try {
const storedData = await this.storageData.getItem('Meross_All_Devices_temp')
const splitData = storedData?.split(':::')
if (!Array.isArray(splitData) || splitData.length !== 5) {
throw new Error(platformLang.accTokenNoExist)
}
if (splitData[0] !== this.config.username) {
// Username has changed so throw error to generate new token
throw new Error(platformLang.accTokenUserChange)
}
this.accountDetails.key = splitData[1]
this.accountDetails.token = splitData[2]
this.accountDetails.userId = splitData[3]
this.accountDetails.domain = splitData[4]
this.log.debug('[HTTP] %s.', platformLang.accTokenFromCache)
this.cloudClient = new httpClient(this)
cloudDevices = await this.cloudClient.getDevices()
} catch (err) {
this.log.warn('[HTTP] %s %s.', platformLang.accTokenFail, parseError(err, [
platformLang.accTokenUserChange,
platformLang.accTokenNoExist,
platformLang.accTokenInvalid,
]))
// Remove existing cache info if it exists
await this.storageData.removeItem('Meross_All_Devices_temp')
this.cloudClient = new httpClient(this)
this.accountDetails = await this.cloudClient.login()
cloudDevices = await this.cloudClient.getDevices()
}
// Initialise the cloud configured devices into Homebridge
cloudDevices.forEach(device => this.initialiseDevice(device))
} catch (err) {
const eText = parseError(err, [platformLang.mfaFail, platformLang.missingCreds, platformLang.accTokenInvalid])
this.log.warn('%s %s.', platformLang.disablingCloud, eText)
this.cloudClient = false
this.accountDetails = {
key: this.config.userkey,
}
}
// Check if a user key has been configured if the credentials aren't present
if (!this.cloudClient) {
if (this.config.userkey) {
// Initialise the local configured devices into Homebridge
Object.values(this.deviceConf)
.filter(el => el.deviceUrl)
.forEach((device) => {
// Ensure we have a model property if a user key is configured, and credentials are not
if (!this.config.username && this.config.userkey && !device.model) {
this.log.warn('[%s] %s.', device.name, platformLang.missingModal)
return
}
// Rename some properties to fit the format of a cloud device
// Local devices don't have the uuid already set
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 garageCount = device.deviceType === 'MSG200' ? 3 : 1
const channelCount = platformConsts.models.switchMulti[device.deviceType] || garageCount
// Create a list of channels to fit the format of a cloud device
if (channelCount > 1) {
for (let index = 0; index <= channelCount; index += 1) {
device.channels.push({})
}
}
this.initialiseDevice(device)
})
} else {
// Cloud client disabled and no user key - plugin will be useless
throw new Error(platformLang.noCredentials)
}
}
// Check for redundant accessories or those that have been ignored but exist
this.devicesInHB.forEach((accessory) => {
switch (accessory.context.connection) {
case 'cloud':
case 'hybrid':
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 never happen
this.removeAccessory(accessory)
break
}
})
// Setup successful
this.log('%s. %s', platformLang.complete, platformLang.welcome)
} catch (err) {
// Catch any errors during setup
const eText = parseError(err, [platformLang.noCredentials])
this.log.warn('***** %s. *****', platformLang.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 (this.cloudClient) {
this.devicesInHB.forEach((accessory) => {
if (accessory.mqtt) {
accessory.mqtt.disconnect()
}
if (accessory.refreshInterval) {
clearInterval(accessory.refreshInterval)
}
if (accessory.powerInterval) {
clearInterval(accessory.powerInterval)
}
})
}
} catch (err) {
// No need to show errors at this point
}
}
applyAccessoryLogging(accessory) {
if (this.isBeta) {
accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
accessory.logDebug = msg => this.log('[%s] %s.', accessory.displayName, msg)
accessory.logDebugWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
} else {
if (this.config.disableDeviceLogging) {
accessory.log = () => {}
accessory.logWarn = () => {}
} else {
accessory.log = msg => this.log('[%s] %s.', accessory.displayName, msg)
accessory.logWarn = msg => this.log.warn('[%s] %s.', accessory.displayName, msg)
}
accessory.logDebug = () => {}
accessory.logDebugWarn = () => {}
}
}
async initialiseDevice(device) {
try {
// Get any user configured entry for this device
const deviceConf = this.deviceConf[device.uuid.toLowerCase()] || {}
// 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, '')
// Add context information for the plugin-ui and instance to use
const context = {
channel: 0,
channelCount: device.channels.length,
connection: deviceConf.deviceUrl
? 'local'
: deviceConf.connection || this.config.connection,
deviceUrl: deviceConf.deviceUrl,
domain: device.domain,
firmware: device.firmware,
hidden: false,
isOnline: false,
model: device.model,
options: deviceConf,
serialNumber: device.uuid,
userkey: deviceConf.userkey || this.accountDetails.key,
}
// Find the correct instance determined by the device model
let accessory
if (platformConsts.models.switchSingle.includes(device.model)) {
/**
**************
SWITCHES (SINGLE)
***************
*/
// Set up the accessory and instance
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
switch (deviceConf.showAs) {
case 'cooler':
accessory.control = new deviceTypes.deviceCoolerSingle(this, accessory)
break
case 'heater':
accessory.control = new deviceTypes.deviceHeaterSingle(this, accessory)
break
case 'outlet':
accessory.control = new deviceTypes.deviceOutletSingle(this, accessory)
break
case 'purifier':
accessory.control = new deviceTypes.devicePurifierSingle(this, accessory)
break
default:
accessory.control = new deviceTypes.deviceSwitchSingle(this, accessory)
}
/** */
} else if (hasProperty(platformConsts.models.switchMulti, device.model)) {
/**
*************
SWITCHES (MULTI)
**************
*/
// If the user has enabled the option to configure multi-outlet devices as power strips
if (deviceConf.showAs === 'power-strip') {
// Set up the main power strip accessory
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context, channels: device.channels }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.devicePowerStrip(this, accessory)
// Check to see if there are any leftover accessory instances from before enabling this option
for (let i = 0; i <= 7; i += 1) {
const uuidSub = device.uuid + i
const hbUUIDSub = this.api.hap.uuid.generate(uuidSub)
if (this.devicesInHB.has(hbUUIDSub)) {
this.removeAccessory(this.devicesInHB.get(hbUUIDSub))
}
}
} else {
// Check to see if we need to remove any leftover power strip accessory instances
if (this.devicesInHB.has(hbUUID)) {
this.removeAccessory(this.devicesInHB.get(hbUUID))
}
// Loop through the channels
device.channels.forEach((channel, index) => {
const subdeviceObj = { ...device }
const extraContext = {}
// Generate the Homebridge UUID from the device uuid and channel index
const uuidSub = device.uuid + index
subdeviceObj.hbDeviceId = uuidSub
const hbUUIDSub = this.api.hap.uuid.generate(uuidSub)
// Supply a device name for the channel accessories
if (index > 0) {
subdeviceObj.devName = channel.devName || `${device.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 (this.devicesInHB.has(hbUUIDSub)) {
this.removeAccessory(this.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
subAcc = this.addAccessory(subdeviceObj, true)
} else {
return
}
} else {
// The user has not hidden this channel
subAcc = this.devicesInHB.get(hbUUIDSub) || this.addAccessory(subdeviceObj)
}
// Add the context information to the accessory
extraContext.channel = index
subAcc.context = { ...subAcc.context, ...context, ...extraContext }
this.applyAccessoryLogging(subAcc)
// Create the device type instance for this accessory
switch (deviceConf.showAs) {
case 'outlet':
subAcc.control = new deviceTypes.deviceOutletMulti(this, subAcc)
break
default:
subAcc.control = new deviceTypes.deviceSwitchMulti(this, subAcc)
break
}
// 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([subAcc])
this.devicesInHB.set(subAcc.UUID, subAcc)
}
})
}
/** */
} else if (platformConsts.models.lightDimmer.includes(device.model)) {
/**
************
LIGHTS (DIMMER)
*************
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceLightDimmer(this, accessory)
/** */
} else if (platformConsts.models.lightRGB.includes(device.model)) {
/**
*********
LIGHTS (RGB)
**********
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceLightRGB(this, accessory)
/** */
} else if (platformConsts.models.lightCCT.includes(device.model)) {
/**
*********
LIGHTS (CCT)
**********
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceLightCCT(this, accessory)
/** */
} else if (platformConsts.models.garage.includes(device.model)) {
/**
*********
GARAGE DOORS
**********
*/
if (device.model === 'MSG200') {
// If a main accessory exists from before then remove it so re-added as hidden
if (this.devicesInHB.has(hbUUID)) {
this.removeAccessory(this.devicesInHB.get(hbUUID))
}
// First, set up the main, hidden, accessory that will process the control and updates
accessory = this.addAccessory(device, true)
accessory.context = { ...accessory.context, ...context, hidden: true }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceGarageMain(this, accessory)
// Loop through the channels
device.channels.forEach((channel, index) => {
// Skip the channel 0 entry
if (index === 0) {
return
}
const subdeviceObj = { ...device }
const extraContext = {}
// Generate the Homebridge UUID from the device uuid and channel index
const uuidSub = device.uuid + index
subdeviceObj.hbDeviceId = uuidSub
const hbUUIDSub = this.api.hap.uuid.generate(uuidSub)
// Supply a device name for the channel accessories
if (index > 0) {
device.devName = channel.devName || `${device.devName} SW${index}`
}
// Check if the user has chosen to hide any channels for this device
if (this.hideChannels.includes(device.uuid + index)) {
// The user has hidden this channel so if it exists then remove it
if (this.devicesInHB.has(hbUUIDSub)) {
this.removeAccessory(this.devicesInHB.get(hbUUIDSub))
}
return
}
// The user has not hidden this channel
const subAcc = this.devicesInHB.get(hbUUIDSub) || this.addAccessory(subdeviceObj)
// Add the context information to the accessory
extraContext.channel = index
subAcc.context = { ...subAcc.context, ...context, ...extraContext }
this.applyAccessoryLogging(subAcc)
// Create the device type instance for this accessory
subAcc.control = new deviceTypes.deviceGarageSub(this, subAcc, accessory)
// Update any changes to the accessory to the platform
this.api.updatePlatformAccessories([subAcc])
this.devicesInHB.set(subAcc.UUID, subAcc)
})
} else {
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceGarageSingle(this, accessory)
}
/** */
} else if (platformConsts.models.roller.includes(device.model)) {
/**
***********
ROLLING MOTORS
************
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = ['6.0.0', '7.0.0', '8.0.0'].includes(device.hdwareVersion)
? new deviceTypes.deviceRollerLocation(this, accessory)
: new deviceTypes.deviceRoller(this, accessory)
/** */
} else if (platformConsts.models.purifier.includes(device.model)) {
/**
******
PURIFIERS
*******
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.devicePurifier(this, accessory)
/** */
} else if (platformConsts.models.fan.includes(device.model)) {
/**
FANS
*
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceFan(this, accessory)
/** */
} else if (platformConsts.models.diffuser.includes(device.model)) {
/**
******
DIFFUSERS
*******
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceDiffuser(this, accessory)
/** */
} else if (platformConsts.models.humidifier.includes(device.model)) {
/**
********
HUMIDIFIERS
*********
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceHumidifier(this, accessory)
/** */
} else if (platformConsts.models.baby.includes(device.model)) {
/**
**********
BABY MONITORS
***********
*/
accessory = this.addExternalAccessory(device, 26)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
// Create a second accessory for the baby light
const deviceLightHBID = `${device.uuid}_light`
const deviceLightHBUUID = this.api.hap.uuid.generate(deviceLightHBID)
const deviceLight = {
...device,
hbDeviceId: deviceLightHBID,
}
const accessoryLight = this.devicesInHB.get(deviceLightHBUUID) || this.addAccessory(deviceLight)
accessoryLight.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessoryLight)
// Update any changes to the accessory to the platform
this.api.updatePlatformAccessories([accessoryLight])
this.devicesInHB.set(accessoryLight.UUID, accessoryLight)
// Set up the main accessory for the baby monitor
accessory.control = new deviceTypes.deviceBaby(this, accessory, accessoryLight)
/** */
} else if (platformConsts.models.thermostat.includes(device.model)) {
/**
********
THERMOSTATS
*********
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceThermostat(this, accessory)
/** */
} else if (platformConsts.models.hubMain.includes(device.model)) {
/**
********
SENSOR HUBS
*********
*/
// At the moment, cloud connection is necessary to get a subdevice list
if (!this.cloudClient) {
throw new Error(platformLang.sensorNoCloud)
}
// Obtain array of any subdevices to ignore
const subdevicesToIgnore = []
if (context.options.ignoreSubdevices) {
context.options.ignoreSubdevices
.split(',')
.forEach(subdeviceId => subdevicesToIgnore.push(subdeviceId.trim()))
}
context.ignoreSubdevices = subdevicesToIgnore
// First, set up the main, hidden, accessory that will process the incoming updates
accessory = this.addAccessory(device, true)
accessory.context = { ...accessory.context, ...context, hidden: true }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceHubMain(this, accessory)
// Then request and initialise a list of subdevices
const subdevices = await this.cloudClient.getSubDevices(device)
if (!Array.isArray(subdevices)) {
throw new TypeError(platformLang.sensorNoSubs)
}
// Initialise subdevices into HB
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)
// Check if it's ignored device
if (subdevicesToIgnore.includes(subdevice.subDeviceId)) {
// Is ignored, remove if exists
if (this.devicesInHB.has(hbUUIDSub)) {
this.removeAccessory(this.devicesInHB.get(hbUUIDSub))
}
return
}
// Not ignored, so continue initialising
subdeviceObj.devName = subdevice.subDeviceName || subdevice.subDeviceId
subdeviceObj.hbDeviceId = uuidSub
subdeviceObj.model = subdevice.subDeviceType.toUpperCase().replace(/-+/g, '')
// Check the subdevice model is supported
if (!platformConsts.models.hubSub.includes(subdeviceObj.model)) {
// Not supported, so show a log message with helpful info for a GitHub issue
this.log.warn(
'[%s] %s:\n%s',
subdeviceObj.devName,
platformLang.notSupp,
JSON.stringify(subdeviceObj),
)
return
}
// Obtain or add this subdevice to Homebridge
const subAcc = this.devicesInHB.get(hbUUIDSub) || this.addAccessory(subdeviceObj)
// Add helpful context info to the accessory object
subAcc.context = {
...subAcc.context,
...context,
subSerialNumber: subdevice.subDeviceId,
}
this.applyAccessoryLogging(subAcc)
// Create the device type instance for this accessory
switch (subdeviceObj.model) {
case 'GS559A':
subAcc.control = new deviceTypes.deviceHubSmoke(this, subAcc)
break
case 'MS100':
case 'MS100F':
subAcc.control = new deviceTypes.deviceHubSensor(this, subAcc)
break
case 'MS200':
subAcc.control = new deviceTypes.deviceHubContact(this, subAcc)
break
case 'MS400':
subAcc.control = new deviceTypes.deviceHubLeak(this, subAcc)
break
case 'MTS100V3':
case 'MTS150':
subAcc.control = new deviceTypes.deviceHubValve(this, subAcc, accessory)
break
default:
return
}
// Update any changes to the accessory to the platform
this.api.updatePlatformAccessories([subAcc])
this.devicesInHB.set(subAcc.UUID, subAcc)
// Log the subdevice id so a user can use it to ignore device if wanted
this.log(
'[%s] [%s] %s [%s].',
device.devName,
subdeviceObj.devName,
platformLang.devSubInit,
subdevice.subDeviceId,
)
} catch (err) {
this.log.warn('[%s] %s %s.', subdevice.subDeviceName, platformLang.devNotAdd, parseError(err))
}
})
/** */
} else if (platformConsts.models.sensorPresence.includes(device.model)) {
/**
*****************
SENSOR (PRESENCE)
*****************
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceSensorPresence(this, accessory)
/** */
} else if (platformConsts.models.template.includes(device.model)) {
/**
****************
WORK IN PROGRESS
****************
*/
accessory = this.devicesInHB.get(hbUUID) || this.addAccessory(device)
accessory.context = { ...accessory.context, ...context }
this.applyAccessoryLogging(accessory)
accessory.control = new deviceTypes.deviceTemplate(this, accessory)
/** */
} else {
/**
*************
UNSUPPORTED YET
*************
*/
this.log.warn('[%s] %s:\n%s', device.devName, platformLang.notSupp, JSON.stringify(device))
return
/** */
}
// Log the device initialisation
accessory.log(`${platformLang.devInit} [${device.uuid}]`)
// Extra debug logging when set, show the device JSON info
accessory.logDebug(`${platformLang.jsonInfo}: ${JSON.stringify(device)}`)
// Update any changes to the accessory to the platform
this.api.updatePlatformAccessories([accessory])
this.devicesInHB.set(accessory.UUID, accessory)
} catch (err) {
// Catch any errors during device initialisation
const eText = parseError(err, [
platformLang.accNotFound,
platformLang.sensorNoCloud,
platformLang.sensorNoSubs,
])
this.log.warn('[%s] %s %s.', device.devName, platformLang.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.Name, device.devName)
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName, device.devName)
.setCharacteristic(this.api.hap.Characteristic.SerialNumber, device.uuid)
.setCharacteristic(this.api.hap.Characteristic.Manufacturer, platformLang.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
this.api.registerPlatformAccessories(plugin.name, plugin.alias, [accessory])
this.log('[%s] %s.', device.devName, platformLang.devAdd)
}
// Configure for good practice
this.configureAccessory(accessory)
// Return the new accessory
return accessory
} catch (err) {
// Catch any errors during add
this.log.warn('[%s] %s %s.', device.devName, platformLang.devNotAdd, parseError(err))
return false
}
}
addExternalAccessory(device, category) {
try {
// Add the new accessory to Homebridge
const accessory = new this.api.platformAccessory(
device.devName,
this.api.hap.uuid.generate(device.hbDeviceId),
category,
)
// Set the accessory characteristics
accessory
.getService(this.api.hap.Service.AccessoryInformation)
.setCharacteristic(this.api.hap.Characteristic.Name, device.devName)
.setCharacteristic(this.api.hap.Characteristic.ConfiguredName, device.devName)
.setCharacteristic(this.api.hap.Characteristic.SerialNumber, device.uuid)
.setCharacteristic(this.api.hap.Characteristic.Manufacturer, platformLang.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
this.api.publishExternalAccessories(plugin.name, [accessory])
this.log('[%s] %s.', device.devName, platformLang.devAdd)
// Return the new accessory
this.configureAccessory(accessory)
return accessory
} catch (err) {
// Catch any errors during add
this.log.warn('[%s] %s %s.', device.name, platformLang.devNotAdd, parseError(err))
return false
}
}
configureAccessory(accessory) {
// 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
this.devicesInHB.set(accessory.UUID, accessory)
}
updateAccessory(accessory) {
this.api.updatePlatformAccessories([accessory])
if (accessory.context.isOnline) {
this.log('[%s] %s.', accessory.displayName, platformLang.repOnline)
} else {
this.log.warn('[%s] %s.', accessory.displayName, platformLang.repOffline)
}
}
removeAccessory(accessory) {
try {
// Remove an accessory from Homebridge
if (!accessory.context.hidden) {
this.api.unregisterPlatformAccessories(plugin.name, plugin.alias, [accessory])
}
this.devicesInHB.delete(accessory.UUID)
this.log('[%s] %s.', accessory.displayName, platformLang.devRemove)
} catch (err) {
// Catch any errors during remove
this.log.warn('[%s] %s %s.', accessory.displayName, platformLang.devNotRemove, parseError(err))
}
}
async sendUpdate(accessory, toSend, opts = {}) {
// 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 = toSend.method || (Object.keys(toSend.payload).length === 0 ? 'GET' : 'SET')
// Always try local control first, even for cloud devices
try {
// Check the user has this mode turned on
if (accessory.context.connection === 'cloud') {
throw new Error(platformLang.noHybridMode)
}
// Check we have the user key
if (!accessory.context.userkey) {
throw new Error(platformLang.noUserKey)
}
// Certain models aren't supported for local control
if (platformConsts.noLocalControl.includes(accessory.context.model)) {
throw new Error(platformLang.notSuppLocal)
}
// Obtain the IP address, either manually configured or from Meross polling data
const ipAddress = accessory.context.deviceUrl || accessory.context.ipAddress
// Check the IP address exists
if (!ipAddress) {
throw new Error(platformLang.noIP)
}
// Generate the timestamp, messageId and sign from the userkey
const timestamp = Math.floor(Date.now() / 1000)
const messageId = generateRandomString(32)
const sign = createHash('md5')
.update(messageId + accessory.context.userkey + timestamp)
.digest('hex')
// Generate the payload to send
const data = {
header: {
from: `http://${ipAddress}/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
accessory.logDebug(`${platformLang.sendUpdate}: ${JSON.stringify(data)}`)
// Send the request to the device
res = await axios({
url: `http://${ipAddress}/config`,
method: 'post',
headers: { 'content-type': 'application/json' },
data,
responseType: 'json',
timeout: toSend.method === 'GET' || accessory.context.connection === 'local' ? 9000 : 4000,
insecureHTTPParser: !!opts.insecureHTTPParser,
})
// Check the response properties based on whether it is a control or request update
switch (toSend.method) {
case 'SET': {
// Check the response
if (!res.data || !res.data.header || res.data.header.method === 'ERROR') {
throw new Error(`${platformLang.reqFail} - ${JSON.stringify(res.data.payload.error)}`)
}
break
}
default: { // GET
// Validate the response, checking for payload property
if (!res.data || !res.data.payload) {
throw new Error(platformLang.invalidResponse)
}
// Check we are sending the command to the correct device
if (
res.data.header.from
!== `/appliance/${accessory.context.serialNumber}/publish`
) {
throw new Error(platformLang.wrongDevice)
}
break
}
}
} catch (err) {
if (accessory.context.connection === 'local') {
// An error occurred and cloud mode is disabled so report the error back
throw err
} else {
// An error occurred, so we can try sending the request via the cloud
const eText = parseError(err, [
platformLang.noHybridMode,
platformLang.notSuppLocal,
platformLang.noUserKey,
platformLang.noIP,
platformLang.wrongDevice,
])
accessory.logDebug(`${platformLang.revertToCloud} ${eText}`)
// Send the update via cloud mqtt
res = await accessory.mqtt.sendUpdate(accessory, toSend)
}
}
// Return the response
return res
}
}