UNPKG

homebridge-wemo

Version:

Homebridge plugin to integrate Wemo devices into HomeKit.

1,342 lines (1,239 loc) 53 kB
import { createServer } from 'node:http' import { createRequire } from 'node:module' import { networkInterfaces } from 'node:os' import process from 'node:process' import { URL as url } from 'node:url' import axios from 'axios' import ip from 'ip' import nodeSSDP from 'node-ssdp' import { parseStringPromise } from 'xml2js' import { create as xmlCreate } from 'xmlbuilder' import httpClient from './connection/http.js' import upnpClient from './connection/upnp.js' import deviceTypes from './device/index.js' import eveService from './fakegato/fakegato-history.js' import platformConsts from './utils/constants.js' import eveChars from './utils/eve-chars.js' import { parseError, parseSerialNumber } from './utils/functions.js' import platformLang from './utils/lang-en.js' const { Client: ssdp } = nodeSSDP const require = createRequire(import.meta.url) const plugin = require('../package.json') const devicesInHB = new Map() let listenerServer let cacheSerialsToConnect = [] let existSerialsToConnect = [] let ssdpClient 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') // Configuration objects for accessories this.ignoredDevices = [] this.manualDevices = [] this.deviceConf = {} // Make sure user is running Homebridge v1.5 or above if (!api?.versionGreaterOrEqual('1.5.0')) { throw new Error(platformLang.hbVersionFail) } // Check the user has configured the plugin if (!config) { throw new Error(platformLang.notConfigured) } // 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.notConfigured]) 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 'disableDeviceLogging': case 'disableUPNP': case 'hideConnectionErrors': if (typeof val === 'string') { logQuotes(key) } this.config[key] = val === 'false' ? false : !!val break case 'discoveryInterval': case 'pollingInterval': case 'upnpInterval': { if (typeof val === 'string') { logQuotes(key) } const intVal = Number.parseInt(val, 10) if (Number.isNaN(intVal)) { logDefault(key, platformConsts.defaultValues[key]) this.config[key] = platformConsts.defaultValues[key] } else if (intVal < platformConsts.minValues[key]) { logIncrease(key, platformConsts.minValues[key]) this.config[key] = platformConsts.minValues[key] } else { this.config[key] = intVal } break } case 'makerTypes': case 'wemoInsights': case 'wemoLights': case 'wemoLinks': case 'wemoMotions': case 'wemoOthers': case 'wemoOutlets': if (Array.isArray(val) && val.length > 0) { val.forEach((x) => { if (!x.serialNumber) { logIgnoreItem(key) return } const id = parseSerialNumber(x.serialNumber) if (Object.keys(this.deviceConf).includes(id)) { logDuplicate(`${key}.${id}`) return } const entries = Object.entries(x) if (entries.length === 1) { logRemove(`${key}.${id}`) return } this.deviceConf[id] = {} cacheSerialsToConnect.push(id) entries.forEach((subEntry) => { const [k, v] = subEntry if (!platformConsts.allowed[key].includes(k)) { logRemove(`${key}.${id}.${k}`) return } switch (k) { case 'adaptiveLightingShift': case 'brightnessStep': case 'makerTimer': case 'noMotionTimer': case 'timeDiff': case 'transitionTime': case 'wattDiff': { 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 'label': case 'serialNumber': this.deviceConf[id][k] = v break case 'listenerType': case 'showAs': { const inSet = platformConsts.allowed[k].includes(v) if (typeof v !== 'string' || !inSet) { logIgnore(`${key}.${id}.${k}`) } this.deviceConf[id][k] = inSet ? v : platformConsts.defaultValues[k] break } case 'makerType': this.deviceConf[id].showAsGarage = x[k].toString() === 'garageDoor' break case 'manualIP': if (typeof v !== 'string' || v === '') { logIgnore(`${key}.${id}.${k}`) } else { const manualIp = v .toString() .toLowerCase() .replace(/[\s'"]+/g, '') this.manualDevices.push(manualIp) } break case 'enableColourControl': case 'outletInUseTrue': case 'reversePolarity': case 'showTodayTC': if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } this.deviceConf[id][k] = v === 'false' ? false : !!v break case 'ignoreDevice': if (typeof v === 'string') { logQuotes(`${key}.${id}.${k}`) } if (!!v && v !== 'false') { this.ignoredDevices.push(id) } break default: } }) }) } else { logIgnore(key) } break case 'mode': { const inSet = platformConsts.allowed[key].includes(val) if (typeof val !== 'string' || !inSet) { logIgnore(key) } this.config.mode = inSet ? val : 'auto' break } case 'name': case 'platform': break case 'removeByName': if (typeof val !== 'string' || val === '') { logIgnore(key) } this.config.removeByName = val break case 'wemoClient': if (typeof val === 'object' && Object.keys(val).length > 0) { Object.entries(val).forEach((subEntry) => { const [k1, v1] = subEntry switch (k1) { case 'callback_url': if (typeof v1 !== 'string' || v1 === '') { logIgnore(`${key}.${k1}`) } this.config.callbackOverride = v1.replace('http://', '').replace(/\/\s*$/, '') break case 'discover_opts': if (typeof v1 === 'object' && Object.keys(v1).length > 0) { Object.entries(v1).forEach((subSubEntry) => { const [k2, v2] = subSubEntry switch (k2) { case 'explicitSocketBind': if (typeof v2 === 'string') { logQuotes(`${key}.${k1}.${k2}`) } this.config[key][k1][k2] = v2 === 'false' ? false : !!v2 break case 'interfaces': if (typeof v2 !== 'string' || v2 === '') { logIgnore(`${key}.${k1}.${k2}`) } this.config[key][k1][k2] = v2.toString() break default: logRemove(`${key}.${k1}.${k2}`) break } }) } else { logIgnore(`${key}.${k1}`) } break case 'listen_interface': if (typeof v1 !== 'string' || v1 === '') { logIgnore(`${key}.${k1}`) } this.config[key][k1] = v1 break case 'port': { if (typeof val === 'string') { logQuotes(`${key}.${k1}`) } const intVal = Number.parseInt(v1, 10) if (Number.isNaN(intVal)) { logDefault(`${key}.${k1}`, platformConsts.defaultValues[k1]) this.config[key][k1] = platformConsts.defaultValues[k1] } else if (intVal < platformConsts.minValues[k1]) { logIncrease(`${key}.${k1}`, platformConsts.minValues[k1]) this.config[key][k1] = platformConsts.minValues[k1] } else { this.config[key][k1] = intVal } break } default: logRemove(`${key}.${k1}`) break } }) } else { logIgnore(key) } break default: logRemove(key) break } }) } 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 = () => {} } // Set up the discovery run counter this.discoveryRun = -1 // Require any libraries that the accessory instances use this.eveChar = new eveChars(this.api) this.eveService = eveService(this.api) // Set up the http client this.httpClient = new httpClient(this) // Configure each accessory restored from the cache devicesInHB.forEach((accessory) => { // If it's in the 'ignore list' or the removeByName option then remove if ( this.ignoredDevices.includes(accessory.context.serialNumber.toUpperCase()) || this.config.removeByName === accessory.displayName || (this.config.mode === 'semi' && !cacheSerialsToConnect.includes(accessory.context.serialNumber)) ) { this.removeAccessory(accessory) return } // Make the accessory show as 'No Response' until it has been discovered const { services } = accessory services.forEach((service) => { let charToError switch (service.constructor.name) { case 'AirPurifier': case 'HeaterCooler': case 'HumidifierDehumidifier': charToError = 'Active' break case 'GarageDoorOpener': charToError = 'TargetDoorState' break case 'Lightbulb': case 'Outlet': case 'Switch': charToError = 'On' break default: return } service .getCharacteristic(this.api.hap.Characteristic[charToError]) .onSet(() => { this.log.warn('[%s] %s.', accessory.displayName, platformLang.accNotReady) throw new this.api.hap.HapStatusError(-70402) }) .updateValue(new this.api.hap.HapStatusError(-70402)) }) // Update the context that the accessory can't be controlled until discovered accessory.context.initialised = false accessory.context.httpOnline = false accessory.context.upnpOnline = false // Add the accessory to cache accessories to connect to if it isn't already if (!cacheSerialsToConnect.includes(accessory.context.serialNumber)) { cacheSerialsToConnect.push() } // Update the changes to the accessory to the platform this.api.updatePlatformAccessories([accessory]) devicesInHB.set(accessory.UUID, accessory) }) // Set up the listener server for device notifications listenerServer = createServer((req, res) => { let body = '' const accessory = devicesInHB.get(req.url.substring(1)) if (req.method === 'NOTIFY' && accessory) { // A notification from a known device req.on('data', (chunk) => { body += chunk.toString() }) req.on('end', () => this.httpClient.receiveDeviceUpdate(accessory, body)) } res.writeHead(200) res.end() }) // Start listening on the above created server if (this.config.wemoClient.listen_interface) { // User has defined a specific network interface to listen on listenerServer.listen(this.config.wemoClient.port, this.getLocalInterfaceAddress(), (err) => { if (err) { this.log.warn('%s: %s.', platformLang.listenerError, err) } else if (this.config.debug) { // Log the port of the listener server in debug mode this.log('%s [%s].', platformLang.listenerPort, listenerServer.address().port) } }) } else { // User has not defined a specific network interface to listen on listenerServer.listen(this.config.wemoClient.port, (err) => { if (err) { this.log.warn('%s: %s', platformLang.listenerError, err) } else if (this.config.debug) { // Log the port of the listener server in debug mode this.log('%s [%s].', platformLang.listenerPort, listenerServer.address().port) } }) } // Set up the SSDP client if the user has not specified manual devices only if (this.config.mode !== 'manual') { if (this.isBeta) { this.config.wemoClient.discover_opts.customLogger = this.log } ssdpClient = new ssdp(this.config.wemoClient.discover_opts) } // Setup successful this.log('%s. %s', platformLang.complete, platformLang.welcome) // Perform the first discovery run and set up the interval for subsequent runs this.discoverDevices() this.refreshInterval = setInterval( () => this.discoverDevices(), this.config.discoveryInterval * 1000, ) } catch (err) { // Catch any errors during setup this.log.warn('***** %s. *****', platformLang.disabling) this.log.warn('***** %s. *****', parseError(err)) this.pluginShutdown() } } pluginShutdown() { try { // Stop the discovery interval if it's running if (this.refreshInterval) { clearInterval(this.refreshInterval) } // Shutdown the listener server if it's running if (listenerServer) { listenerServer.close(() => { if (this.config.debug) { this.log('%s.', platformLang.listenerClosed) } }) } // Stop the SSDP client if it's running if (ssdpClient) { ssdpClient.stop() if (this.config.debug) { this.log('%s.', platformLang.ssdpStopped) } } // Close accessory subscriptions devicesInHB.forEach((accessory) => { if (accessory.control?.pollingInterval) { clearInterval(accessory.control.pollingInterval) } if (accessory.client) { accessory.client.stopSubscriptions() } }) } catch (err) { // No need to show errors at this point } } async discoverDevices() { // Increment the discovery run count this.discoveryRun += 1 const accessoryArray = [...devicesInHB.values()] // Nothing to do if mode is semi or manual and no device is re/awaiting connection if ( this.config.mode !== 'auto' && existSerialsToConnect.length === 0 && cacheSerialsToConnect.length === 0 ) { return } // ********************* \\ // Auto Detected Devices \\ // ********************* \\ if ( this.config.mode === 'auto' || (this.config.mode === 'semi' && (cacheSerialsToConnect.length > 0 || existSerialsToConnect.length > 0)) ) { // Remove all previous listeners as we don't want duplications on each interval ssdpClient.removeAllListeners('response') // Set up the listener for a detected device ssdpClient.on('response', async (msg) => { // Don't continue if it's not a Wemo device (service type) if (msg.ST !== 'urn:Belkin:service:basicevent:1') { return } // Get some information from the USN and location for checks const urlParts = new url(msg.LOCATION) const usnParts = msg.USN.split('::') const deviceIP = urlParts.hostname const devicePort = urlParts.port const deviceUDN = usnParts[0] try { // Checks for if the device is manually configured or if the device is ignored if ( this.manualDevices.some(el => el.includes(deviceIP)) || this.ignoredDevices.some(el => deviceUDN.toUpperCase().includes(el)) ) { return } const deviceData = await this.getDeviceInfo(deviceIP, devicePort) // Don't continue if we haven't found the correct port if (!deviceData) { throw new Error(platformLang.noPort) } // Don't continue specifically if the device type is switch: https://bit.ly/hb-pywemo-link if (deviceData.deviceType === 'urn:Belkin:device:switch:1') { return } // Don't continue if the mode is semi, and it's not a configured device if (this.config.mode === 'semi' && !this.deviceConf[deviceData.serialNumber]) { return } // Find a matching Homebridge accessory const accessory = accessoryArray.find(el => el.context.udn === deviceUDN) // Check if the accessory exists if (accessory?.context?.initialised) { if ( !existSerialsToConnect.includes(accessory.context.serialNumber) || !accessory.client ) { // Accessory exists and client has reported no error, nothing to do return } // Accessory exists but client has failed so renew this.reinitialiseDevice(accessory, deviceData) } else { // Accessory does not exist in Homebridge await this.initialiseDevice(deviceData) } } catch (err) { // Show warnings on runs 0 (initial), 2, 5, 8, 11, ... just to limit logging to an extent if ( !this.config.hideConnectionErrors && (this.discoveryRun === 0 || this.discoveryRun % 3 === 2) ) { const eText = parseError(err, [platformLang.noPort]) this.log.warn('[%s] %s: %s.', deviceIP, platformLang.connError, eText) } } }) // Perform the scan try { await ssdpClient.search('urn:Belkin:service:basicevent:1') } catch (err) { const eText = err.message === 'No sockets available, cannot start.' ? platformLang.noSockets : parseError(err) this.log.warn('%s %s.', platformLang.ssdpFail, eText) } } // *************************** \\ // Manually Configured Devices \\ // *************************** \\ this.manualDevices.forEach(async (device) => { try { // Check to see if the entry is a full address or an IP let deviceIP let devicePort if (device.includes(':')) { // It's a full address so get some information from the address const urlParts = new url(device) deviceIP = urlParts.hostname devicePort = urlParts.port } else { // It's an IP so perform a port scan deviceIP = device devicePort = null } const deviceData = await this.getDeviceInfo(deviceIP, devicePort) // Don't continue if no port was found if (!deviceData) { throw new Error(platformLang.noPort) } // Don't continue if the device is on the 'ignore list' if (this.ignoredDevices.includes(deviceData.serialNumber.toUpperCase())) { return } // Find a matching Homebridge accessory const accessory = accessoryArray.find(el => el.context.udn === deviceData.UDN) // Check if the accessory exists if (accessory?.context?.initialised) { if ( !existSerialsToConnect.includes(accessory.context.serialNumber) || !accessory.client ) { // Accessory exists and client has reported no error, nothing to do return } // Accessory exists but client has failed so renew this.reinitialiseDevice(accessory, deviceData) } else { // Accessory does not exist in Homebridge await this.initialiseDevice(deviceData) } } catch (err) { // Show warnings on runs 0 (initial), 2, 5, 8, 11, ... just to limit logging to an extent if ( !this.config.hideConnectionErrors && (this.discoveryRun === 0 || this.discoveryRun % 3 === 2) ) { const eText = parseError(err, [platformLang.noPort]) this.log.warn('[%s] %s: %s.', device, platformLang.connError, eText) } } }) // ************************ \\ // Erroneous Device Logging \\ // ************************ \\ if (this.discoveryRun % 3 === 2) { // Add a small delay in case devices were discovered on this round setTimeout(() => { if (cacheSerialsToConnect.length > 0) { const names = accessoryArray.filter(el => cacheSerialsToConnect.includes(el.context.serialNumber)) if (names.length > 0 && !this.config.hideConnectionErrors) { this.log.warn( '%s: [%s].', platformLang.awaiting, names.map(el => el.displayName).join('], ['), ) } } }, 3000) } // Reset the discovery counter to 0 if (this.discoveryRun === 3) { this.discoveryRun = 0 } } async getDeviceInfo(thisIp, portToTryFirst) { // Try to find the correct port of a device by ip // Credit to @Zacknetic for this function const tryPort = async (port, ipAddress) => { try { // Send a request to the device URL to get the XML information const res = await axios.get(`http://${ipAddress}:${port}/setup.xml`, { timeout: 5000, }) // Parse the XML response from the device const json = await parseStringPromise(res.data, { explicitArray: false }) const { device } = json.root // Add extra properties to the device variable device.host = ipAddress device.port = port device.cbURL = this.config.callbackOverride || `${this.getLocalInterfaceAddress(ipAddress)}:${listenerServer.address().port}` // Return the XML2JS data return device } catch (err) { // Suppress any errors as we don't want to show them return false } } // Loop through the ports that Wemo devices generally use let portsToTry = platformConsts.portsToScan if (portToTryFirst) { portsToTry = portsToTry.filter(el => el !== portToTryFirst) portsToTry.unshift(portToTryFirst) } for (const port of portsToTry) { const portAttempt = await tryPort(port, thisIp) if (portAttempt) { // We found the correct port return portAttempt } } // None of the ports worked return false } 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 { let accessory // Generate the uuid for this device from the device UDN const uuid = this.api.hap.uuid.generate(device.UDN) // Remove the device from the pending connection list cacheSerialsToConnect = cacheSerialsToConnect.filter(el => el !== device.serialNumber) // Obtain any user configured entry for this device const deviceConf = this.deviceConf[device.serialNumber] || {} // Save context information for the plugin to use const listenerType = deviceConf.listenerType === 'http' ? 'http' : 'upnp' const disableUPNP = this.config.disableUPNP ? 'http' : 'upnp' const context = { connection: deviceConf.listenerType ? listenerType : disableUPNP, cbURL: device.cbURL, firmware: device.firmwareVersion, hidden: false, icon: device.iconList?.icon?.url || false, ipAddress: device.host, macAddress: device.macAddress ? device.macAddress.replace(/..\B/g, '$&:') : false, port: device.port, serialNumber: device.serialNumber, udn: device.UDN, } // Create a map of device services const services = {} if (device.serviceList) { if (!Array.isArray(device.serviceList.service)) { device.serviceList.service = [device.serviceList.service] } // Put all the useful service info into the services object device.serviceList.service.forEach((service) => { services[service.serviceType] = { serviceId: service.serviceId, controlURL: service.controlURL, eventSubURL: service.eventSubURL, } }) } else { // Device has no services so is useless to Homebridge if (devicesInHB.has(uuid)) { this.removeAccessory(devicesInHB.get(uuid)) } throw new Error(platformLang.noServices) } // Update the context with the service object context.serviceList = { ...services } // Get the correct device type instance switch (device.deviceType) { case 'urn:Belkin:device:bridge:1': { /** *************** WEMO LINKS * BULBS **************** */ if (!context.serviceList['urn:Belkin:service:bridge:1']) { throw new Error(platformLang.noService) } // Set up the main 'hidden' accessory for the Link accessory = this.addAccessory(device, true, true) this.applyAccessoryLogging(accessory) accessory.context = { ...accessory.context, ...context, hidden: true } if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceLinkHub(this, accessory, devicesInHB) // Request a list of subdevices from the Wemo Link const xml = xmlCreate('s:Envelope', { version: '1.0', encoding: 'utf-8', allowEmpty: true, }) .att('xmlns:s', 'http://schemas.xmlsoap.org/soap/envelope/') .att('s:encodingStyle', 'http://schemas.xmlsoap.org/soap/encoding/') .ele('s:Body') .ele('u:GetEndDevices') .att('xmlns:u', 'urn:Belkin:service:bridge:1') // Send the request to the device const res = await axios({ url: `http://${device.host}:${device.port}/upnp/control/bridge1`, method: 'post', headers: { 'SOAPACTION': '"urn:Belkin:service:bridge:1#GetEndDevices"', 'Content-Type': 'text/xml; charset="utf-8"', }, data: xml.ele({ DevUDN: device.UDN, ReqListType: 'PAIRED_LIST' }).end(), timeout: 10000, }) // Parse the response from the device const xmlRes = res.data const response = await parseStringPromise(xmlRes, { explicitArray: false }) // Get the data we need the parsed response const data = response['s:Envelope']['s:Body']['u:GetEndDevicesResponse'] // Parse the XML response from the Wemo Link const result = await parseStringPromise(data.DeviceLists) // A function used later for parsing the device information const parseDeviceInfo = (thisData) => { const thisDevice = {} if (thisData.GroupID) { // Treat device group as if it were a single device thisDevice.friendlyName = thisData.GroupName[0] thisDevice.deviceId = thisData.GroupID[0] const values = thisData.GroupCapabilityValues[0].split(',') thisDevice.capabilities = {} thisData.GroupCapabilityIDs[0].split(',').forEach((val, index) => { thisDevice.capabilities[val] = values[index] }) } else { // Single device thisDevice.friendlyName = thisData.FriendlyName[0] thisDevice.deviceId = thisData.DeviceID[0] const values = thisData.CurrentState[0].split(',') thisDevice.capabilities = {} thisData.CapabilityIDs[0].split(',').forEach((val, index) => { thisDevice.capabilities[val] = values[index] }) } return thisDevice } // Create an array of subdevices we can use const subdevices = [] const deviceInfos = result.DeviceLists.DeviceList[0].DeviceInfos[0].DeviceInfo if (deviceInfos) { Array.prototype.push.apply(subdevices, deviceInfos.map(parseDeviceInfo)) } if (result.DeviceLists.DeviceList[0].GroupInfos) { const groupInfos = result.DeviceLists.DeviceList[0].GroupInfos[0].GroupInfo Array.prototype.push.apply(subdevices, groupInfos.map(parseDeviceInfo)) } // Loop through the subdevices initialising each one subdevices.forEach((subdevice) => { try { // Don't continue if the device is on the 'ignore list' if (this.ignoredDevices.includes(subdevice.deviceId.toUpperCase())) { return } // Give the subdevice some extra context (primary, secondary serial numbers) const extraContext = { capabilities: subdevice.capabilities, linkSerialNumber: device.serialNumber, serialNumber: subdevice.deviceId, } // Generate the uuid for this subdevice from the subdevice id const uuidSub = this.api.hap.uuid.generate(subdevice.deviceId) // Get the cached accessory or add to Homebridge if it doesn't exist const subAcc = devicesInHB.get(uuidSub) || this.addAccessory(subdevice) subAcc.context = { ...subAcc.context, ...context, ...extraContext } this.applyAccessoryLogging(subAcc) subAcc.control = new deviceTypes.deviceLinkBulb(this, accessory, subAcc) // Log the successfully initialised device this.log( '[%s] %s %s %s %s:%s.', subAcc.displayName, platformLang.initSer, subdevice.deviceId, platformLang.initMac, subAcc.context.ipAddress, subAcc.context.port, ) // Mark the device as initialised and the http status as online subAcc.context.initialised = true subAcc.context.httpOnline = true if (context.connection === 'upnp') { subAcc.context.upnpOnline = true } // Update any changes to the accessory to the platform this.api.updatePlatformAccessories([subAcc]) devicesInHB.set(uuidSub, subAcc) } catch (err) { // Catch any errors during the process const eText = parseError(err) this.log.warn('[%s] %s %s.', subdevice.friendlyName, platformLang.devNotInit, eText) } }) break /** */ } case 'urn:Belkin:device:insight:1': { /** ********** WEMO INSIGHTS *********** */ // Retrieve or add accessory, and add client and control properties accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } const showAs = deviceConf.showAs || platformConsts.defaultValues.showAs switch (showAs) { case 'purifier': accessory.control = new deviceTypes.deviceSimPurifierInsight(this, accessory) break case 'switch': accessory.control = new deviceTypes.deviceSimSwitchInsight(this, accessory) break default: accessory.control = new deviceTypes.deviceInsight(this, accessory) break } break /** */ } case 'urn:Belkin:device:dimmer:1': { /** ********* WEMO DIMMERS ********** */ accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceDimmer(this, accessory) break /** */ } case 'urn:Belkin:device:lightswitch:1': { /** **************** WEMO LIGHT SWITCHES ***************** */ accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceLightSwitch(this, accessory) break /** */ } case 'urn:Belkin:device:Maker:1': { /** ******** WEMO MAKERS ********* */ // Retrieve or add accessory, and add client and control properties accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } if (deviceConf.showAsGarage) { accessory.control = new deviceTypes.deviceMakerGarage(this, accessory) } else { accessory.control = new deviceTypes.deviceMakerSwitch(this, accessory) } break /** */ } case 'urn:Belkin:device:sensor:1': case 'urn:Belkin:device:NetCamSensor:1': { /** ********* WEMO MOTIONS ********** */ accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceMotion(this, accessory) break /** */ } case 'urn:Belkin:device:controllee:1': case 'urn:Belkin:device:outdoor:1': { /** ********** WEMO SWITCHES *********** */ // Retrieve or add accessory, and add client and control properties accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } const showAs = deviceConf.showAs || platformConsts.defaultValues.showAs switch (showAs) { case 'purifier': accessory.control = new deviceTypes.deviceSimPurifier(this, accessory) break case 'switch': accessory.control = new deviceTypes.deviceSimSwitch(this, accessory) break default: accessory.control = new deviceTypes.deviceOutlet(this, accessory) break } break /** */ } case 'urn:Belkin:device:HeaterA:1': case 'urn:Belkin:device:HeaterB:1': { /** ********* WEMO HEATERS ********** */ accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceHeater(this, accessory) break /** */ } case 'urn:Belkin:device:Humidifier:1': case 'urn:Belkin:device:HumidifierB:1': { /** ************* WEMO HUMIDIFIERS ************** */ accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceHumidifier(this, accessory) break /** */ } case 'urn:Belkin:device:AirPurifier:1': { /** *********** WEMO PURIFIERS ************ */ if (deviceConf.label) { device.friendlyName = deviceConf.label } accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.devicePurifier(this, accessory) break /** */ } case 'urn:Belkin:device:crockpot:1': { /** *********** WEMO CROCKPOTS ************ */ accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) // Wemo Crockpot does not support UPnP so override the connection to http accessory.context = { ...accessory.context, ...context, connection: 'http' } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceCrockpot(this, accessory) break /** */ } case 'urn:Belkin:device:CoffeeMaker:1': { /** *************** WEMO COFFEE MAKERS **************** */ accessory = devicesInHB.get(uuid) || this.addAccessory(device, true) accessory.context = { ...accessory.context, ...context } this.applyAccessoryLogging(accessory) if (Object.keys(context.serviceList).length > 0 && context.connection === 'upnp') { accessory.client = new upnpClient(this, accessory) } accessory.control = new deviceTypes.deviceCoffee(this, accessory) break /** */ } default: { /** ************** UNSUPPORTED AS YET *************** */ this.log.warn( '[%s] [%s] %s.', device.friendlyName, device.deviceType, platformLang.unsupported, ) // Add device to ignore list to stop repeated notifications this.ignoredDevices.push(device.serialNumber.toUpperCase()) return /** */ } } // Log the successfully initialised device this.log( '[%s] %s %s %s %s:%s', accessory.displayName, platformLang.initSer, device.serialNumber, platformLang.initMac, accessory.context.ipAddress, accessory.context.port, ) // Mark the device as initialised and the http status as online accessory.context.initialised = true accessory.context.httpOnline = true accessory.log(platformLang.httpGood) // If upnp is enabled then start the subscriptions as mark the upnp status as online if (accessory.client) { accessory.client.startSubscriptions() accessory.context.upnpOnline = true accessory.log(platformLang.upnpGood) } // Update any changes to the accessory to the platform this.api.updatePlatformAccessories([accessory]) devicesInHB.set(uuid, accessory) } catch (err) { // Catch any errors during the process this.log.warn('[%s] %s %s.', device.friendlyName, platformLang.devNotInit, parseError(err)) } } addAccessory(device, isPri, hidden = false) { const accName = device.friendlyName || device.deviceId || device.serialNumber try { // Add an accessory to Homebridge const newUUID = this.api.hap.uuid.generate(isPri ? device.UDN : device.deviceId) const accessory = new this.api.platformAccessory(accName, newUUID) // 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, accName) .setCharacteristic(this.api.hap.Characteristic.ConfiguredName, accName) .setCharacteristic(this.api.hap.Characteristic.Manufacturer, platformLang.brand) .setCharacteristic( this.api.hap.Characteristic.Model, isPri ? device.modelName : platformLang.modelLED, ) .setCharacteristic( this.api.hap.Characteristic.SerialNumber, isPri ? device.serialNumber : device.deviceId, ) .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.', accName, platformLang.devAdd) } this.configureAccessory(accessory) return accessory } catch (err) { // Catch any errors during add this.log.warn('[%s] %s %s.', accName, platformLang.devNotAdd, parseError(err)) return false } } configureAccessory(accessory) { // Add the configured accessory to our global map devicesInHB.set(accessory.UUID, accessory) } removeAccessory(accessory) { try { // Remove an accessory from Homebridge if (!accessory.context.hidden) { this.api.unregisterPlatformAccessories(plugin.name, plugin.alias, [accessory]) } 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)) } } reinitialiseDevice(accessory, deviceData) { // Update the context with the new information accessory.context = { ...accessory.context, cbURL: deviceData.cbURL, controllable: true, ipAddress: deviceData.host, port: deviceData.port, } // Mark the http status as online accessory.context.httpOnline = true accessory.log(platformLang.httpGood) // If upnp is supported then restart subscriptions and mark the upnp status as online if (accessory.client) { accessory.client.startSubscriptions() accessory.context.upnpOnline = true accessory.log(platformLang.upnpGood) } // Remove the accessory from the pending connection list existSerialsToConnect = existSerialsToConnect.filter(el => el !== deviceData.serialNumber) this.api.updatePlatformAccessories([accessory]) devicesInHB.set(accessory.UUID, accessory) } disableUPNP(accessory, err) { // Log the error immediately if (accessory.context.enableLogging) { accessory.logWarn(`${platformLang.upnpFail} [${err.message}]`) } // Update the context now the device is uncontrollable ac