alexa-fhem
Version:
a fhem skill for amazon alexa
1,451 lines (1,186 loc) • 183 kB
JavaScript
'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