UNPKG

alexa-fhem

Version:
1,451 lines (1,186 loc) 183 kB
'use strict'; const PORT=3000; var path = require('path'); var fs = require('fs'); var version = require('./version'); var User = require('./user').User; var log = require("./logger")._system; var Logger = require('./logger').Logger; var FHEM = require('./fhem').FHEM; const util = require('util'); // For ssh proxy: Readings for Amazon provisioned token and SSH status: const BEARERTOKEN_NAME = "alexaFHEM.bearerToken"; const SSHSTATUS_NAME = "alexaFHEM.ProxyConnection"; module.exports = { Server: Server } function Server() { this._config = this._loadConfig(); if( Server.as_proxy ) this._config.alexa = this._config['alexa-proxy']; if( !this._config.alexa && !this._config.sshproxy ) { log.error( 'error: neither alexa nor proxy config found, nothing to do' ); process.exit(-1); } if( this._config.alexa && this._config.alexa.port === undefined ) this._config.alexa.port = PORT; if( this._config.alexa && this._config.sshproxy && this._config.alexa.port !== undefined && this._config.alexa.port === this._config.sshproxy.port ) { log.error( 'error: alexa and proxy ports are identical' ); process.exit(-1); } if (this._config.sshproxy) { this._config.sshproxy.options = [ '-i', path.join(User.sshKeyPath(), 'id_rsa'), '-p', 58824, 'fhem-va.fhem.de' ]; } } Server.as_proxy = false; Server.use_proxy = false; Server.asProxy = function(as_proxy) { try { require.resolve('socket.io') } catch (e) { if (e.code !== 'MODULE_NOT_FOUND') { throw e; } log.error( 'error: socket.io not installed' ); process.exit(-1); } if( Server.use_proxy ) { log.error( 'error: can\'t run as proxy with use-proxy' ); process.exit(-1); } Server.as_proxy = as_proxy; } Server.useProxy = function(use_proxy) { try { require.resolve('socket.io-client') } catch (e) { if (e.code !== 'MODULE_NOT_FOUND') { throw e; } log.error( 'error: socket.io-client not installed' ); process.exit(-1); } if( Server.as_proxy ) { log.error( 'error: can\'t run with use-proxy as proxy' ); process.exit(-1); } Server.use_proxy = use_proxy; } Server.prototype._loadConfig = function() { // Look for the configuration file var configPath = User.configPath(); log.info("using config from " + configPath ); var config; // Complain and exit if it doesn't exist yet if( !fs.existsSync(configPath) ) { log.error("Couldn't find a config file at '"+configPath+"'. Look at config-sample.json for an example."); process.exit(1); } // Load up the configuration file try { config = JSON.parse(fs.readFileSync(configPath)); } catch (err) { log.error("There was a problem reading your config.json file."); log.error("Please try pasting your config.json file here to validate it: http://jsonlint.com"); log.error(""); throw err; } if( config.alexa ) { if( config.alexa.applicationId !== undefined && typeof config.alexa.applicationId !== 'object' ) config.alexa.applicationId = [config.alexa.applicationId]; if( config.alexa.oauthClientID !== undefined && typeof config.alexa.oauthClientID !== 'object' ) config.alexa.oauthClientID = [config.alexa.oauthClientID]; var username = config.alexa.username; } console.log( '*** CONFIG: parsed completely' ); return config; } Server.prototype.startProxy = function(fhem, alexaDevName) { if( this._config.sshproxy.server !== undefined ) { return; } function processRequest(request, response){ //console.log( request ); //console.log( request.url ); var body = ''; request.on('data', function(chunk){body += chunk}); request.on('end', function() { function processBody(response, body) { log.info (">>>> [ssh] " + body.replace(/[\r\n]/gm, ' ')); var event = JSON.parse(body); verifyToken.bind(this)(true, event, function(ret, error) { if( error ) log.error( 'ERROR: ' + error + ' from ' + request.connection.remoteAddress ); const rsp = JSON.stringify(ret); log.info('<<<< [ssh] '+ rsp); response.end(rsp); }); } if( 1 ) { try { processBody.bind(this)(response, body); } catch( e ) { log.error( 'ERROR: ' + util.inspect(e) + ' from ' + request.connection.remoteAddress ); const rsp = JSON.stringify(createError(ERROR_UNSUPPORTED_OPERATION)); log.error('<<<< [ssh] '+ rsp); response.end(rsp); }// try-catch } else { processBody.bind(this)(response, body); } }.bind(this)); } var server = require('http').createServer(processRequest.bind(this)); server.on('error', (res) => { log.error ("Server emitted error: " + JSON.stringify(res)); if (res.syscall === "listen") { log.info ("Terminating - starting the listener not possible (another instance running?)"); process.exit(1); } }); if( this._config.sshproxy['bind-ip'] === undefined ) this._config.sshproxy['bind-ip'] = '127.0.0.1'; server.setTimeout(0); server.listen(this._config.sshproxy.port, this._config.sshproxy['bind-ip'], function(){ log.info("Server listening on: http://%s:%s for proxy connections", server.address().address, server.address().port); this._config.sshproxy.server = server; log.info ("*** SSH: checking proxy configuration"); var startupPromise = User.autoConfig(false, this._config, fhem, alexaDevName); startupPromise.then ( (r) => { log.info("*** SSH: proxy configuration set up done"); this.open_ssh(this._config.sshproxy); // This fixes (hopefully) a race condition: Initial SSH setup takes longer than server setup if (typeof r === "object" && r.hasOwnProperty('bearerToken')) { log.info ('SSH setup completed with new bearer token'); published_tokens.push(r.bearerToken); } }).catch((e) => { log.error('*** SSH: proxy configuration failed: ' + e); setTimeout( ()=>{ this.set_ssh_state( 'error', e ); }, 1000); }); }.bind(this) ); } Server.prototype.startProxy2 = function(fhem, alexaDevName) { if( this._config.sshproxy.server !== undefined ) { return; } var Client = null; try { Client = require('ssh2').Client; } catch(e) { if( e.code !== 'MODULE_NOT_FOUND' ) throw e; console.error( 'ssh2 not available, falling back to external ssh' ); this.startProxy(fhem, alexaDevName); return; } var conn = new Client(); conn.on('ready', function() { log.info('*** SSH: proxy connection established'); conn.shell({ window: false }, function(err, stream) { if (err) throw err; stream.on('close', function() { log.info('*** SSH: closed'); conn.end(); }).on('data', function(data) { log.info('SSH: ' + data); }); }); conn.forwardIn('127.0.0.1', 1234, function(err) { if (err) throw err; log.info('*** SSH: tunnel connection established'); }); }).on('tcp connection', function(info, accept, reject) { //console.log('TCP :: INCOMING CONNECTION:'); //console.dir(info); let session = accept(); session.on('close', function() { //console.log('TCP :: CLOSED'); }).on('error', function(e) { //console.log('TCP :: ERROR ' + e); }).on('data', function(data) { //console.log('TCP :: DATA: ' + data); var parts = data.toString().split( '\r\n\r\n' ); let body = parts[1]; function processBody(conn, body) { log.info (">>>> [ssh] " + body.replace(/[\r\n]/gm, ' ')); var event = JSON.parse(body); verifyToken.bind(this)(true, event, function(ret, error) { if( error ) log.error( 'ERROR: ' + error ); const rsp = JSON.stringify(ret); log.info('<<<< [ssh] '+ rsp); var header = 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length:'+ rsp.length +'\r\n'; session.end(header + '\r\n' +rsp); }); } if( 1 ) { try { processBody.bind(this)(conn, body); } catch( e ) { log.error( 'ERROR: ' + util.inspect(e) ); const rsp = JSON.stringify(createError(ERROR_UNSUPPORTED_OPERATION)); log.error('<<<< [ssh] '+ rsp); var header = 'HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length:'+ rsp.length +'\r\n'; session.end(header + '\r\n' +rsp); }// try-catch } else { processBody.bind(this)(conn, body); } }.bind(this)); }.bind(this)).connect({ host: 'fhem-va.fhem.de', port: 58824, username: 'xxx', keepaliveInterval: 90*1000, privateKey: require('fs').readFileSync('./key') }); } var sockets = {}; Server.prototype.startServer = function() { function processRequest(request, response){ //console.log( request ); //console.log( request.url ); var body = ''; request.on('data', function(chunk){body += chunk}); request.on('end', function() { if( request.url === '/favicon.ico' ) { response.writeHead(404); response.end(''); return; } function processBody(response, body) { log.info (">>>> [srv] " + body.replace(/[\r\n]/gm, ' ')); var event = JSON.parse(body); verifyToken.bind(this)(false, event, function(ret, error) { if( error ) log.error( 'ERROR: ' + error + ' from ' + request.connection.remoteAddress ); const rsp = JSON.stringify(ret); log.info ("<<<< [srv] " + rsp); response.end(rsp); }); } if( 1 ) { try { processBody.bind(this)(response, body); } catch( e ) { log.error( 'ERROR: ' + util.inspect(e) + ' from ' + request.connection.remoteAddress ); const rsp = JSON.stringify(createError(ERROR_UNSUPPORTED_OPERATION)); log.error ("<<<< [srv] " + rsp); response.end(rsp); }// try-catch } else { processBody.bind(this)(response, body); } }.bind(this)); } var server; if( this._config.alexa.ssl === false ) { server = require('http').createServer(processRequest.bind(this)); } else { let options = { key: fs.readFileSync(this._config.alexa.keyFile || './key.pem'), cert: fs.readFileSync( this._config.alexa.certFile || './cert.pem'), }; server = require('https').createServer(options,processRequest.bind(this)); } server.on('error', (res) => { log.error ("Server emitted error: " + JSON.stringify(res)); if (res.syscall === "listen") { log.info ("Terminating - starting the listener not possible (another instance running?)"); process.exit(1); } }); server.listen(this._config.alexa.port, this._config.alexa['bind-ip'], function(){ log.info("Server listening on: http%s://%s:%s for direct connections", this._config.alexa.ssl === false?'':'s', server.address().address, server.address().port); if( Server.as_proxy ) { var io = require('socket.io'); io = io.listen(server); io.on('connection', function(socket){ console.log('a user connected'); socket.on('register', function(msg){ console.log('user registered'); socket.id = msg; sockets[msg] = socket; }); socket.on('disconnect', function(){ delete sockets[socket.id]; console.log('user disconnected'); }); }); } }.bind(this) ); } Server.prototype.connectToProxy = function() { log.info('trying to connect to proxy '); const io = require('socket.io-client'); const socket = io('https://'+ this._config.alexa.proxy, {rejectUnauthorized: false}); socket.on('event', function(msg,callback) { try { log.info (">>>> [prx] " + msg.replace(/[\r\n]/gm, ' ')); var event = JSON.parse(msg); verifyToken.bind(this)(false, event, function(ret, error) { if( error ) log.error( 'ERROR: ' + error + ' from proxy' ); const rsp = JSON.stringify(ret); log.info( "<<<< [prx] " + rsp); callback(rsp); }); } catch( e ) { if( e ) log.error( 'ERROR: ' + util.inspect(e) + ' from proxy' ); const rsp = JSON.stringify(createError(ERROR_UNSUPPORTED_OPERATION)); log.info( "<<<< [prx] " + rsp); callback(rsp); }// try-catch }.bind(this)); socket.on('connect', () => { log.info('connected to proxy: ' +this._config.alexa.proxy); for( let i = 0; i < this._config.alexa.oauthClientID.length; ++i ) { socket.emit('register', this._config.alexa.oauthClientID[i]); } }); socket.on('disconnect', (reason) => { log.info('disconnect from proxy: ' +reason); log.info('trying to reconnect to proxy '); }); } var ssh_client; var ssh_client_state; var ssh_client_laststderr; var ssh_client_reconnectdelay = undefined; Server.prototype.set_ssh_state = function( state, txt ) { ssh_client_state = state; if( txt === undefined ) this.setreading( SSHSTATUS_NAME, state ); else this.setreading( SSHSTATUS_NAME, state +";; "+ txt ); } Server.prototype.unregister_ssh = function() { const proxy_config = this._config.sshproxy; const execSync = require('child_process').spawnSync; const prc = execSync( proxy_config.ssh, proxy_config.options.concat(['unregister'])); if (prc.error) { log.warn ("Error executing unregister: " + prc.error.message); return; } if (prc.status !== 0) { log.warn ( "ssh unregister returned error - " + (prc.output[2].toString())); return; } log.info ("ssh unregister: Result is " + prc.output[1].toString().replace(/[\r\n]/gm, ' ')); if (ssh_client) { ssh_client.removeAllListeners('close'); ssh_client.kill(); this.set_ssh_state( 'stopped', ssh_client_laststderr); } } Server.prototype.open_ssh = function() { if( !ssh_client ) { this.set_ssh_state( 'starting', 'starting SSH' ); ssh_client_laststderr = undefined; ssh_client_reconnectdelay = undefined; const proxy_config = this._config.sshproxy; if( proxy_config.server === undefined ) { this.set_ssh_state( 'stopped', 'server for reverse tunnel not started'); log.warn ("*** SSH: server for reverse tunnel not started"); return; } const exec = require('child_process').spawn; const args = ['-R', '1234:' + proxy_config['bind-ip']+ ':' + proxy_config.server.address().port, '-oServerAliveInterval=90'].concat(proxy_config.options); log.info ("Starting SSH with " + args.join(' ')); ssh_client = exec( proxy_config.ssh, args); ssh_client.stdout.on('data', (data) => { const delayReconnectRegex = /\(retry: (\d+) seconds\)/m; const str = data.toString(); if (str.indexOf('Welcome at the reverse proxy')>=0) { this.set_ssh_state('running', 'SSH connected') log.info ("*** SSH: proxy connection established"); } else { log.info ("*** SSH: stdout message: " + str); const match = delayReconnectRegex.exec(str); if (match) { ssh_client_reconnectdelay = parseInt(match[1])*1000; } this.set_ssh_state('running', data.toString()); } log.info('SSH: ' + str.replace(/[\r\n]/gm, ' ')); }); ssh_client.stderr.on('data', (data) => { const str = data.toString(); // Ignore Pseudo-terminal will not be allocated because stdin is not a terminal. if (str.toLowerCase().indexOf('pseudo-terminal')<0) { const stderrmsg = str.replace(/[\r\n]/gm, ' '); this.set_ssh_state('running', 'stderr=' + stderrmsg); log.info ("*** SSH: stderr: " + stderrmsg); ssh_client_laststderr = stderrmsg; } }); ssh_client.on('close', (code) => { if( ssh_client_state !== 'running' ) { this.set_ssh_state( 'stopped', ssh_client_laststderr); log.warn ("*** SSH: exited with " + (ssh_client_laststderr ? ssh_client_laststderr : code)); return; } // Wait some time (15-135 seconds) not to overload the server... const delay = ssh_client_reconnectdelay ? ssh_client_reconnectdelay : 15000 + Math.random()*120000; const date = new Date(new Date().getTime()+delay); let d = [ date.getHours(), date.getMinutes(), date.getSeconds()]; d = d.map(d => { return d<10 ? "0" + d.toString() : d.toString()}); log.error('SSH: exited with ' + code + ' - will restart in ' + delay/1000 + ' seconds'); ssh_client = undefined; this.set_ssh_state( 'stopped', 'Terminated with ' + (ssh_client_laststderr ? ssh_client_laststderr : code) + ', ssh will restart at ' + d.join(':') ); setTimeout(()=>{ this.open_ssh(proxy_config) }, delay); }); /* process.on('exit', (code) => { this.set_ssh_state( 'stopping', 'alexa-fhem terminating' ); if( ssh_client ) { log.info('Killing SSH on event process.exit'); ssh_client.kill(); console.log('done!'); } }); */ } } Server.prototype.addDevice = function(device, fhem) { if( !device.isInScope('alexa.*') ) { log.info( 'ignoring '+ device.name +' for alexa' ); return; } if( device.proactiveEvents === undefined ) device.proactiveEvents = fhem.connection.proactiveEvents; device.alexaName = device.alexaName.toLowerCase().replace( /\+/g, ' ' ); //device.alexaName = device.alexaName.replace( /\+/g, ' ' ); device.alexaNames = device.alexaName; device.alexaName = device.alexaName.replace(/,.*/g,''); device.hasName = function(name) { if( this.alexaNames.match( '(^|,)('+name+')(,|\$)' ) ) return true; return this.alexaName === name; }.bind(device); this.devices[device.device.toLowerCase()] = device; for( let characteristic_type in device.mappings ){ if (characteristic_type === 'ModeController'){ (Object.values(device.mappings[characteristic_type].instance)).forEach(function (mode){ device.subscribe( mode ); }) }else{ device.subscribe( device.mappings[characteristic_type] ); } } if( device.alexaRoom ) { device.alexaRoom = device.alexaRoom.toLowerCase().replace( /\+/g, ' ' ); this.namesOfRoom = {}; this.roomsOfName = {}; for( let d in this.devices ) { var device = this.devices[d]; if( !device ) continue; var room = device.alexaRoom?device.alexaRoom:undefined; var name = device.alexaName; if( room ) { for( let r of room.split(',') ) { if( !this.namesOfRoom[r] ) this.namesOfRoom[r] = []; this.namesOfRoom[r].push( name ); } } if( !this.roomsOfName[name] ) this.roomsOfName[name] = []; this.roomsOfName[name].push( room ); } } } Server.prototype.setreading = function(reading, value) { log.info ("Reading " + reading + " set to " + value); if ( this.connections ) for( let fhem of this.connections ) { if (!fhem.alexa_device) continue; fhem.execute('setreading ' + fhem.alexa_device.Name + ' ' + reading + ' ' + value); } } Server.prototype.run = function() { log.info( 'this is alexa-fhem '+ version +(Server.as_proxy?'; as proxy':'') +(Server.use_proxy?'; use proxy':'') ); if( !this._config.connections ) { log.error( 'no connections in config file' ); process.exit( -1 ); } if( Server.use_proxy ) this.connectToProxy(); if( this._config.alexa ) { if( !Server.use_proxy || Server.as_proxy ) this.startServer(); this.roomOfIntent = {}; if( this._config.alexa.applicationId ) for( let i = 0; i < this._config.alexa.applicationId.length; ++i ) { var parts = this._config.alexa.applicationId[i].split( ':', 2 ); if( parts.length == 2 ) { this.roomOfIntent[parts[0]] = parts[1].toLowerCase(); this._config.alexa.applicationId[i] = parts[0]; } } if( this._config.alexa.oauthClientID ) for( let i = 0; i < this._config.alexa.oauthClientID.length; ++i ) { var parts = this._config.alexa.oauthClientID[i].split( ':', 2 ); if( parts.length == 2 ) { this.roomOfIntent[parts[0]] = parts[1].toLowerCase(); this._config.alexa.oauthClientID[i] = parts[0]; } } } if( !Server.as_proxy ) log.info('connecting to FHEM ...'); this.devices = {}; this.roomOfEcho = {}; this.personOfId = {}; this.connections = []; this.namesOfRoom = {}; this.roomsOfName = {}; if( !Server.as_proxy ) { let isFirstConnection = true; for (let connection of this._config.connections) { var fhem = new FHEM(Logger.withPrefix(connection.name), connection); //fhem.on( 'DEFINED', function() {log.error( 'DEFINED' )}.bind(this) ); fhem.on('customSlotTypes', function (fhem, cl) { var ret = ''; ret += 'Custom Slot Types:'; ret += '\n FHEM_Device'; var seen = {}; for (let d in this.devices) { let device = this.devices[d]; for (let name of device.alexaNames.split(',')) { if (seen[name]) continue; seen[name] = 1; ret += '\n'; ret += ' ' + name; } } for (let c of this.connections) { if (!c.alexaTypes) continue; for (let type in c.alexaTypes) { for (let name of c.alexaTypes[type]) { if (!seen[name]) ret += '\n ' + name; seen[name] = 1; } } } if (!seen['lampe']) ret += '\n lampe'; if (!seen['licht']) ret += '\n licht'; if (!seen['lampen']) ret += '\n lampen'; if (!seen['rolläden']) ret += '\n rolläden'; if (!seen['jalousien']) ret += '\n jalousien'; if (!seen['rollos']) ret += '\n rollos'; ret += '\n FHEM_Room'; for (let room in this.namesOfRoom) { ret += '\n'; ret += ' ' + room; } log.error(ret); if (cl) { fhem.execute('{asyncOutput($defs{"' + cl + '"}, "' + ret + '")}'); } }.bind(this, fhem)); fhem.on('RELOAD', function (fhem, n) { if (n) log.info('reloading ' + n + ' from ' + fhem.connection.base_url); else log.info('reloading ' + fhem.connection.base_url); for (let d in this.devices) { var device = this.devices[d]; if (!device) continue; if (n && device.name !== n) continue; if (device.fhem.connection.base_url !== fhem.connection.base_url) continue; log.info('removing ' + device.name + ' from ' + device.fhem.connection.base_url); fhem = device.fhem; device.unsubscribe(); delete this.devices[device.name]; } if (n) { fhem.connect(function (fhem, devices) { for (let device of devices) { this.addDevice(device, fhem); this.postReportOrUpdate(device); } }.bind(this, fhem), 'NAME=' + n); } else { for (let fhem of this.connections) { fhem.connect(function (fhem, devices) { for (let device of devices) { this.addDevice(device, fhem); this.postReportOrUpdate(device); } }.bind(this, fhem)); } } }.bind(this, fhem)); fhem.on('ALEXA DEVICE', function (fhem, n) { if (this._config.sshproxy) { getProxyToken(fhem); if (fhem === this.connections[0]) { if( this._config.sshproxy.ssh === '-' ) this.startProxy2(fhem, n); else this.startProxy(fhem, n); } } if (fhem.alexa_device) { function lcfirst(str) { str += ''; return str.charAt(0).toLowerCase() + str.substr(1); } function append(a, b, v) { if (a[b] === undefined) a[b] = {}; a[b][v] = true; } fhem.perfectOfVerb = {'stelle': 'gestellt', 'schalte': 'geschaltet', 'färbe': 'gefärbt', 'mach': 'gemacht'}; fhem.verbsOfIntent = []; fhem.intentsOfVerb = {} fhem.valuesOfIntent = {} fhem.intentsOfCharacteristic = {} fhem.characteristicsOfIntent = {} fhem.prefixOfIntent = {} fhem.suffixOfIntent = {} for( let characteristic in fhem.alexaMapping ) { var mappings = fhem.alexaMapping[characteristic]; if (!Array.isArray(mappings)) mappings = [mappings]; var i = 0; for (let mapping of mappings) { if( mapping.action2value ) { mappings.action2value = mapping.action2value; continue; } if( !mapping.verb ) continue; var intent = characteristic; if (mapping.valueSuffix) intent = lcfirst(mapping.valueSuffix); intent += 'Intent'; if (!mapping.valueSuffix) intent += i ? String.fromCharCode(65 + i) : ''; if (mapping.articles) mapping.articles = mapping.articles.split(';'); if (mapping.perfect) fhem.perfectOfVerb[mapping.verb] = mapping.perfect; //append(fhem.verbsOfIntent, intent, mapping.verb ); if (fhem.verbsOfIntent[intent] === undefined) { fhem.verbsOfIntent[intent] = [mapping.verb]; } else if (fhem.verbsOfIntent[intent].indexOf(mapping.verb) == -1) { fhem.verbsOfIntent[intent].push(mapping.verb); } append(fhem.intentsOfVerb, mapping.verb, intent); //append(fhem.valuesOfIntent, intent, join( ',', @{$values} ) ); append(fhem.intentsOfCharacteristic, characteristic, intent); //append(fhem.characteristicsOfIntent, intent, characteristic ); if (fhem.characteristicsOfIntent[intent] === undefined) { fhem.characteristicsOfIntent[intent] = [characteristic]; } else if (fhem.characteristicsOfIntent[intent].indexOf(characteristic) == -1) { fhem.characteristicsOfIntent[intent].push(characteristic); } fhem.prefixOfIntent[intent] = mapping.valuePrefix; fhem.suffixOfIntent[intent] = mapping.valueSuffix; ++i; } } log.debug('perfectOfVerb:'); log.debug(fhem.perfectOfVerb); log.debug('verbsOfIntent:'); log.debug(fhem.verbsOfIntent); //log.debug(fhem.intentsOfVerb); //log.debug(fhem.valuesOfIntent); //log.debug(fhem.intentsOfCharacteristic); log.debug('characteristicsOfIntent:'); log.debug(fhem.characteristicsOfIntent); log.debug('prefixOfIntent:'); log.debug(fhem.prefixOfIntent); log.debug('suffixOfIntent:'); log.debug(fhem.suffixOfIntent); } if (fhem.alexaTypes) { var types = {}; for (let type of fhem.alexaTypes.split(/ |\n/)) { if (!type) continue; if (type.match(/^#/)) continue; var match = type.match(/(^.*?)(:|=)(.*)/); if (!match || match.length < 4 || !match[3]) { log.error(' wrong syntax: ' + type); continue; } var name = match[1]; var aliases = match[3].split(/,|;/); types[name] = aliases; } fhem.alexaTypes = types; log.debug('alexaTypes:'); log.debug(fhem.alexaTypes); } if (fhem.echoRooms) { for (let line of fhem.echoRooms.split(/ |\n/)) { if (!line) continue; if (line.match(/^#/)) continue; var match = line.match(/(^.*?)(:|=)(.*)/); if (!match || match.length < 4 || !match[3]) { log.error(' wrong syntax: ' + line); continue; } var echoId = match[1]; var room = match[3]; this.roomOfEcho[echoId] = room.toLowerCase(); } log.debug('roomOfEcho:'); log.debug(this.roomOfEcho); } if (fhem.persons) { for (let line of fhem.persons.split(/ |\n/)) { if (!line) continue; if (line.match(/^#/)) continue; var match = line.match(/(^.*?)(:|=)(.*)/); if (!match || match.length < 4 || !match[3]) { log.error(' wrong syntax: ' + line); continue; } var echoId = match[1]; var person = match[3]; this.personOfId[echoId] = person.toLowerCase(); } log.debug('personOfId:'); log.debug(this.personOfId); } if (fhem.fhemIntents) { var intents = {} for (let intent of fhem.fhemIntents.split(/\n/)) { if (!intent) continue; if (intent.match(/^#/)) continue; var match = intent.match(/(^.*?)(:|=)(.*)/); if (!match || match.length < 4 || !match[3]) { this.log.error(' wrong syntax: ' + intent); continue; } var name = match[1]; var params = match[3]; var intent_name = 'FHEM' + name + 'Intent'; if (match = name.match(/^(set|get|attr)\s/)) { intent_name = 'FHEM' + match[1] + 'Intent'; var i = 1; while (intents[intent_name] !== undefined) { intent_name = 'FHEM' + match[1] + 'Intent' + String.fromCharCode(65 + i); ++i; } } else if (name.match(/^{.*}$/)) { intent_name = 'FHEMperlCodeIntent'; var i = 1; while (intents[intent_name] !== undefined) { if (i < 26) intent_name = 'FHEMperlCodeIntent' + String.fromCharCode(65 + i); else intent_name = 'FHEMperlCodeIntent' + String.fromCharCode(64 + i / 26) + String.fromCharCode(65 + i % 26); ++i; } } intent_name = intent_name.replace(/ /g, ''); intents[intent_name] = name; } fhem.fhemIntents = intents; log.debug('fhemIntents:'); log.debug(fhem.fhemIntents); } if (fhem.alexaConfirmationLevel === undefined) fhem.alexaConfirmationLevel = 2; if (fhem.alexaStatusLevel === undefined) fhem.alexaStatusLevel = 2; if( fhem.alexa_device !== undefined ) fhem.execute('list ' + fhem.alexa_device.Name + ' .eventToken', function (fhem, result) { var match; if (match = result.match(/\{.*\}$/)) { try { fhem.eventToken = JSON.parse(match[0]); fhem.log.info("got .eventToken"); event_token = fhem.eventToken; this.updateEventToken(fhem); } catch (e) { fhem.log.error("failed to parse .eventToken: " + util.inspect(e)); } } }.bind(this, fhem)); }.bind(this, fhem)); fhem.on('VALUE CHANGED', function (fhem, device, reading, value) { if( fhem.alexa_device && fhem.alexa_device.proactiveEvents === false ) { fhem.log.debug( 'proactive events are disable for this fhem connection' ); return; } //fhem.log.error( device +":"+ reading +":"+ value ); device = this.devices[device.toLowerCase()]; if( !device ) return; if( !device.proactiveEvents ) { fhem.log.debug( 'not sending proactive event for: '+ device.name +":"+ reading +":"+ value ); return; } var header = createHeader(NAMESPACE_ALEXA, REPORT_STATE) header.payloadVersion = "3"; //createDirective( header, payload, event ) var properties = propertiesFromDevice(device, device.name +'-' + reading); if( !properties.length ) { fhem.log.debug( 'not sending empty proactive event for: '+ device.name +":"+ reading +":"+ value ); return; } this.postEvent({ "event": { "header": header, "payload": { "change": { "cause": {"type": "PHYSICAL_INTERACTION"}, "properties": properties }, }, "endpoint": { "scope": { "type": "BearerToken", "token": "access-token-from-Amazon" }, "endpointId": device.uuid_base.replace(/[^\w_\-=#;:?@&]/g, '_'), } } }); }.bind(this, fhem)); fhem.on('ATTR', function (fhem, device, attribute, value) { //fhem.log.error( device +":"+ attribute +":"+ value ); device = this.devices[device.toLowerCase()]; if (!device) return; if( attribute === 'alexaProactiveEvents' ) { if( value === undefined ) delete device.proactiveEvents; else device.proactiveEvents = parseInt(value) == true; this.addDevice(device, fhem); this.postReportOrUpdate(device); } else if( attribute === 'alexaName' ) { if( value === undefined ) device.alexaName = device.alias; else device.alexaName = value; this.addDevice(device, fhem); this.postReportOrUpdate(device); } }.bind(this, fhem)); fhem.on('CONNECTED', function (f) { for (let fhem of this.connections) { if (f.connection.base_url !== fhem.connection.base_url) continue; fhem.connect(function (fhem, devices) { for (let device of devices) { this.addDevice(device, fhem); } }.bind(this, fhem)) } }.bind(this, fhem)); fhem.on('UNREGISTER SSHPROXY', function () { this.unregister_ssh(); }.bind(this)); this.connections.push(fhem); isFirstConnection = false; } // connections loop } } Server.prototype.shutdown = function() { if( this._config.sshproxy ) this.set_ssh_state( 'stopping', 'alexa-fhem terminating' ); if( ssh_client ) { log.info('Stopping SSH ...'); ssh_client.kill(); setTimeout(() => { process.exit() }, 2000); } else process.exit(); } // namespaces // https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/smart-home-skill-api-reference#payload const NAMESPACE_SmartHome_DISCOVERY = "Alexa.ConnectedHome.Discovery"; const NAMESPACE_SmartHome_SYSTEM = "Alexa.ConnectedHome.System"; const NAMESPACE_SmartHome_CONTROL = "Alexa.ConnectedHome.Control"; const NAMESPACE_SmartHome_QUERY = "Alexa.ConnectedHome.Query"; const NAMESPACE_DISCOVERY = "Alexa.Discovery"; const NAMESPACE_PowerController = "Alexa.PowerController"; const NAMESPACE_BrightnessController = "Alexa.BrightnessController"; const NAMESPACE_ColorController = "Alexa.ColorController"; const NAMESPACE_ColorTemperatureController = "Alexa.ColorTemperatureController"; const NAMESPACE_PercentageController = "Alexa.PercentageController"; const NAMESPACE_Speaker = "Alexa.Speaker"; const NAMESPACE_ThermostatController = "Alexa.ThermostatController"; const NAMESPACE_LockController = "Alexa.LockController"; const NAMESPACE_SceneController = "Alexa.SceneController"; const NAMESPACE_ChannelController = "Alexa.ChannelController"; const NAMESPACE_InputController = "Alexa.InputController"; const NAMESPACE_PlaybackController = "Alexa.PlaybackController"; const NAMESPACE_RangeController = "Alexa.RangeController"; const NAMESPACE_ModeController = "Alexa.ModeController"; const NAMESPACE_TemperatureSensor = "Alexa.TemperatureSensor"; const NAMESPACE_ContactSensor = "Alexa.ContactSensor"; const NAMESPACE_MotionSensor = "Alexa.MotionSensor"; const NAMESPACE_SecurityPanelController = "Alexa.SecurityPanelController"; const NAMESPACE_Authorization = "Alexa.Authorization"; const NAMESPACE_ALEXA = "Alexa"; // discovery const REQUEST_DISCOVER_APPLIANCES = "DiscoverAppliancesRequest"; const RESPONSE_DISCOVER_APPLIANCES = "DiscoverAppliancesResponse"; const REQUEST_DISCOVER = "Discover"; const RESPONSE_DISCOVER = "Discover.Response"; const REPORT_ADD_OR_UPDATE = "AddOrUpdateReport"; const REPORT_DELETE = "DeleteReport"; // system const REQUEST_HEALTH_CHECK = "HealthCheckRequest"; const RESPONSE_HEALTH_CHECK = "HealthCheckResponse"; // control const REQUEST_TURN_ON = "TurnOnRequest"; const RESPONSE_TURN_ON = "TurnOnConfirmation"; const REQUEST_TURN_OFF = "TurnOffRequest"; const RESPONSE_TURN_OFF = "TurnOffConfirmation"; const REQUEST_SET_PERCENTAGE = "SetPercentageRequest"; const RESPONSE_SET_PERCENTAGE = "SetPercentageConfirmation"; const REQUEST_INCREMENT_PERCENTAGE = "IncrementPercentageRequest"; const RESPONSE_INCREMENT_PERCENTAGE = "IncrementPercentageConfirmation"; const REQUEST_DECREMENT_PERCENTAGE = "DecrementPercentageRequest"; const RESPONSE_DECREMENT_PERCENTAGE = "DecrementPercentageConfirmation"; const REQUEST_SET_TARGET_TEMPERATURE = "SetTargetTemperatureRequest"; const RESPONSE_SET_TARGET_TEMPERATURE = "SetTargetTemperatureConfirmation"; const REQUEST_INCREMENT_TARGET_TEMPERATURE = "IncrementTargetTemperatureRequest"; const RESPONSE_INCREMENT_TARGET_TEMPERATURE = "IncrementTargetTemperatureConfirmation"; const REQUEST_DECREMENT_TARGET_TEMPERATURE = "DecrementTargetTemperatureRequest"; const RESPONSE_DECREMENT_TARGET_TEMPERATURE = "DecrementTargetTemperatureConfirmation"; const REQUEST_SET_LOCK_STATE = "SetLockStateRequest"; const CONFIRMATION_SET_LOCK_STATE = "SetLockStateConfirmation"; //https://developer.amazon.com/public/solutions/alexa/alexa-skills-kit/docs/smart-home-skill-api-reference#tunable-lighting-control-messages const REQUEST_SET_COLOR = "SetColorRequest"; const RESPONSE_SET_COLOR = "SetColorConfirmation"; const REQUEST_SET_COLOR_TEMPERATURE = "SetColorTemperatureRequest"; const RESPONSE_SET_COLOR_TEMPERATURE = "SetColorTemperatureConfirmation"; const REQUEST_INCREMENT_COLOR_TEMPERATURE = "IncrementColorTemperatureRequest"; const RESPONSE_INCREMENT_COLOR_TEMPERATURE = "IncrementColorTemperatureConfirmation"; const REQUEST_DECREMENT_COLOR_TEMPERATURE = "DecrementColorTemperatureRequest"; const RESPONSE_DECREMENT_COLOR_TEMPERATURE = "DecrementColorTemperatureConfirmation"; // query const REQUEST_GET_TEMPERATURE_READING = "GetTemperatureReadingRequest"; const RESPONSE_GET_TEMPERATURE_READING = "GetTemperatureReadingResponse"; const REQUEST_GET_TARGET_TEMPERATURE = "GetTargetTemperatureRequest"; const RESPONSE_GET_TARGET_TEMPERATURE = "GetTargetTemperatureResponse"; const REQUEST_GET_LOCK_STATE = "GetLockStateRequest"; const RESPONSE_GET_LOCK_STATE = "GetLockStateResponse"; //state const REQUEST_STATE = "ReportState"; const RESPONSE_STATE = "StateReport"; const REPORT_STATE = "ChangeReport"; // errors const ERROR_VALUE_OUT_OF_RANGE = "ValueOutOfRangeError";; const ERROR_UNSUPPORTED_OPERATION = "UnsupportedOperationError"; const ERROR_UNSUPPORTED_TARGET = "UnsupportedTargetError"; const ERROR_INVALID_ACCESS_TOKEN = "InvalidAccessTokenError"; const ERROR3_INVALID_AUTHORIZATION_CREDENTIAL = "INVALID_AUTHORIZATION_CREDENTIAL"; const ERROR3_BRIDGE_UNREACHABLE = "BRIDGE_UNREACHABLE"; const ERROR3_NO_SUCH_ENDPOINT = "NO_SUCH_ENDPOINT"; const ERROR3_ENDPOINT_UNREACHABLE = "ENDPOINT_UNREACHABLE"; const ERROR3_INVALID_DIRECTIVE = "INVALID_DIRECTIVE"; const ERROR3_TEMPERATURE_VALUE_OUT_OF_RANGE = "TEMPERATURE_VALUE_OUT_OF_RANGE"; const ERROR3_VALUE_OUT_OF_RANGE = "VALUE_OUT_OF_RANGE"; Server.prototype.postReportOrUpdate = function(device) { var endpoints = deviceToEndpoints(device); if( !Array.isArray(endpoints) ) return; var header = createHeader(NAMESPACE_DISCOVERY,REPORT_ADD_OR_UPDATE) header.payloadVersion = "3"; //createDirective( header, payload, event ) this.postEvent( { "event": { "header": header, "payload": { "endpoints": endpoints, "scope": { "type": "BearerToken", "token": "access-token-from-Amazon" } } } } ); } Server.prototype.postEvent = function(event) { if( !event_token || !event_token.access_token ) { log.error( 'no event token available' ); return; } if( Date.now() >= event_token.expires_in ) { this.updateEventToken(undefined, function(event) { this.postEvent(event); }.bind(this, event) ); return; } log.info( JSON.stringify(event) ); if( event && event.event && event.event.endpoint && event.event.endpoint.scope ) event.event.endpoint.scope.token = event_token.access_token; else if( event && event.event && event.event.payload && event.event.payload.endpoints && event.event.payload.endpoints[0] && event.event.payload.endpoints[0].scope ) event.event.payload.endpoints[0].scope.token = event_token.access_token; log.info( 'posting skill event' ); //var request = require('request'); //require('request-debug')(request); //request.post('https://api.eu.amazonalexa.com/v3/events', require('postman-request').post('https://api.eu.amazonalexa.com/v3/events', { headers :{ 'Content-Type': 'application/json', 'Authorization': 'Bearer '+ event_token.access_token }, //body: '{"data":{ "sampleMessage": "Sample Message"}, "expiresAfterSeconds": 60 }' }, body: JSON.stringify(event) }, function(err, httpResponse, body) { if( err ) { log.error( 'failed to post event: '+ httpResponse.statusCode + ': ' +err); return; } log.info( 'posted skill event: '+ httpResponse.statusCode + ': ' +body ); } ); } Server.prototype.refreshToken = function(token, callback) { var permissions = {}; var url = 'https://api.amazon.com/auth/O2/token'; if( !this._config.alexa || !this._config.alexa.permissions ) { if( this._config.sshproxy ) { url = 'https://va.fhem.de/proxy/oauth'; } else { log.error('no permissions in config.json'); return; } } else permissions = this._config.alexa.permissions[Object.keys(this._config.alexa.permissions)[0]]; log.info( 'refreshing token' ); //var request = require('request'); //require('request-debug')(request); //request.post(url, require('postman-request').post(url, { form :{ 'grant_type': 'refresh_token', 'refresh_token': token.refresh_token, 'client_id': permissions.client_id?permissions.client_id:'', 'client_secret': permissions.client_secret?permissions.client_secret:'' } }, function(err, httpResponse, body) { if( err ) { log.error( 'failed to refresh token: '+ err); return; } const contentType = httpResponse.headers['content-type']; if( contentType && contentType.match('application/json') ) { var json = JSON.parse(body); if( json.error ) { log.error( 'failed to refresh token: '+ json.error +': \''+ json.error_description +'\''); return; } log.info( 'got fresh token' ); json.expires_in = json.expires_in * 1000 + Date.now(); for( let key in json ) token[key] = json[key]; if( callback ) callback(token); } else { log.error( 'failed to refresh token: '+ body); return; } }.bind(this) ); } var event_token = {'expires': 0}; Server.prototype.updateEventToken = function(grant,callback) { if( Date.now() < event_token.expires_in ) { if( callback ) callback(); } else if( event_token.refresh_token ) { this.refreshToken(event_token, function(token) { event_token = token; if( callback ) callback(); }.bind(this) ); } else { if( !grant ) { log.error('no grant for event token update'); return; } var permissions = {}; var url = 'https://api.amazon.com/auth/O2/token'; if( !this._config.alexa || !this._config.alexa.permissions ) { if( this._config.sshproxy ) { url = 'https://va.fhem.de/proxy/oauth'; } else { log.error('no permissions in config.json'); return; } } else permissions = this._config.alexa.permissions[Object.keys(this._config.alexa.permissions)[0]]; log.info( 'requesting event token' ); //log.error( grant ); //var request = require('request'); //require('request-debug')(request); //request.post(url, require('postman-request').post(url, { form :{ 'grant_type': 'authorization_code', 'code': grant.grant.code, 'client_id': permissions.client_id?permissions.client_id:'', 'client_secret': permissions.client_secret?permissions.client_secret:'' } }, function(err, httpResponse, body) { if( err ) { log.error( 'failed to get event token: '+ err); return; } const contentType = httpResponse.headers['content-type']; if( contentType && contentType.match('application/json') ) { var json = JSON.parse(body); if( json.error ) { log.error( 'failed to get event token: '+ json.error +': \''+ json.error_description +'\''); return; } this.setreading( '.eventToken', body ); log.info( 'got event token' ); event_token = json; event_token.expires_in = event_token.expires_in * 1000 + Date.now(); if( callback ) callback(); } else { log.error( 'failed to get event token: '+ body); return; } }.bind(this) ); } } // Token by classic developer skill mode (created by Amazon:) var accepted_token = {'token': undefined, 'oauthClientID': undefined, 'expires': 0}; // Token in sshproxy mode, created by ourself and stored in FHEM (bearerToken): const published_tokens = []; const getProxyToken = function(fhem) { if( !fhem.alexa_device ) { log.error('No Alexa device defined - this will not work with sshproxy mode'); return; } fhem.execute( 'get ' + fhem.alexa_device.Name + ' proxyToken', function(fhem, token) { if( !token ) { log.warn('No reading "' + BEARERTOKEN_NAME + '" found in "' + fhem.alexa_device.Name + '" - incoming Cloud requests cannot be validated.'); return; } log.info("BearerToken '" + token.replace(/^.*(.....)$/, "...$1") + "' read from " + fhem.alexa_device.Name); published_tokens.push(token); }.bind(this, fhem) ); }; var verifyToken = function(sshproxy, event, callback) { var token; if( event.directive && event.directive.endpoint && event.directive.endpoint.scope && event.directive.endpoint.scope.token ) token = event.directive.endpoint.scope.token; else if( event.directive && event.directive.payload && event.directive.payload.scope && event.directive.payload.scope.token ) token = event.directive.payload.scope.token; else if( event.context && event.context.System && event.context.System.user && event.context.System.user.accessToken ) token = event.context.System.user.accessToken; else if( event.session && event.session.user && event.session.user.accessToken ) token = event.session.user.accessToken; else if( event.payload ) token = event.payload.accessToken; else token = undefined; // Token w/o routing ID in case of sshproxy mode let ptoken = undefined; if( sshproxy && token && token.lastIndexOf('-')>=0 ) ptoken = token.substring(token.lastIndexOf('-')+1); if( event.directive && event.directive.header && event.directive.header.namespace === NAMESPACE_Authorization && event.directive.header.name === "AcceptGrant" ) { handler.bind(this)( event, callback ); // Trust the token either: // 1) This token is in the array of bearerToken(s) we read from FHEM (instances) - sshproxy mode. } else if( sshproxy && ptoken && published_tokens.indexOf(ptoken)>=0 ) { handler.bind(this)( event, callback ); // 2) If we validated it via Amazon OAuth (cla