UNPKG

node-red-contrib-google-smarthome

Version:

Lets you control Node-Red via Google Assistant or the Google Home App

704 lines (607 loc) 27.4 kB
/** * node-red-contrib-google-smarthome * Copyright (C) 2024 Michael Jacobsen and others. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <http://www.gnu.org/licenses/>. */ 'use strict'; const http = require('http'); const https = require('https'); const express = require('express'); const stoppable = require('stoppable'); const helmet = require('helmet'); const morgan = require('morgan'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const events = require('events'); const dnssd = require('@gravitysoftware/dnssd'); const udp = require('dgram'); const Auth = require('./Auth.js'); const Devices = require('./Devices.js'); const HttpAuth = require('./HttpAuth.js'); const HttpActions = require('./HttpActions.js'); /****************************************************************************************************************** * GoogleSmartHome * */ class GoogleSmartHome { constructor(mgmtNode, nodeId, userDir, httpNodeRoot, useGoogleLogin, googleClientId, emails, username, password, accessTokenDuration, usehttpnoderoot, httpPath, httpPort, localScanType, localScanPort, httpLocalPort, nodeRedUsesHttps, ssloffload, publicKey, privateKey, jwtkeyFile, clientid, clientsecret, reportStateInterval, requestSyncDelay, setStateDelay, debug, debug_function, error_function) { this.auth = new Auth(this); this.devices = new Devices(this); this.httpActions = new HttpActions(this); this.httpAuth = new HttpAuth(this); this._nodeId = nodeId; this._mgmtNode = mgmtNode; this._reportStateTimer = null; this._reportStateInterval = reportStateInterval; // minutes this._httpNodeRoot = httpNodeRoot; this._httpPath = this.Path_join('/', httpPath || ''); this._httpPort = httpPort; this._localScanType = localScanType || ''; this._localScanPort = localScanPort; this._httpLocalPort = httpLocalPort; this._sslOffload = ssloffload; this._publicKey = publicKey; this._privateKey = privateKey; this._jwtKeyFile = jwtkeyFile; this._requestSyncDelay = requestSyncDelay * 1000; this._setStateDelay = setStateDelay * 1000; this._debug = debug; this._userDir = userDir; this._httpServerRunning = false; this._dnssdAdRunning = false; this._syncScheduled = false; this._getStateScheduled = false; this._httpLocalPath = this.Path_join(this._httpNodeRoot || '/', this._httpPath); this._httpPath = this.Path_join((usehttpnoderoot ? this._httpNodeRoot || '/' : '/'), this._httpPath); this.debug_function = debug_function; this.error_function = error_function; this._localScanPacket = 'node-red-contrib-google-smarthome'; this._localUDPServers = {}; this._localDiscoveryPort = null; this._localExecutionPort = null; this.dnssdAd = null; if (nodeRedUsesHttps && httpLocalPort <= 0) { error_function("GoogleSmartHome: Node-RED is using HTTPS but no local http port was defined, local execution will fail."); } this.debug('GoogleSmartHome.constructor'); this.auth.loadAuthStorage(nodeId, userDir); this.auth.setClientIdSecret(clientid, clientsecret); if (useGoogleLogin) { this.auth.setGoogleClientIdAndEmails(googleClientId, emails); } else { this.auth.setUsernamePassword(username, password); } this.auth.setAccessTokenDuration(accessTokenDuration); this.emitter = new events.EventEmitter(); // httpNodeRoot is the root url for nodes that provide HTTP endpoints. If set to false, all node-based HTTP endpoints are disabled. if (this._httpNodeRoot !== false) { if (httpPort > 0) { // create express middleware this.app = express(); this.app.use(helmet({ // opener policy required for Google Sign-In popup crossOriginOpenerPolicy: { policy: "same-origin-allow-popups" } })); this.app.use(cors()); if(this._debug) this.app.use(morgan('dev')); this.app.use(express.json()); this.app.use(express.urlencoded({extended: true})); this.app.set('trust proxy', 1); // trust first proxy // frontend UI this.app.set('jsonp callback name', 'cid'); this.httpAuth.httpAuthRegister(this._httpPath, this.app); // login and oauth http interface this.httpActions.httpActionsRegister(this._httpPath, this.app); // actual SmartHome http interface } if (httpLocalPort > 0) { // create express middleware this.localApp = express(); this.localApp.use(helmet()); this.localApp.use(cors()); if(this._debug) this.localApp.use(morgan('dev')); this.localApp.use(express.json()); this.localApp.use(express.urlencoded({extended: true})); this.localApp.set('trust proxy', 1); // trust first proxy // frontend UI this.localApp.set('jsonp callback name', 'cid'); this.httpActions.httpLocalActionsRegister(this._httpLocalPath, this.localApp); } } } /** * Retrieves the router instance from an Express app object. * This method provides compatibility for Express 4 (app._router) and Express 5 (app.router). * * @param {object} appInstance - The Express application instance. * @returns {object|undefined} The router object if found, otherwise undefined. */ getRouter(appInstance) { return appInstance._router || appInstance.router; } /** * Joins multiple path segments into a single path. * * @param {...string} paths - The path segments to join * @returns {string} The joined path */ Path_join(...paths) { let full_path = ''; for (const ipath of paths) { const fpe = full_path.endsWith('/'); const ips = ipath.startsWith('/'); if (fpe && ips) { full_path += ipath.substring(1); } else if (!fpe && !ips) { full_path += '/' + ipath; } else { full_path += ipath; } } return full_path; } /** * Determines the type of a HTTP route (GET, POST, OPTIONS, etc.). * * @param {object} route - The route object * @returns {string} The type of the route */ GetRouteType(route) { if (route) { if (route.route.methods['get'] && route.route.methods['post']) return "all"; if (route.route.methods['get']) return "get"; if (route.route.methods['post']) return "post"; if (route.route.methods['options']) return "options"; } return 'unknown'; } /** * Unregisters all URLs we registered with Node-RED's webserver. * This is only necessary if the webserver from Node-RED is used. If we * use our own webserver, routes are automatically removed when we * stop the webserver. * * @param {express} REDapp - the Express server from Node-RED */ UnregisterUrl(REDapp) { // Skip if we are using our own webserver instead of Node-RED's webserver if (this._httpPort > 0) { return; } const me = this; const redAppRouter = this.getRouter(REDapp); if (redAppRouter) { me.debug("SmartHome:UnregisterUrl(): use the Node-RED server port, path '" + this._httpPath + "' local path '" + this._httpLocalPath + "'"); let get_urls = [me.Path_join(me._httpPath, 'oauth'), me.Path_join(me._httpPath, 'check')]; let post_urls = [me.Path_join(me._httpPath, 'oauth'), me.Path_join(me._httpPath, 'smarthome')]; let options_urls = [me.Path_join(me._httpPath, 'smarthome')]; let all_urls = [me.Path_join(me._httpPath, 'token')]; let to_remove = []; redAppRouter.stack.forEach(function (route, i) { if (route.route && ( (route.route.methods['get'] && get_urls.includes(route.route.path)) || (route.route.methods['post'] && post_urls.includes(route.route.path)) || (route.route.methods['options'] && options_urls.includes(route.route.path)) || (all_urls.includes(route.route.path)) )) { me.debug('SmartHome:Stop(): removing url: ' + route.route.path + " registered for " + me.GetRouteType(route)); to_remove.unshift(i); } }); to_remove.forEach(i => redAppRouter.stack.splice(i, 1)); redAppRouter.stack.forEach(function (route) { if (route.route) me.debug('SmartHome:Stop(): remaining url: ' + route.route.path + " registered for " + me.GetRouteType(route)); }); } } // // // StartDeviceScanServer() { if (this._localScanType === "UDP") { this.StartUDPDeviceScanServer(); } else if (this._localScanType === "MDNS") { this.StartMDNSAdvertisement(); } } // // // StopDeviceScanServer() { this.StopUDPDeviceScanServer(); this.StopMDNSAdvertisement(); } // // // StopUDPDeviceScanServer() { ['udp4', /*'udp6'*/].forEach((type) => { if (Object.prototype.hasOwnProperty.call(this._localUDPServers, type) && this._localUDPServers[type] !== null) { this._localUDPServers[type].close(); delete this._localUDPServers[type]; } }); } // // // StartUDPDeviceScanServer() { const me = this; me.debug('Starting service Scan UDP server on port ' + me._localDiscoveryPort); me.StopUDPDeviceScanServer(); function onError(error) { me.debug('Service Scan UDP server: ' + error); me.StopUDPDeviceScanServer(); } function onMessage(msg, info) { const data = msg.toString(); if (data === me._localScanPacket) { const sync_res = Buffer.from(JSON.stringify({clientId: me._nodeId}), 'utf8'); this.send(sync_res, info.port, info.address, function(error){ if(error) { me.debug("Service Scan UDP server: error sending message " + error); } }); } else { me.debug('Service Scan UDP server: error wrong message received'); } } function onListening() { const address = this.address(); me.debug(`Service Scan UDP server: listening on: ${this.type} ${address.address}:${address.port}`); } function onClose() { me._localUDPServers[this.type] = null; me.debug(`Service Scan UDP server: server ${this.type} server closed`); } ['udp4', /*'udp6'*/].forEach((type) => { me._localUDPServers[type] = udp.createSocket({type: type, ipv6Only: type === 'udp6'}); me._localUDPServers[type].on('error', onError); me._localUDPServers[type].on('message', onMessage); me._localUDPServers[type].on('listening', onListening); me._localUDPServers[type].on('close', onClose); me._localUDPServers[type].bind(me._localDiscoveryPort); }); } /** * Stops the mDNS advertisement for local fulfillment. */ StopMDNSAdvertisement(){ if (this._dnssdAdRunning) { this._dnssdAdRunning = false; this.dnssdAd.stop(); this.dnssdAd = null; } } /** * Starts the mDNS advertisement for local fulfillment. */ StartMDNSAdvertisement() { const me = this; this.StopMDNSAdvertisement(); this.dnssdAd = dnssd.Advertisement(dnssd.tcp('nodered-google'), this._localDiscoveryPort, {txt: {clientId: this._nodeId}}) this.dnssdAd.start(); this._dnssdAdRunning = true; me.debug('SmartHome:Start(): dnssd-ad: port:' + this._localDiscoveryPort); this.dnssdAd.on('error', function (err) { me.error('SmartHome:Start(): dnssd-ad: err:' + err); }); } // // // Start(REDapp, REDserver) { // httpNodeRoot is the root url for nodes that provide HTTP endpoints. If set to false, all node-based HTTP endpoints are disabled. if (this._httpNodeRoot === false) return; this._localDiscoveryPort = this._localScanPort || this._httpLocalPort || REDserver.address().port; this._localExecutionPort = this._httpLocalPort || REDserver.address().port; try { const graceMilliseconds = 500; const me = this; if (this._jwtKeyFile) { this.auth.loadJwtKeyFile(this._jwtKeyFile, this._userDir); // will throw if file cannot be read if (this._reportStateInterval > 0) { this._reportStateTimer = setInterval(function() { let states = me.devices.getStates(); if (states) { me.httpActions.reportState(undefined, states); } }, this._reportStateInterval * 60 * 1000); } } if (this._httpPort > 0) { if (this._sslOffload) { me.debug('SmartHome:Start(listen): using external SSL offload'); // create our HTTP server this.httpServer = stoppable(http.createServer(this.app), graceMilliseconds); } else { me.debug('SmartHome:Start(listen): using internal SSL'); let httpsOptions = {}; try { if(!this._privateKey) { return 'No private SSL key file specified in configuration.'; } httpsOptions.key = fs.readFileSync(this._privateKey) } catch (error) { return `Error while loading private SSL key from file "${this._privateKey}" (${error})`; } try { if(!this._publicKey) { return 'No public SSL key specified in configuration.'; } httpsOptions.cert = fs.readFileSync(this._publicKey) } catch (error) { return `Error while loading public SSL key from file "${this._publicKey}" (${error})`; } // create our HTTPS server this.httpServer = stoppable(https.createServer(httpsOptions, this.app), graceMilliseconds); // update server if certificate file changes on disk // timeout is used to give certbot enough time to renew private and public key let waitForRenewalTimeout; fs.watch(this._publicKey, () => { me.debug('SmartHome:Start(listen): Certificate file change detected. Updating HTTPS server in 30 seconds.'); clearTimeout(waitForRenewalTimeout); waitForRenewalTimeout = setTimeout(() => { let context = { key: fs.readFileSync(this._privateKey), cert: fs.readFileSync(this._publicKey) } this.httpServer.setSecureContext(context); me.debug('SmartHome:Start(listen): HTTPS server updated after certificate file change'); }, 30000); }); } // start server this.httpServer.listen(this._httpPort, () => { me._httpServerRunning = true; const host = me.httpServer.address().address; const port = me.httpServer.address().port; me.debug('SmartHome:Start(listen): listening at ' + host + ':' + port); process.nextTick(() => { me.emitter.emit('server', 'start', me._httpPort); }); }); this.httpServer.on('error', function (err) { me.error('SmartHome:Start(): err:' + err); process.nextTick(() => { me.emitter.emit('server', 'error', err); }); }); me.debug('SmartHome:Start(): registered routes:'); const appRouter = this.getRouter(this.app); appRouter.stack.forEach((r) => { if (r.route && r.route.path) { me.debug('SmartHome:Start(): url ' + r.route.path + " registered for " + me.GetRouteType(r)); } }); } if (this._httpLocalPort > 0) { me.debug('SmartHome:Start(listen): starting local fulfillment'); this.localHttpServer = stoppable(http.createServer(this.localApp), graceMilliseconds); // start server this.localHttpServer.listen(this._httpLocalPort, () => { me._localHttpServerRunning = true; const host = me.localHttpServer.address().address; const port = me.localHttpServer.address().port; me.debug('SmartHome:Start(listen): listening for local fullfullment at ' + host + ':' + port); }); this.localHttpServer.on('error', function (err) { me.error('SmartHome:Start(): local err:' + err); }); me.debug('SmartHome:Start(): local registered routes:'); const localAppRouter = this.getRouter(this.localApp); localAppRouter.stack.forEach((r) => { if (r.route && r.route.path) { me.debug('SmartHome:Start(): url ' + r.route.path + " registered for " + me.GetRouteType(r)); } }); } me.StartDeviceScanServer(); if (this._httpPort <= 0) { me.UnregisterUrl(REDapp); if (this._httpPort <= 0) { me.debug("SmartHome:Start(): use the Node-RED server port, path " + this._httpPath); this.httpAuth.httpAuthRegister(this._httpPath, REDapp); // login and oauth http interface this.httpActions.httpActionsRegister(this._httpPath, REDapp); // actual SmartHome http interface } const redAppRouter = this.getRouter(REDapp); redAppRouter.stack.forEach((r) => { if (r.route && r.route.path && (r.route.path.startsWith(this._httpPath) || r.route.path.startsWith(this._httpLocalPath))) { me.debug('SmartHome:Start(): url ' + r.route.path + " registered for " + me.GetRouteType(r)); } }); } } catch (err) { return err; } return true; } // // // Stop(REDapp, done) { // httpNodeRoot is the root url for nodes that provide HTTP endpoints. If set to false, all node-based HTTP endpoints are disabled. if (this._httpNodeRoot === false) return; const me = this; if (this._reportStateTimer !== null) { clearTimeout(this._reportStateTimer); this._reportStateTimer = null; } me.UnregisterUrl(REDapp); me.StopDeviceScanServer(); if (this._httpLocalPort > 0) { if (this._localHttpServerRunning) { this._localHttpServerRunning = false; this.localHttpServer.stop(); setImmediate(function(){ me.localHttpServer.emit('close'); }); } } if (this._httpPort > 0) { if (this._httpServerRunning) { this._httpServerRunning = false; this.httpServer.stop(function() { process.nextTick(() => { me.emitter.emit('server', 'stop', 0); }); if (typeof done === 'function') { done(); } }); setImmediate(function(){ me.httpServer.emit('close'); }); } else { process.nextTick(() => { me.emitter.emit('server', 'stop', 0); }); if (typeof done === 'function') { done(); } } } else { process.nextTick(() => { me.emitter.emit('server', 'stop', 0); }); if (typeof done === 'function') { done(); } } } // // // Restart(REDapp, REDserver) { let me = this; this.Stop(REDapp, function() { me.debug('SmartHome:Restart(): Stop done'); me.Start(REDapp, REDserver); me.debug('SmartHome:Restart(): Start done'); }); } getCustomData() { if (this._localScanType) { return { httpPort: this._localExecutionPort, httpPathPrefix: this.Path_join(this._httpLocalPath, ""), clientId: this._nodeId, accessToken: this.auth.getLocalAuthCode(), } } return {}; } /** * Reports the states of all devices to Google. */ ReportAllStates() { let states = this.devices.getStates(); if (states) { this.httpActions.reportState(undefined, states); } } /** * Sends a SYNC request to Google. */ RequestSync() { this.httpActions.requestSync(); } /** * Waits 10 seconds, then sends a SYNC request to Google. * Multiple calls to this method during the delay are buffered into the same SYNC call. */ ScheduleRequestSync() { const me = this; if (me._requestSyncDelay && !me._syncScheduled) { me._syncScheduled = true; setTimeout(() => { me._syncScheduled = false; me.httpActions.requestSync(); }, me._requestSyncDelay); } } // // // ScheduleGetState() { const me = this; if (me._setStateDelay && !me._getStateScheduled) { me._getStateScheduled = true; setTimeout(() => { me._getStateScheduled = false; Object.keys(me._mgmtNode.mgmtNodes).forEach(key => me._mgmtNode.mgmtNodes[key].sendSetState()); }, me._setStateDelay); } } /** * Checks if the app.js script running on the smart speaker is the most up-to-date version. * * During the IDENTIFY request, the app.js script running on the smart speaker sends its version number. Here, we * read the local app.js file, parse the version number out of the file and compare the version numbers with each * other. If the version numbers do not match, the user gets a message on Node-RED's debug panel. * * @param {string} remoteAppJsVersion - version number of the script running on the speaker */ checkAppJsVersion(remoteAppJsVersion) { const appJsPath = path.resolve(__dirname, '../local-execution/app.js'); fs.readFile(appJsPath, 'utf8', (err, data) => { if (err) { this.error('SmartHome:checkAppJsVersion(): Cannot read app.js file (' + err + ')'); return; } const regex = /VERSION\s*=\s*'([0-9.]+)'/; const matches = data.match(regex); const localAppJsVersion = matches[1]; if(typeof localAppJsVersion === 'undefined') { this.error('SmartHome:checkAppJsVersion(): Cannot parse version from app.js file'); return; } if(remoteAppJsVersion === localAppJsVersion) { this.debug('SmartHome:checkAppJsVersion(): app.js on smart speaker is up to date (v' + localAppJsVersion + ')'); } else if(typeof remoteAppJsVersion === 'undefined') { this._mgmtNode.warn('SmartHome:checkAppJsVersion(): app.js on smart speaker did not report version number. Please upload latest app.js as explained on https://github.com/mikejac/node-red-contrib-google-smarthome/blob/master/docs/local_fulfillment.md#updating-appjs.'); } else { this._mgmtNode.warn('SmartHome:checkAppJsVersion(): app.js version on smart speaker did not match local app. Expected ' + localAppJsVersion + ', got ' + remoteAppJsVersion + '. Please upload latest app.js as explained on https://github.com/mikejac/node-red-contrib-google-smarthome/blob/master/docs/local_fulfillment.md#updating-appjs.'); } }); } // // // IsHttpServerRunning() { return this._httpServerRunning || this._httpPort <= 0; } /** * Logs debug information. * * @param {string} data - The debug information to log. */ debug(data) { this.debug_function(data); } /** * Logs error information. * * @param {string} data - The error information to log. */ error(data) { this.error_function(data); } } module.exports = GoogleSmartHome;