UNPKG

node-red-contrib-virtual-smart-home

Version:

A Node-RED node that represents a 'virtual device' which can be controlled via Alexa. Requires the virtual smart home skill to be enabled for your Amazon account.

930 lines (794 loc) 25.8 kB
const axios = require('axios') const Buffer = require('buffer').Buffer const debounce = require('debounce') const throttle = require('./throttle') const semver = require('semver') const MqttClient = require('./MqttClient') const MsgRateLimiter = require('./MsgRateLimiter') const VSH_VERSION = require('./version') const { buildNewStateForDirectiveRequest, buildPropertiesFromState, annotateChanges, } = require('./directives') function decodeBase64(str) { return Buffer.from(str, 'base64').toString('utf-8') } function timeout(ms) { return new Promise((resolve) => setTimeout(resolve, ms)) } module.exports = function (RED) { RED.httpAdmin.get( `/vsh-connection/:nodeId`, RED.auth.needsPermission('vsh-virtual-device.read'), (req, res) => { const connectionNode = RED.nodes.getNode(req.params.nodeId) res.json({ plan: connectionNode?.getPlan() ?? 'unknown' }) } ) class ConnectionNode { credentials = undefined config = undefined plan = 'unknown' logger = undefined rater = undefined mqttClient = undefined childNodes = {} isDisconnecting = false isSubscribed = false isInitializing = false isError = false errorCode = '' isKilled = false killedStatusText = 'KILLED' allowedDeviceCount = 200 userIdToken = '' stats = { lastStartup: new Date().getTime(), connectionCount: 0, inboundMsgCount: 0, outboundMsgCount: 0, } jobQueue = [] jobQueueExecutor = undefined constructor(config) { this.config = config RED.nodes.createNode(this, config) this.logger = this.config.debug ? (logMessage, variable = undefined, logLevel = 'log') => { //logLevel: log | warn | error | trace | debug if (variable) { logMessage = logMessage + ': ' + JSON.stringify(variable) } this[logLevel](logMessage) } : (_logMessage, _variable) => {} this.rater = new MsgRateLimiter(this.logger) this.jobQueueExecutor = setInterval(() => { this.jobQueue = this.jobQueue.filter((job) => job() == false) }, 1000) this.on('close', async (_removed, done) => { await this.rater.destroy() if (!this.credentials.thingId) { this.logger( 'no thingId present while closing vsh-connection', null, 'warn' ) return done() } clearInterval(this.jobQueueExecutor) try { await this.disconnect() } catch (e) { this.logger('disconnect() failed', e, 'error') } this.execCallbackForAll('onDisconnect') done() }) } isConnected() { return this.mqttClient && this.mqttClient.isConnected() } isReconnecting() { return this.mqttClient && this.mqttClient.isReconnecting() } setPlan(newPlan) { this.plan = newPlan } getPlan() { return this.plan } execOrQueueJob(job) { if (job() == false) { this.jobQueue.push(job) } } refreshChildrenNodeStatus(statusText = null) { let fill, text if (this.isError) { fill = 'red' text = `ERROR: ${this.errorCode}. ${ this.isReconnecting() ? 'Periodically retrying...' : '' }` } else if (this.isKilled) { fill = 'red' text = this.killedStatusText } else if (this.isInitializing) { fill = 'yellow' text = 'Initializing...' } else if (statusText === 'Reconnecting...') { fill = 'yellow' text = statusText } else if (this.isConnected()) { fill = 'green' text = 'Online' } else if (this.isReconnecting()) { fill = 'red' text = 'Disconnected. Periodically retrying...' } else { fill = 'red' text = 'Offline' } this.execCallbackForAllThrottled('setStatus', { shape: 'dot', fill, text, }) } getLogger() { return this.logger } registerChildNode(nodeId, callbacks) { if (Object.keys(this.childNodes).length >= this.allowedDeviceCount) { callbacks.setActive(false) callbacks.setStatus( { shape: 'dot', fill: 'gray', text: 'Device limit reached! Upgrade your VSH subscription to get more devices!', }, true //force! ) } else { callbacks.setActive(true) } this.childNodes[nodeId] = callbacks if (Object.keys(this.childNodes).length == 1) { //first child node is registering! this.connectAndSubscribe() } const requestConfigJob = () => { if (!this.isSubscribed) { return false } this.requestConfigDebounced() } this.execOrQueueJob(requestConfigJob) } async unregisterChildNode(nodeId) { delete this.childNodes[nodeId] if (Object.keys(this.childNodes).length == 0) { //last child node is unregistering! await this.disconnect() } } getLocalDevices() { const localDevices = {} for (const nodeId in this.childNodes) { localDevices[nodeId] = this.childNodes[nodeId].getDeviceConfig() } return localDevices } execCallbackForAll(eventName, eventDetails) { const result = {} for (const nodeId in this.childNodes) { if (this.childNodes[nodeId][eventName]) { result[nodeId] = this.childNodes[nodeId][eventName](eventDetails) } } return result } execCallbackForAllThrottled = throttle(this.execCallbackForAll, 1000) execCallbackForOne(nodeId, eventName, params, ...moreParams) { if (!this.childNodes[nodeId]) { this.logger( `execCallbackForOne() failed because node ${nodeId} has not been registered at this connection node!`, null, 'warn' ) return } if (this.childNodes[nodeId][eventName]) { return this.childNodes[nodeId][eventName](params, ...moreParams) } } requestConfig() { this.isInitializing = true this.refreshChildrenNodeStatus() this.publish(`vsh/${this.credentials.thingId}/requestConfig`, { vshVersion: VSH_VERSION, }) } requestConfigDebounced = debounce(this.requestConfig, 1000) markShadowAsConnected() { if (!this.isConnected()) { this.logger( `skipping markShadowAsConnected() because isConnected() is false` ) return false } this.publish(`$aws/things/${this.credentials.thingId}/shadow/update`, { state: { reported: { connected: true, vsh_version: VSH_VERSION, nr_version: RED.version(), }, }, }) } markShadowAsConnectedDebounced = debounce( this.markShadowAsConnected, 5000, { immediate: false, //to mitigate race condition with LWT setting device shadow to disconnected } ) async markShadowAsDisconnected() { return await this.publish( `$aws/things/${this.credentials.thingId}/shadow/update`, { state: { reported: { connected: false } }, } ) } async publish(topic, message) { if (!this.mqttClient) { return } this.stats.outboundMsgCount++ this.logger(`MQTT: publish to topic ${topic}`, message) return await this.mqttClient.publish(topic, message) } triggerChangeReport({ template, endpointId, properties, causeType, correlationToken = '', }) { const changes = properties.filter((prop) => prop.changed).length if (changes == 0 && causeType == 'PHYSICAL_INTERACTION') { this.logger(`skipping ChangeReport - no properties changed`) return } const publishCb = (doneCb) => { if (!this.isDisconnecting) { this.publish(`vsh/${this.credentials.thingId}/changeReport`, { template, endpointId, properties, correlationToken, causeType, vshVersion: VSH_VERSION, userIdToken: this.userIdToken, }) } doneCb() } const classification = { causeType, template, endpointId } this.rater.execute(classification, publishCb.bind(this)) } bulkDiscover(devices, mode = 'discover') { const payload = { devices: [] } for (const deviceId in devices) { if (devices[deviceId] !== null) { payload['devices'].push({ deviceId, friendlyName: devices[deviceId]['friendlyName'], template: devices[deviceId]['template'], retrievable: devices[deviceId]['retrievable'], }) } } if (payload.devices.length > 0) { this.publish(`vsh/${this.credentials.thingId}/bulk${mode}`, payload) } } handleGetAccepted(message) { const localDevices = this.getLocalDevices() const shadowDevices = (message.state.reported && message.state.reported.devices) || {} const toBeDiscoveredDevices = {} for (const deviceId in localDevices) { if ( !shadowDevices.hasOwnProperty(deviceId) || shadowDevices[deviceId]['template'] !== localDevices[deviceId]['template'] || shadowDevices[deviceId]['friendlyName'] !== localDevices[deviceId]['friendlyName'] || shadowDevices[deviceId]['retrievable'] !== localDevices[deviceId]['retrievable'] ) { toBeDiscoveredDevices[deviceId] = localDevices[deviceId] } } const toBeUndiscoveredDevices = {} for (const deviceId in shadowDevices) { if (!localDevices.hasOwnProperty(deviceId)) { toBeUndiscoveredDevices[deviceId] = shadowDevices[deviceId] toBeDiscoveredDevices[deviceId] = null } } if (Object.keys(toBeDiscoveredDevices).length > 0) { this.publish(`$aws/things/${this.credentials.thingId}/shadow/update`, { state: { reported: { devices: toBeDiscoveredDevices } }, }) this.bulkDiscover(toBeDiscoveredDevices) } this.bulkDiscover(toBeUndiscoveredDevices, 'undiscover') } handleLocalDeviceStateChange({ deviceId, oldState, newState }) { const oldProperties = buildPropertiesFromState(oldState) let newProperties = buildPropertiesFromState(newState) // annotate whether properties changed or not newProperties = annotateChanges(newProperties, oldProperties) // tell Alexa about new device properties this.triggerChangeReport({ template: newState.template, endpointId: deviceId, properties: newProperties, causeType: 'PHYSICAL_INTERACTION', }) } handleReportState(deviceId, directiveRequest) { // EXAMPLE directiveRequest: // { // directive: { // header: { // namespace: 'Alexa', // name: 'ReportState', // payloadVersion: '3', // correlationToken: 'AAAAAAAAAQAwOfXmbhm...', // }, // endpoint: { // endpointId: 'vshd-xxxxxxxxxxxxx', // }, // payload: {}, // }, // } const isActive = this.execCallbackForOne(deviceId, 'isActive') if (!isActive) { this.logger( `ignoring handleReportState for non-active device ID ${deviceId}`, null, 'warn' ) return } const currentState = this.execCallbackForOne(deviceId, 'getLocalState') if (!currentState) { this.logger( `no local state found for device ID ${deviceId}`, null, 'warn' ) return } const currentProperties = buildPropertiesFromState(currentState).map( (prop) => { prop['changed'] = false return prop } ) this.triggerChangeReport({ template: currentState.template, endpointId: deviceId, properties: currentProperties, causeType: 'STATE_REPORT', correlationToken: directiveRequest.directive.header.correlationToken, }) } handleDirectiveFromAlexa(deviceId, directiveRequest) { // EXAMPLE directiveRequest: // { // directive: { // header: { // namespace: 'Alexa.PowerController', // name: 'TurnOn', // payloadVersion: '3', // correlationToken: 'AAAAAAAAAQAwOfXmbhm...', // }, // endpoint: { // endpointId: 'vshd-xxxxxxxxxxxxx', // }, // payload: {}, // }, // } const isActive = this.execCallbackForOne(deviceId, 'isActive') if (!isActive) { // this.logger( // `ignoring handleDirectiveFromAlexa for non-active device ID ${deviceId}`, // null, // 'warn' // ) return } // get current device state const oldState = this.execCallbackForOne(deviceId, 'getLocalState') if (!oldState) { this.logger( `no local state found for device ID ${deviceId}`, null, 'warn' ) return } // memorize old properties so that we can find out what changed const oldProperties = buildPropertiesFromState(oldState) // apply directive to local device state try { const newState = buildNewStateForDirectiveRequest( directiveRequest, oldState ) // update local device state const newConfirmedState = this.execCallbackForOne( deviceId, 'setLocalState', newState ) // emit msg obj this.execCallbackForOne(deviceId, 'emitLocalState', { rawDirective: directiveRequest, }) let newProperties = buildPropertiesFromState(newConfirmedState) // annotate whether properties changed or not newProperties = annotateChanges(newProperties, oldProperties) // tell Alexa about new device properties this.triggerChangeReport({ template: oldState.template, endpointId: deviceId, properties: newProperties, causeType: 'VOICE_INTERACTION', correlationToken: directiveRequest.directive.header.correlationToken, }) } catch (e) { this.logger(e.message, null, 'error') return } } handlePing({ semverExpr }) { if (!semver.satisfies(VSH_VERSION, semverExpr)) { return } this.publish(`vsh/${this.credentials.thingId}/pong`, { thingId: this.credentials.thingId, email: this.credentials.email, vsh_version: VSH_VERSION, nr_version: RED.version(), secondsSinceStartup: Math.floor( (new Date().getTime() - this.stats.lastStartup) / 1000 ), ...this.stats, deviceCount: Object.keys(this.childNodes).length, devices: this.execCallbackForAll('getDeviceConfig'), }) } handleOverrideConfig(message) { this.publish(`$aws/things/${this.credentials.thingId}/shadow/get`, {}) if (message.msgRateLimiter) { const config = message.msgRateLimiter this.rater.overrideConfig(config) } if (message.userIdToken) { this.userIdToken = message.userIdToken } this.allowedDeviceCount = message.allowedDeviceCount this.disableUnallowedDevices(message.allowedDeviceCount) this.setPlan(message.plan) this.isInitializing = false this.refreshChildrenNodeStatus() } handleRestart({ semverExpr }) { if (semverExpr && !semver.satisfies(VSH_VERSION, semverExpr)) { return } this.logger('RECEIVED REQUEST TO RESTART VSH...') this.disconnect() this.execCallbackForAll('setActive', true) setTimeout(() => { this.connectAndSubscribe() const requestConfigJob = () => { if (!this.isSubscribed) { return false } this.requestConfigDebounced() } this.execOrQueueJob(requestConfigJob) }, 5000) } handleKill({ reason, semverExpr }) { if (semverExpr && !semver.satisfies(VSH_VERSION, semverExpr)) { return } this.logger( 'CONNECTION KILLED! Reason:', reason || 'undefined', null, 'warn' ) this.isKilled = true this.killedStatusText = reason ? reason : 'KILLED' this.isInitializing = false this.disconnect() } handleSetDeviceStatus({ status, color, devices }) { devices.forEach((deviceId) => { this.execCallbackForOne(deviceId, 'setStatus', { shape: 'dot', fill: color, text: status, }) }) } handleService(message) { switch (message.operation) { case 'ping': this.handlePing(message) break case 'overrideConfig': this.handleOverrideConfig(message) break case 'restart': this.handleRestart(message) break case 'kill': this.handleKill(message) break case 'setDeviceStatus': this.handleSetDeviceStatus(message) break default: this.logger( `received service request (${message.operation}) that is not supported by this VSH version. Updating to the latest version might fix this!`, null, 'warn' ) } } async checkVersion() { let response try { response = await axios.get( `${ this.config.backendUrl }/check_version?version=${VSH_VERSION}&nr_version=${RED.version()}&thingId=${ this.credentials.thingId }` ) // EXAMPLE response.data: // { // "isAllowedVersion": false, // "isLatestVersion": false, // "updateHint": "Please update to the latest version of VSH!", // "allowedDeviceCount": 5, // } return response.data } catch (error) { this.logger('checkVersion() failed', error, 'error') throw new Error( `HTTP Error Response: ${response.status || 'n/a'} ${ response.statusText || 'n/a' }` ) } } disableUnallowedDevices(allowedDeviceCount) { let i = 0 for (const nodeId in this.childNodes) { i++ if (i > allowedDeviceCount) { this.execCallbackForOne(nodeId, 'setActive', false) this.execCallbackForOne( nodeId, 'setStatus', { shape: 'dot', fill: 'gray', text: 'Device limit reached! Upgrade your VSH subscription to get more devices!', }, true //force ) } } } async connectAndSubscribe() { if (!this.credentials.server) { return } try { const { isAllowedVersion, isLatestVersion, updateHint } = await this.checkVersion() if (!isLatestVersion) { this.logger( `A newer version of VSH is available! Please update to ensure compatibility`, null, 'warn' ) } if (!isAllowedVersion) { this.logger( `connection to backend refused: ${updateHint}`, null, 'error' ) this.errorCode = updateHint this.isError = true this.refreshChildrenNodeStatus() return } } catch (e) { this.errorCode = 'Connection failed' this.isError = true this.refreshChildrenNodeStatus() //retry again in 30s: setTimeout(() => this.connectAndSubscribe(), 30_000) return this.logger(`version check failed! ${e.message}`, null, 'error') } this.isDisconnecting = false const options = { host: this.credentials.server, port: this.config.port, key: decodeBase64(this.credentials.privateKey), cert: decodeBase64(this.credentials.cert), ca: decodeBase64(this.credentials.caCert), clientId: this.credentials.thingId, log: (message) => { this.logger('mqtt.js: ' + message, null, 'debug') }, will: { topic: `vsh/${this.credentials.thingId}/update`, payload: JSON.stringify({ state: { reported: { connected: false } }, }), qos: 1, }, } this.mqttClient = new MqttClient(options) // register event listeners: this.mqttClient.on('connect', (_conAck) => { this.stats.connectionCount++ this.logger( `MQTT: connected to ${options.host}:${options.port}, connection #${this.stats.connectionCount}` ) this.isError = false this.refreshChildrenNodeStatus() const topicsToSubscribe = [ `$aws/things/${this.credentials.thingId}/shadow/get/accepted`, `vsh/${this.credentials.thingId}/+/directive`, `vsh/service`, `vsh/version/${VSH_VERSION}/+`, `vsh/${this.credentials.thingId}/service`, ] this.logger('MQTT: subscribe to topics', topicsToSubscribe) this.mqttClient.subscribe(topicsToSubscribe).catch((error) => { this.logger('MQTT: subscription failed', error, 'error') }) this.markShadowAsConnectedDebounced() }) this.mqttClient.on('offline', () => { this.logger('MQTT: connection offline') this.refreshChildrenNodeStatus() }) this.mqttClient.on('close', () => { this.logger('MQTT: connection closed') this.isSubscribed = false this.refreshChildrenNodeStatus() }) this.mqttClient.on('error', (error) => { this.logger('MQTT: error', error) this.isError = true this.errorCode = error.code this.refreshChildrenNodeStatus() }) this.mqttClient.on('message', (topic, message) => { this.logger(`MQTT: message received on topic ${topic}`, message) this.stats.inboundMsgCount++ switch (topic) { case `$aws/things/${this.credentials.thingId}/shadow/get/accepted`: this.handleGetAccepted(message) break case `vsh/service`: case `vsh/version/${VSH_VERSION}/service`: case `vsh/${this.credentials.thingId}/service`: this.handleService(message) break default: const match = topic.match(/vshd-[^\/]+/) if (match) { const deviceId = match[0] if (topic.includes('/directive')) { if (message.directive.header.name == 'ReportState') { this.handleReportState(deviceId, message) } else { this.handleDirectiveFromAlexa(deviceId, message) } } else { this.logger( 'received device-related message that is not supported yet!', { topic, message }, null, 'warn' ) } } else { this.logger( 'received thing-related message that is not supported yet!', { topic, message }, null, 'warn' ) } } }) this.mqttClient.on('subscribed', (_subscriptions) => { this.isSubscribed = true }) this.logger( `MQTT: attempting connection: ${options.host}:${options.port} (clientId: ${options.clientId})` ) this.mqttClient.connect() } async disconnect() { if (this.isDisconnecting) { this.logger('ignoring disconnect() as already disconnecting') return } this.logger('MQTT: disconnecting') this.isDisconnecting = true if (this.isConnected()) { // publish() will block forever when not connected. Use of Promise.race() as an additional precaution await Promise.race([this.markShadowAsDisconnected(), timeout(1000)]) } if (this.mqttClient) { await this.mqttClient.end() this.mqttClient = null } this.isSubscribed = false this.isInitializing = false this.isError = false } } RED.nodes.registerType('vsh-connection', ConnectionNode, { credentials: { vshJwt: { type: 'text' }, refreshToken: { type: 'text' }, accessToken: { type: 'text' }, email: { type: 'text' }, cert: { type: 'text' }, thingId: { type: 'text' }, caCert: { type: 'text' }, server: { type: 'text' }, privateKey: { type: 'text' }, }, settings: { vshConnectionShowSettings: { //= RED.settings.vshConnectionShowSettings value: false, exportable: true, }, vshConnectionDefaultBackendUrl: { //= RED.settings.vshConnectionDefaultBackendUrl value: 'https://kfd5m4a21f.execute-api.eu-west-1.amazonaws.com/dev', exportable: true, }, vshConnectionDefaultLwaClientId: { //= RED.settings.vshConnectionDefaultLwaClientId value: 'amzn1.application-oa2-client.3f1bb07133854b078261ad43f2484c18', exportable: true, }, }, }) }