homebridge-fhem
Version:
a fhem platform plugin for homebridge
1,491 lines (1,206 loc) • 130 kB
JavaScript
// FHEM Platform Plugin for HomeBridge
// current version on https://github.com/justme-1968/homebridge
//
// Remember to add platform to config.json. Example:
// "platforms": [
// {
// "platform": "FHEM",
// "name": "FHEM",
// "ssl": true,
// "auth": {"user": "fhem", "pass": "fhempassword"},
// "server": "127.0.0.1",
// "port": 8083,
// "webname": fhem,
// "jsFunctions": "myFunctions",
// "filter": "room=xyz"
// }
// ],
'use strict';
var version = require('./lib/version');
//var FHEM = require('./lib/fhem').FHEM;
function getLine(offset) {
var stack = new Error().stack.split('\n'),
line = stack[(offset || 1) + 1].split(':');
return parseInt(line[line.length - 2], 10);
}
global.__defineGetter__('__LINE__', function () {
return getLine(2);
});
var User;
var Accessory, Service, Characteristic, Units, Formats, Perms, UUIDGen, FakeGatoHistoryService;
module.exports = function(homebridge){
console.log('homebridge API version: ' + homebridge.version);
console.info( 'this is homebridge-fhem '+ version );
//console.log( homebridge );
//process.exit(0);
User = homebridge.user;
//Accessory = homebridge.platformAccessory;
Accessory = homebridge.hap.Accessory;
Service = homebridge.hap.Service;
Characteristic = homebridge.hap.Characteristic;
UUIDGen = homebridge.hap.uuid;
try {
FakeGatoHistoryService = require('fakegato-history')(homebridge);
} catch(e) {
if (e.code !== 'MODULE_NOT_FOUND') {
throw e;
}
console.log( 'error: fakegato-history not installed' );
}
homebridge.registerPlatform('homebridge-fhem', 'FHEM', FHEMPlatform);
}
var util = require('util');
var events = require('events');
// subscriptions to fhem longpoll events
var FHEM_subscriptions = {};
function
FHEM_subscribe(accessory, informId, characteristic, mapping) {
if( !FHEM_subscriptions[informId] )
FHEM_subscriptions[informId] = [];
FHEM_subscriptions[informId].push( { accessory: accessory, characteristic: characteristic, mapping: mapping } );
}
function
FHEM_unsubscribe(accessory, informId, characteristic) {
var subscriptions = FHEM_subscriptions[informId];
if( subscriptions ) {
for( var i = 0; i < subscriptions.length; ++i ) {
var subscription = subscriptions[i];
if( subscription.accessory !== accessory )
continue;
if( subscription.characteristic !== characteristic )
continue;
delete subscriptions[i];
}
FHEM_subscriptions[informId] = subscriptions.filter( function(n){ return n !== undefined } );
if( !FHEM_subscriptions[informId].length )
delete FHEM_subscriptions[informId] ;
}
}
function
FHEM_isPublished(device) {
for( var inform_id in FHEM_subscriptions ) {
for( var subscription of FHEM_subscriptions[inform_id] ) {
var accessory = subscription.accessory;
if( accessory.device === device )
return accessory;
}
}
return null;
}
var tzoffset = (new Date()).getTimezoneOffset() * 60000; //offset in milliseconds
// cached readings from longpoll & query
var FHEM_cached = {};
function
FHEM_update(informId, orig, no_update) {
if( orig === undefined
|| FHEM_cached[informId] === orig )
return;
FHEM_cached[informId] = orig;
//FHEM_cached[informId] = { orig: orig, timestamp: Date.now() };
var date = new Date(Date.now()-tzoffset).toISOString().replace(/T/, ' ').replace(/\..+/, '');
console.log(' ' + date + ' caching: ' + informId + ': ' + orig );
var subscriptions = FHEM_subscriptions[informId];
if( subscriptions )
subscriptions.forEach( function(subscription) {
var mapping = subscription.mapping;
if( typeof mapping !== 'object' )
return;
mapping.last_update = parseInt( Date.now()/1000 );
var value = FHEM_reading2homekit(mapping, orig);
if( value === undefined )
return;
var accessory = subscription.accessory;
if( accessory && accessory.historyService ) {
var historyService = accessory.historyService;
var extra_persist = {};
if( historyService.isHistoryLoaded() ) {
extra_persist = historyService.getExtraPersistedData();
}
historyService.extra_persist = extra_persist;
if( mapping.name === 'Custom TimesOpened' )
historyService.extra_persist.TimesOpened = value;
else if( mapping.name === 'Custom LastActivation' )
historyService.extra_persist.LastActivation = value;
else {
var entry = { time: mapping.last_update };
if( mapping.characteristic_type === 'ContactSensorState' ) {
entry.status = value;
if( value === Characteristic.ContactSensorState.CONTACT_NOT_DETECTED ) {
FHEM_update( mapping.device + '-EVE-TimesOpened', ++historyService.extra_persist.TimesOpened );
}
//var time = mapping.last_update - historyService.getInitialTime();
//accessory.mappings['E863F11A-079E-48FF-8F27-9C2605A29F52'].characteristic.setValue(time, undefined, 'fromFHEM');
} else if( mapping.characteristic_type === 'MotionDetected' )
entry.status = value?1:0;
else if( mapping.characteristic_type === 'CurrentTemperature'
|| mapping.characteristic_type === 'TargetTemperature'
|| mapping.characteristic_type === CustomUUIDs.Actuation ) {
var current;
if( accessory.mappings.CurrentTemperature )
current = accessory.mappings.CurrentTemperature.cached;
if( accessory.mappings.TargetTemperature ) {
var target = accessory.mappings.TargetTemperature.cached;
if( target !== undefined )
entry.setTemp = target;
if( current !== undefined )
entry.currentTemp = current;
} else
if( current !== undefined )
entry.temp = current;
if( accessory.mappings[CustomUUIDs.Actuation] ) {
var valve = accessory.mappings[CustomUUIDs.Actuation].cached;
if( valve !== undefined )
entry.valvePosition = valve;
}
} else if( mapping.characteristic_type === 'CurrentRelativeHumidity' )
entry.humidity = value;
else if( mapping.characteristic_type === 'AirQuality' )
entry.ppm = value;
else if( mapping.characteristic_type === CustomUUIDs.AirPressure )
entry.pressure = value;
else if( mapping.characteristic_type === CustomUUIDs.Power )
entry.power = value;
else
entry = undefined;
if( entry !== undefined ) {
mapping.log.info( ' adding history entry '+ util.inspect(entry) );
historyService.addEntry(entry);
}
}
}
if( !no_update && mapping.characteristic )
mapping.characteristic.setValue(value, undefined, 'fromFHEM');
} );
}
function
FHEM_reading2homekit(mapping, orig)
{
var value = undefined;
if( typeof mapping.reading2homekit === 'function' ) {
try {
value = mapping.reading2homekit(orig);
} catch(err) {
mapping.log.error( mapping.informId + ' reading2homekit: ' + err );
return undefined;
}
if( typeof value === 'number' && isNaN(value) ) {
mapping.log.error(mapping.informId + ' not a number: ' + orig);
return undefined;
}
} else {
value = FHEM_reading2homekit_(mapping, orig);
}
if( value === undefined ) {
if( mapping.default !== undefined ) {
orig = 'mapping.default';
value = mapping.default;
} else
return undefined;
}
if( 0 && typeof value === 'string' ) { //FIXME: activate this ?
if( Characteristic[mapping.characteristic_type] && Characteristic[mapping.characteristic_type][value] !== undefined ) {
if( mapping.homekit2name === undefined ) mapping.homekit2name = {};
mapping.homekit2name[Characteristic[mapping.characteristic_type][value]] = value;
value = Characteristic[mapping.characteristic_type][value];
}
}
var defined = undefined;
if( mapping.homekit2name !== undefined ) {
defined = mapping.homekit2name[value];
if( defined === undefined )
defined = '???';
}
mapping.log.info(' caching: ' + (mapping.name?mapping.name:mapping.characteristic_type) +': '
+ value + ' (' + 'as '+typeof(value) + (defined?'; means '+defined:'') + '; from \''+orig + '\')');
mapping.cached = value;
return value;
}
function
FHEM_reading2homekit_(mapping, orig)
{
var value = orig;
if( value === undefined )
return undefined;
var reading = mapping.reading;
if( reading === undefined )
return orig;
if( reading == 'temperature'
|| reading == 'measured'
|| reading == 'measured-temp'
|| reading == 'desired-temp'
|| reading == 'desired'
|| reading == 'desiredTemperature' ) {
if( typeof value === 'string' && value.toLowerCase() == 'on' )
value = 31.0;
else if( typeof value === 'string' && value.toLowerCase() == 'off' )
value = 4.0;
else
value = parseFloat( value );
if( mapping.minValue !== undefined && value < mapping.minValue )
value = mapping.minValue;
else if( mapping.maxValue !== undefined && value > mapping.maxValue )
value = mapping.maxValue;
if( mapping.minStep ) {
if( mapping.minValue )
value -= mapping.minValue;
value = parseFloat( (Math.round(value / mapping.minStep) * mapping.minStep).toFixed(1) );
if( mapping.minValue )
value += mapping.minValue;
}
} else if( reading == 'humidity' ) {
value = parseInt( value );
} else if( reading == 'onoff' ) {
value = parseInt( value );
} else if( reading == 'reachable' ) {
value = parseInt( value ) == true;
} else if( reading === 'state' && ( mapping.On
&& typeof mapping.values !== 'object'
&& mapping.reading2homekit === undefined
&& mapping.valueOn === undefined && mapping.valueOff === undefined ) ) {
if( value.match(/^set-/ ) )
return undefined;
if( value.match(/^set_/ ) )
return undefined;
if( mapping.event_map !== undefined ) {
var mapped = mapping.event_map[value];
if( mapped !== undefined )
value = mapped;
}
if( value.toLowerCase() == 'off' )
value = 0;
else if( value == '000000' )
value = 0;
else if( value.match( /^[A-D]0$/ ) )
value = 0;
else
value = 1;
} else {
if( value.match(/^set-/ ) )
return undefined;
else if( value.match(/^set_/ ) )
return undefined;
var orig = value;
var format = undefined;
if( typeof mapping.characteristic === 'object' )
format = mapping.characteristic.props.format;
else if( typeof mapping.characteristic === 'function' ) {
var characteristic = new (Function.prototype.bind.apply(mapping.characteristic, arguments));
format = characteristic.props.format;
//delete characteristic;
} else if( mapping.format ) { // only for testing !
format = mapping.format;
}
if( mapping.event_map !== undefined ) {
var mapped = mapping.event_map[value];
if( mapped !== undefined ) {
mapping.log.debug(mapping.informId + ' eventMap: value ' + value + ' mapped to: ' + mapped);
value = mapped;
}
}
if( value !== undefined && mapping.part !== undefined ) {
var mapped = value.split(' ')[mapping.part];
if( mapped === undefined ) {
mapping.log.error(mapping.informId + ' value ' + value + ' has no part ' + mapping.part);
return value;
}
mapping.log.debug(mapping.informId + ' parts: using part ' + mapping.part + ' of: ' + value + ' results in: ' + mapped);
value = mapped;
}
if( mapping.threshold ) {
//if( !format.match( /bool/i ) && mapping.threshold ) {
var mapped;
if( parseFloat(value) > mapping.threshold )
mapped = 1;
else
mapped = 0;
mapping.log.debug(mapping.informId + ' threshold: value ' + value + ' mapped to ' + mapped);
value = mapped;
}
if( typeof mapping.value2homekit_re === 'object' || typeof mapping.value2homekit === 'object' ) {
var mapped = undefined;
if( typeof mapping.value2homekit_re === 'object' )
for( var entry of mapping.value2homekit_re ) {
if( value && value.match( entry.re ) ) {
mapped = entry.to;
break;
}
}
if( mapped === '#' )
mapped = value;
if( typeof mapping.value2homekit === 'object' )
if( mapping.value2homekit[value] !== undefined )
mapped = mapping.value2homekit[value];
if( mapped === undefined )
mapped = mapping.default;
if( mapped === undefined ) {
mapping.log.error(mapping.informId + ' value ' + value + ' not handled in values');
return undefined;
}
mapping.log.debug(mapping.informId + ' values: value ' + value + ' mapped to ' + mapped);
value = mapped;
}
if( format === undefined )
return value;
//mapping.log.error( format );
if( !format ) {
mapping.log.error(mapping.informId + ' empty format' );
} else if( format.match( /bool/i ) ) {
var mapped = undefined;;
if( mapping.valueOn !== undefined ) {
var match = mapping.valueOn.match('^/(.*)/$');
if( !match && value == mapping.valueOn )
mapped = 1;
else if( match && value.toString().match( match[1] ) )
mapped = 1;
else
mapped = 0;
}
if( mapping.valueOff !== undefined ) {
var match = mapping.valueOff.match('^/(.*)/$');
if( !match && value == mapping.valueOff )
mapped = 0;
else if( match && value.toString().match( match[1] ) )
mapped = 0;
else if( mapped === undefined )
mapped = 1;
}
if( mapping.valueOn === undefined && mapping.valueOff === undefined ) {
if( typeof value === 'string' && value.toLowerCase() == 'on' )
mapped = 1;
else if( typeof value === 'string' && value.toLowerCase() == 'off' )
mapped = 0;
else
mapped = parseInt(value)?1:0;
}
if( mapped !== undefined ) {
mapping.log.debug(mapping.informId + ' valueOn/valueOff: value ' + value + ' mapped to ' + mapped);
value = mapped;
}
if( mapping.factor ) {
mapping.log.debug(mapping.informId + ' factor: value ' + value + ' mapped to ' + value * mapping.factor);
value *= mapping.factor;
}
if( mapping.invert ) {
mapping.minValue = 0;
mapping.maxValue = 1;
}
} else if( format.match( /float/i ) ) {
var mapped = parseFloat( value );
if( typeof mapped !== 'number' ) {
mapping.log.error(mapping.informId + ' is not a number: ' + value);
return undefined;
}
value = mapped;
if( mapping.factor ) {
mapping.log.debug(mapping.informId + ' factor: value ' + value + ' mapped to ' + value * mapping.factor);
value *= mapping.factor;
}
} else if( format.match(/int/i) ) {
var mapped = parseFloat( value );
if( typeof mapped !== 'number' ) {
mapping.log.error(mapping.informId + ' not a number: ' + value);
return undefined;
}
value = mapped;
if( mapping.factor ) {
mapping.log.debug(mapping.informId + ' factor: value ' + value + ' mapped to ' + value * mapping.factor);
value *= mapping.factor;
}
value = parseInt( value + 0.5 );
} else if( format.match( /string/i ) ) {
}
if( mapping.max && mapping.maxValue ) {
value = Math.round(value * mapping.maxValue / mapping.max );
mapping.log.debug(mapping.informId + ' value ' + orig + ' scaled to: ' + value);
}
if( mapping.minValue !== undefined && value < mapping.minValue ) {
mapping.log.debug(mapping.informId + ' value ' + value + ' clipped to minValue: ' + mapping.minValue);
value = mapping.minValue;
} else if( mapping.maxValue !== undefined && value > mapping.maxValue ) {
mapping.log.debug(mapping.informId + ' value ' + value + ' clipped to maxValue: ' + mapping.maxValue);
value = mapping.maxValue;
}
if( mapping.minStep ) {
if( mapping.minValue )
value -= mapping.minValue;
value = parseFloat( (Math.round(value / mapping.minStep) * mapping.minStep).toFixed(1) );
if( mapping.minValue )
value += mapping.minValue;
}
if( format && format.match(/int/i) )
value = parseInt( value );
else if( format && format.match(/float/i) )
value = parseFloat( value );
if( typeof value === 'number' ) {
var mapped = value;
if( isNaN(value) ) {
mapping.log.error(mapping.informId + ' not a number: ' + orig);
return undefined;
} else if( mapping.invert && mapping.minValue !== undefined && mapping.maxValue !== undefined ) {
mapped = mapping.maxValue - value + mapping.minValue;
} else if( mapping.invert && mapping.maxValue !== undefined ) {
mapped = mapping.maxValue - value;
} else if( mapping.invert ) {
mapped = 100 - value;
}
if( value !== mapped )
mapping.log.debug(mapping.informId + ' value: ' + value + ' inverted to ' + mapped);
value = mapped;
}
if( format && format.match( /bool/i ) )
value = parseInt(value)?true:false;
}
return(value);
}
var FHEM_longpoll = {};
var FHEM_csrfToken = {};
//FIXME: add filter
function FHEM_startLongpoll(connection) {
if( !FHEM_longpoll[connection.base_url] ) {
FHEM_longpoll[connection.base_url] = {};
FHEM_longpoll[connection.base_url].connects = 0;
FHEM_longpoll[connection.base_url].disconnects = 0;
FHEM_longpoll[connection.base_url].received_total = 0;
}
if( FHEM_longpoll[connection.base_url].connected )
return;
FHEM_longpoll[connection.base_url].connects++;
FHEM_longpoll[connection.base_url].received = 0;
FHEM_longpoll[connection.base_url].connected = true;
var filter = '.*';
var since = 'null';
if( FHEM_longpoll[connection.base_url].last_event_time )
since = FHEM_longpoll[connection.base_url].last_event_time/1000;
var query = '?XHR=1'
+ '&inform=type=status;addglobal=1;filter='+filter+';since='+since+';fmt=JSON'
+ '×tamp='+Date.now();
var url = encodeURI( connection.base_url + query );
console.log( 'starting longpoll: ' + url );
var FHEM_longpollOffset = 0;
var input = '';
connection.request.get( { url: url } ).on( 'data', function(data) {
//console.log( 'data: ' + data );
if( !data )
return;
var length = data.length;
FHEM_longpoll[connection.base_url].received += length;
FHEM_longpoll[connection.base_url].received_total += length;
input += data;
try {
var lastEventTime = Date.now();
for(;;) {
var nOff = input.indexOf('\n', FHEM_longpollOffset);
if(nOff < 0)
break;
var l = input.substr(FHEM_longpollOffset, nOff-FHEM_longpollOffset);
FHEM_longpollOffset = nOff+1;
//console.log( 'Rcvd: ' + (l.length>132 ? l.substring(0,132)+'...('+l.length+')':l) );
if(!l.length)
continue;
var d;
if( l.substr(0,1) == '[' ) {
try {
d = JSON.parse(l);
} catch(err) {
console.log( ' longpoll JSON.parse: ' + err );
continue;
}
} else
d = l.split('<<', 3);
//console.log(d);
if(d.length != 3)
continue;
if(d[0].match(/-ts$/))
continue;
if(d[0].match(/^#FHEMWEB:/))
continue;
var match = d[0].match(/([^-]*)-(.*)/);
if( !match )
continue;
var device = match[1];
var reading = match[2];
//console.log( 'device: ' + device );
//console.log( 'reading: ' + reading );
if( reading === undefined )
continue;
var value = d[1];
//console.log( 'value: ' + value );
if( value.match( /^set-/ ) )
continue;
if( 0 && device == 'global' ) {
if( reading == 'DEFINED' ) {
console.log( 'DEFINED: ' + value );
console.log( connection.base_url );
FHEM_platforms.forEach( function(platform) {
platform.log( platform.connection.base_url );
if( connection.base_url == platform.connection.base_url ) {
platform.log( platform.filter );
platform.accessories( function(accessories) {
accessories.forEach( function(accessory) {
if( platform.addBridgedAccessory )
platform.addBridgedAccessory(accessory);
} );
} );
}
} );
} else if( reading == 'DELETED' ) {
console.log( 'DELETED: ' + value );
var accessory = FHEM_isPublished(value);
//if( accessory && typeof accessory.updateReachability === 'function' )
//accessory.updateReachability( false );
} else if( reading == 'ATTR' ) {
console.log( 'ATTR: ' + value );
var values = value.split( ' ' );
var accessory = FHEM_isPublished(values[0]);
if( accessory && values[1] == 'disable' )
FHEM_update(values[0] + '-reachable', !values[2] );
else if( values[1] == 'genericDeviceType' || values[1] =='homebridgeMapping' ) {
console.log( connection.base_url );
FHEM_platforms.forEach( function(platform) {
platform.log( platform.connection.base_url );
if( connection.base_url == platform.connection.base_url ) {
platform.log( platform.filter );
platform.accessories( function(accessories) {
accessories.forEach( function(accessory) {
if( platform.addBridgedAccessory )
platform.addBridgedAccessory(accessory);
} );
} );
}
} );
}
} else if( reading == 'DELETEATTR' ) {
console.log( 'DELETEATTR: ' + value );
var values = value.split( ' ' );
var accessory = FHEM_isPublished(values[0]);
if( accessory && values[1] == 'disable' )
FHEM_update(values[0] + '-reachable', !values[2] );
}
continue;
}
var subscriptions = FHEM_subscriptions[d[0]];
if( subscriptions ) {
FHEM_update( d[0], value );
FHEM_longpoll[connection.base_url].last_event_time = lastEventTime;
subscriptions.forEach( function(subscription) {
var accessory = subscription.accessory;
if(accessory.mappings.colormode) {
if( reading == 'xy') {
var xy = value.split(',');
var rgb = FHEM_xyY2rgb(xy[0], xy[1] , 1);
var hsv = FHEM_rgb2hsv(rgb);
FHEM_update( device+'-h', hsv[0] );
FHEM_update( device+'-s', hsv[1] );
FHEM_update( device+'-v', hsv[2] );
FHEM_update( device+'-'+reading, value, false );
return;
} else if( reading == 'ct') {
var rgb = FHEM_ct2rgb(value);
var hsv = FHEM_rgb2hsv(rgb);
FHEM_update( device+'-h', hsv[0] );
FHEM_update( device+'-s', hsv[1] );
FHEM_update( device+'-v', hsv[2] );
FHEM_update( device+'-'+reading, value, false );
return;
}
}
} );
}
}
} catch(err) {
connection.log.error( ' error in longpoll connection: ' + err );
}
input = input.substr(FHEM_longpollOffset);
FHEM_longpollOffset = 0;
if( FHEM_csrfToken[connection.base_url] )
FHEM_longpoll[connection.base_url].disconnects = 0;
} ).on( 'response', function(response) {
if( response.headers && response.headers['x-fhem-csrftoken'] )
FHEM_csrfToken[connection.base_url] = response.headers['x-fhem-csrftoken'];
else
FHEM_csrfToken[connection.base_url] = '';
connection.fhem.checkAndSetGenericDeviceType();
} ).on( 'end', function() {
FHEM_longpoll[connection.base_url].connected = false;
FHEM_longpoll[connection.base_url].disconnects++;
var timeout = 500 * FHEM_longpoll[connection.base_url].disconnects - 300;
if( timeout > 30000 ) timeout = 30000;
connection.log.error( 'longpoll ended, reconnect in: ' + timeout + 'msec' );
setTimeout( function(){FHEM_startLongpoll(connection)}, timeout );
} ).on( 'error', function(err) {
FHEM_longpoll[connection.base_url].connected = false;
FHEM_longpoll[connection.base_url].disconnects++;
var timeout = 3000 * FHEM_longpoll[connection.base_url].disconnects;
if( timeout > 30000 ) timeout = 30000;
if( !connection.neverTimeout && timeout > 10000 && !FHEM_csrfToken[connection.base_url] ) {
connection.log.error( 'longpoll error: ' + err + ', retrys exhausted' );
connection.dead = true;
return;
}
connection.log.error( 'longpoll error: ' + err + ', retry in: ' + timeout + 'msec' );
setTimeout( function(){FHEM_startLongpoll(connection)}, timeout );
} );
}
var FHEM_platforms = [];
function
FHEMPlatform(log, config, api) {
events.EventEmitter.call(this);
Units = api.hap.Units;
Formats = api.hap.Formats;
Perms = api.hap.Perms;
this.log = log;
this.config = config;
if( api ) {
this.api = api;
//this.api.on('didFinishLaunching', this.didFinishLaunching.bind(this));
this.api.on('shutdown', this.shutdown.bind(this));
}
//this.server = config['server'] || '127.0.0.1';
this.server = config['server'];
this.port = config['port'] || 8083;
this.filter = config['filter'];
this.jsFunctions = config['jsFunctions'];
this.scope = config['scope'];
if( this.server === undefined ) {
log.error( 'incomplete configuration ' );
return;
}
if( this.jsFunctions !== undefined ) {
try {
var path = this.jsFunctions;
if( path.substr(0,1) != '/' )
path = User.storagePath()+'/'+this.jsFunctions;
this.jsFunctions = require(path);
} catch(err) {
log.error( ' jsFunctions: ' + err );
delete this.jsFunctions;
}
}
var base_url = 'http://';
if( config.ssl ) {
if( typeof config.ssl !== 'boolean' ) {
this.log.error( 'config: value for ssl has to be boolean.' );
process.exit(0);
}
base_url = 'https://';
}
base_url += this.server + ':' + this.port;
if( config.webname ) {
base_url += '/'+config.webname;
} else {
base_url += '/fhem';
}
var request = require('postman-request');
var auth = config['auth'];
if( auth ) {
if( auth.sendImmediately === undefined )
auth.sendImmediately = false;
request = request.defaults( { auth: auth, rejectUnauthorized: false } );
}
this.connection = { base_url: base_url, request: request, log: log, fhem: this, neverTimeout: config['neverTimeout'] };
FHEM_platforms.push(this);
FHEM_startLongpoll( this.connection );
}
util.inherits(FHEMPlatform, events.EventEmitter);
function
FHEM_sortByKey(array, key) {
return array.sort( function(a, b) {
var x = a[key]; var y = b[key];
return ((x < y) ? -1 : ((x > y) ? 1 : 0));
});
}
function
FHEM_rgb2hex(r,g,b) {
if( g === undefined )
return Number(0x1000000 + r[0]*0x10000 + r[1]*0x100 + r[2]).toString(16).substring(1);
return Number(0x1000000 + r*0x10000 + g*0x100 + b).toString(16).substring(1);
}
function
FHEM_hsv2rgb(h,s,v) {
var r = 0.0;
var g = 0.0;
var b = 0.0;
if( s == 0 ) {
r = v;
g = v;
b = v;
} else {
var i = Math.floor( h * 6.0 );
var f = ( h * 6.0 ) - i;
var p = v * ( 1.0 - s );
var q = v * ( 1.0 - s * f );
var t = v * ( 1.0 - s * ( 1.0 - f ) );
i = i % 6;
if( i == 0 ) {
r = v;
g = t;
b = p;
} else if( i == 1 ) {
r = q;
g = v;
b = p;
} else if( i == 2 ) {
r = p;
g = v;
b = t;
} else if( i == 3 ) {
r = p;
g = q;
b = v;
} else if( i == 4 ) {
r = t;
g = p;
b = v;
} else if( i == 5 ) {
r = v;
g = p;
b = q;
}
}
return FHEM_rgb2hex( Math.round(r*255),Math.round(g*255),Math.round(b*255) );
}
function
FHEM_ct2rgb(ct) {
// calculation from http://www.tannerhelland.com/4435/convert-temperature-rgb-algorithm-code
// kelvin -> mired
if( ct > 1000 )
ct = 1000000/ct;
// adjusted by 1000K
var temp = (1000000/ct)/100 + 10;
var r = 0;
var g = 0;
var b = 0;
r = 255;
if( temp > 66 )
r = 329.698727446 * Math.pow(temp - 60, -0.1332047592);
if( r < 0 )
r = 0;
if( r > 255 )
r = 255;
if( temp <= 66 )
g = 99.4708025861 * Math.log(temp) - 161.1195681661;
else
g = 288.1221695283 * Math.pow(temp - 60, -0.0755148492);
if( g < 0 )
g = 0;
if( g > 255 );
g = 255;
b = 255;
if( temp <= 19 )
b = 0;
if( temp < 66 )
b = 138.5177312231 * log(temp-10) - 305.0447927307;
if( b < 0 )
b = 0;
if( b > 255 )
b = 255;
return FHEM_rgb2hex( Math.round(r),Math.round(g),Math.round(b) );
}
function
FHEM_xyY2rgb(x,y,Y) {
// calculation from http://www.brucelindbloom.com/index.html
var r = 0;
var g = 0;
var b = 0;
if( y > 0 ) {
var X = x * Y / y;
var Z = (1 - x - y)*Y / y;
if( X > 1
|| Y > 1
|| Z > 1 ) {
var f = Math.max(X,Y,Z);
X /= f;
Y /= f;
Z /= f;
}
r = 0.7982 * X + 0.3389 * Y - 0.1371 * Z;
g = -0.5918 * X + 1.5512 * Y + 0.0406 * Z;
b = 0.0008 * X + 0.0239 * Y + 0.9753 * Z;
if( r > 1
|| g > 1
|| b > 1 ) {
var f = Math.max(r,g,b);
r /= f;
g /= f;
b /= f;
}
}
return FHEM_rgb2hex( Math.round(r*255),Math.round(g*255),Math.round(b*255) );
}
function
FHEM_rgb2hsv(r,g,b) {
if( r === undefined )
return;
if( g === undefined ) {
var str = r;
r = parseInt( str.substr(0,2), 16 );
g = parseInt( str.substr(2,2), 16 );
b = parseInt( str.substr(4,2), 16 );
r /= 255;
g /= 255;
b /= 255;
}
var M = Math.max( r, g, b );
var m = Math.min( r, g, b );
var c = M - m;
var h, s, v;
if( c == 0 ) {
h = 0;
} else if( M == r ) {
h = ( ( 360 + 60 * ( ( g - b ) / c ) ) % 360 ) / 360;
} else if( M == g ) {
h = ( 60 * ( ( b - r ) / c ) + 120 ) / 360;
} else if( M == b ) {
h = ( 60 * ( ( r - g ) / c ) + 240 ) / 360;
}
if( M == 0 ) {
s = 0;
} else {
s = c / M;
}
v = M;
return [h,s,v];
}
function
FHEM_execute(log,connection,cmd,callback) {
if( FHEM_csrfToken[connection.base_url] )
cmd += '&fwcsrf='+FHEM_csrfToken[connection.base_url];
cmd += '&XHR=1';
var url = encodeURI( connection.base_url + '?cmd=' + cmd );
log.info( ' executing: ' + url );
connection.request
.get( { url: url, gzip: true },
function(err, response, result) {
if( !err && response.statusCode == 200 ) {
result = result.replace(/[\r\n]/g, '');
if( callback )
callback( result );
} else {
log('There was a problem connecting to FHEM ('+ url +').');
if( response )
log( ' ' + response.statusCode + ': ' + response.statusMessage );
}
} )
.on( 'error', function(err) { log('There was a problem connecting to FHEM ('+ url +'):'+ err); } );
}
FHEMPlatform.prototype = {
didFinishLaunching: function() { this.log.error('didFinishLaunching')
},
shutdown: function() { //this.log.error('shutdown')
for( var informId in FHEM_subscriptions ) {
for( var subscription of FHEM_subscriptions[informId] ) {
var accessory = subscription.accessory;
if( accessory.shutdown ) continue;
if( accessory.historyService )
accessory.historyService.save();
accessory.shutdown = true;
}
}
},
execute: function(cmd,callback) {FHEM_execute(this.log, this.connection, cmd, callback)},
checkAndSetGenericDeviceType: function() {
this.log('Checking devices and attributes...');
var cmd = '{AttrVal("global","userattr","")}';
this.execute( cmd,
function(result) {
//if( result === undefined )
//result = '';
if( !result.match(/(^| )homebridgeMapping\b/) ) {
var cmd = '{ addToAttrList( "homebridgeMapping:textField-long" ) }';
this.execute( cmd );
this.log.info( 'homebridgeMapping attribute created.' );
}
if( !result.match(/(^| )genericDeviceType\b/) ) {
var cmd = '{addToAttrList( "genericDeviceType:security,ignore,switch,outlet,light,blind,thermometer,thermostat,contact,garage,window,lock" ) }';
this.execute( cmd,
function(result) {
this.log.warn( 'genericDeviceType attribute was not known. please restart.' );
if( FHEM_csrfToken[this.connection.base_url] )
process.exit(0);
}.bind(this) );
}
}.bind(this) );
if( !this.siri_device );
this.execute( 'jsonlist2 TYPE=siri',
function(result) {
try {
var d = JSON.parse( result );
if( d.totalResultsReturned === 1 ) {
this.siri_device = d.Results[0].Name;
this.log.info( 'siri device is ' + this.siri_device );
this.execute( '{$defs{'+ this.siri_device +'}->{"homebridge-fhem version"} = "'+ version +'"}' );
} else
this.log.warn( 'no siri device found. please define it.' );
} catch(err) {
this.log.error( 'failed to parse ' + result );
}
}.bind(this) );
},
accessories: function(callback) {
var foundAccessories = [];
//this.checkAndSetGenericDeviceType();
if( !this.connection ) {
callback(foundAccessories);
return;
}
// mechanism to ensure callback is only executed once all requests complete
var asyncCalls = 0;
function callbackLater() { if (--asyncCalls == 0) callback(foundAccessories); }
if( FHEM_csrfToken[this.connection.base_url] === undefined ) {
if( this.connection.dead ) {
callback(foundAccessories);
return;
}
var timeout = 500;
if( FHEM_longpoll[this.connection.base_url].disconnects )
timeout = FHEM_longpoll[this.connection.base_url].disconnects * 1000;
this.log.debug('FHEM csrfToken missing, retry in: ' + timeout + 'msec');
setTimeout( function(){this.connection.fhem.accessories(callback)}.bind(this), timeout );
return;
}
this.log.info('Fetching FHEM devices...');
var cmd = 'jsonlist2';
if( this.filter )
cmd += '%20' + encodeURIComponent(this.filter);
if( FHEM_csrfToken[this.connection.base_url] )
cmd += '&fwcsrf='+FHEM_csrfToken[this.connection.base_url];
var url = this.connection.base_url + '?cmd=' + cmd + '&XHR=1';
this.log.info( 'fetching: ' + url );
asyncCalls++;
this.connection.request.get( { url: url, json: true, gzip: true },
function(err, response, json) {
if( !err && response.statusCode == 200 && json ) {
//console.log("got json: " + util.inspect(json) );
this.log.info( 'got: ' + json['totalResultsReturned'] + ' results' );
if( json['totalResultsReturned'] ) {
var sArray=FHEM_sortByKey(json['Results'],'Name');
sArray.map( function(s) {
//FIXME: change to factory pattern
var accessory = new FHEMAccessory(this, s);
if( accessory && accessory.service_name ) {
accessory.fhem = this.connection.fhem;
if( !accessory.isInScope('siri') ) {
this.log.info( 'ignoring '+ accessory.name +' for siri' );
return;
}
foundAccessories.push(accessory);
} else {
this.log.info( 'no accessory created for ' + s.Internals.NAME + ' (' + s.Internals.TYPE + ')' );
return undefined;
}
}.bind(this) );
}
callback(foundAccessories);
//callbackLater();
} else {
this.log.error('There was a problem connecting to FHEM');
if( response )
this.log.error( ' ' + response.statusCode + ': ' + response.statusMessage );
}
}.bind(this) );
}
}
var CustomUUIDs = {
// F H E M h o m e b r i d g e
xVolume: '4648454d-0101-686F-6D65-627269646765',
//Actuation: '4648454d-0201-686F-6D65-627269646765',
//ColorTemperature: '4648454d-0301-686F-6D65-627269646765',
// see: https://github.com/ebaauw/homebridge-hue/wiki/Characteristics
CT: 'E887EF67-509A-552D-A138-3DA215050F46',
//ColorTemperature: 'A18E5901-CFA1-4D37-A10F-0071CEEEEEBD',
Volume: '00001001-0000-1000-8000-135D67EC4377', // used in YamahaAVRPlatform, recognized by EVE
// see: https://gist.github.com/gomfunkel/b1a046d729757120907c
Voltage: 'E863F10A-079E-48FF-8F27-9C2605A29F52',
Current: 'E863F126-079E-48FF-8F27-9C2605A29F52',
Power: 'E863F10D-079E-48FF-8F27-9C2605A29F52',
Energy: 'E863F10C-079E-48FF-8F27-9C2605A29F52',
AirPressure: 'E863F10F-079E-48FF-8F27-9C2605A29F52',
Actuation: 'E863F12E-079E-48FF-8F27-9C2605A29F52',
};
function
FHEMAccessory(platform, s) {
this.log = platform.log;
this.connection = platform.connection;
this.jsFunctions = platform.jsFunctions;
if( FHEM_isPublished(s.Internals.NAME) ) {
this.log.warn( s.Internals.NAME + ' is already published');
return;
}
if( !s.Readings ) {
this.log.error( 'ignoring ' + s.Internals.NAME + ' (' + s.Internals.TYPE + ') without readings' );
return;
}
if( !s.Attributes ) {
this.log.error( 'ignoring ' + s.Internals.NAME + ' (' + s.Internals.TYPE + ') without attributes' );
return;
}
if( s.Attributes.disable == 1 ) {
this.log.info( s.Internals.NAME + ' is disabled');
//return;
}
var genericType = s.Attributes.genericDeviceType;
if( !genericType === undefined )
genericType = s.Attributes.genericDisplayType;
if( genericType === 'ignore' ) {
this.log.info( 'ignoring ' + s.Internals.NAME );
return;
}
this.service_name = genericType;
if( s.Internals.TYPE === 'structure' && genericType === undefined ) {
this.log.info( 'ignoring ' + s.Internals.NAME + ' (' + s.Internals.TYPE + ') without genericDeviceType' );
return;
}
if( s.Internals.TYPE === 'SVG' && genericType === undefined ) {
this.log.info( 'ignoring ' + s.Internals.NAME + ' (' + s.Internals.TYPE + ') without genericDeviceType' );
return;
}
if( s.Internals.TYPE === 'THRESHOLD' && genericType === undefined ) {
this.log.info( 'ignoring ' + s.Internals.NAME + ' (' + s.Internals.TYPE + ') without genericDeviceType' );
return;
}
this.mappings = {};
//this.service_name = 'switch';
var match;
if( match = s.PossibleSets.match(/(^| )dim:slider,0,1,99/) ) {
// ZWave dimmer
if( !this.service_name ) this.service_name = 'light';
this.mappings.On = { reading: 'state', valueOff: '/^(dim )?0$/', cmdOn: 'on', cmdOff: 'off' };
this.mappings.Brightness = { reading: 'state', cmd: 'dim', delay: true };
this.mappings.Brightness.reading2homekit = function(mapping, orig) {
var match;
if( match = orig.match(/dim (\d+)/ ) )
return parseInt( match[1] );
return 0;
}.bind(null, this.mappings.Brightness);
} else if( match = s.PossibleSets.match(/(^| )bri(:[^\b\s]*(,(\d+))+)?\b/) ) {
// Hue
if( !this.service_name ) this.service_name = 'light';
this.log.debug( 'detected HUEDevice' );
var max = 100;
if( match[4] !== undefined )
max = match[4];
this.mappings.On = { reading: 'onoff', valueOff: '0', cmdOn: 'on', cmdOff: 'off' };
//FIXME: max & maxValue are not set. they would work in both directions. but we use pct for the set cmd. not bri!
this.mappings.Brightness = { reading: 'bri', cmd: 'pct', delay: true };
this.mappings.Brightness.reading2homekit = function(mapping, orig) {
return Math.round(orig / 2.54);
}.bind(null, this.mappings.Brightness);
} else if( match = s.PossibleSets.match(/(^| )pct\b/) ) {
// HM dimmer
if( !this.service_name ) this.service_name = 'light';
this.mappings.On = { reading: 'pct', valueOff: '0', cmdOn: 'on', cmdOff: 'off' };
this.mappings.Brightness = { reading: 'pct', cmd: 'pct', delay: true };
} else if( match = s.PossibleSets.match(/(^| )dim\d+%/) ) {
// FS20 dimmer
if( !this.service_name ) this.service_name = 'light';
this.mappings.On = { reading: 'state', valueOff: 'off', cmdOn: 'on', cmdOff: 'off' };
this.mappings.Brightness = { reading: 'state', cmd: ' ', delay: true };
this.mappings.Brightness.reading2homekit = function(mapping, orig) {
var match;
if( orig.toLowerCase() == 'off' )
return 0;
else if( match = orig.match(/dim(\d+)%?/ ) )
return parseInt( match[1] );
return 100;
}.bind(null, this.mappings.Brightness);
this.mappings.Brightness.homekit2reading = function(mapping, orig) {
var dim_values = [ 'dim06%', 'dim12%', 'dim18%', 'dim25%', 'dim31%', 'dim37%', 'dim43%', 'dim50%',
'dim56%', 'dim62%', 'dim68%', 'dim75%', 'dim81%', 'dim87%', 'dim93%', 'dim100%' ];
//if( value < 3 )
// value = 'off';
//else
if( orig > 97 )
return 'dim100%';
return dim_values[Math.round(orig/6.25)];
}.bind(null, this.mappings.Brightness);
}
if( match = s.PossibleSets.match(/(^| )hue(:[^\b\s]*(,(\d+))+)?\b/) ) {
if( !this.service_name ) this.service_name = 'light';
var max = 359;
if( match[4] !== undefined )
max = match[4];
this.mappings.Hue = { reading: 'hue', cmd: 'hue', max: max, maxValue: 359 };
}
if( match = s.PossibleSets.match(/(^| )sat(:[^\b\s]*(,(\d+))+)?\b/) ) {
if( !this.service_name ) this.service_name = 'light';
var max = 100;
if( match[4] !== undefined )
max = match[4];
this.mappings.Saturation = { reading: 'sat', cmd: 'sat', max: max, maxValue: 100 };
}
/*if( match = s.PossibleSets.match(/(^| )ct(:[^\d]*([^\$ ]*))?/) ) {
if( !this.service_name ) this.service_name = 'light';
var minValue = 2000;
var maxValue = 6500;
if( match[3] ) {
var values = match[3].split(',');
minValue = parseInt(1000000/values[2]);
maxValue = parseInt(1000000/values[0]);
}
this.mappings[ColorTemperature] = { reading: 'ct', cmd: 'ct', delay: true,
name: 'Color Temperature', format: 'INT', unit: 'K',
minValue: maxValue, maxValue: minValue, minStep: 10 };
var reading2homekit = function(mapping, orig) { return parseInt(1000000 / parseInt(orig)) };
var homekit2reading = function(mapping, orig) { return parseInt(1000000 / orig) };
this.mappings[ColorTemperature].reading2homekit = reading2homekit.bind(null, this.mappings.color);
this.mappings[ColorTemperature].reading2homekit = reading2homekit.bind(null, this.mappings.color);
} else if( match = s.PossibleSets.match(/(^| )color(:[^\d]*([^\$ ]*))?/) ) {
if( !this.service_name ) this.service_name = 'light';
var minValue = 2000;
var maxValue = 6500;
if( match[3] ) {
var values = match[3].split(',');
minValue = parseInt(values[0]);
maxValue = parseInt(values[2]);
}
this.mappings[ColorTemperature] = { reading: 'color', cmd: 'color', delay: true,
name: 'Color Temperature', format: 'INT', unit: 'K',
minValue: minValue, maxValue: maxValue, minStep: 10 };
}*/
if( s.Internals.TYPE == 'MilightDevice' && s.PossibleSets.match(/(^| )dim\b/) ) {
// MilightDevice
if( !this.service_name ) this.service_name = 'light';
this.log.debug( 'detected MilightDevice' );
this.mappings.Brightness = { reading: 'brightness', cmd: 'dim', max: 100, maxValue: 100, delay: true };
if( s.PossibleSets.match(/(^| )hue\b/) && s.PossibleSets.match(/(^| )saturation\b/) ) {
this.mappings.Hue = { reading: 'hue', cmd: 'hue', max: 359, maxValue: 359 };
this.mappings.Saturation = { reading: 'saturation', cmd: 'saturation', max: 100, maxValue: 100 };
}
} else if( s.Internals.TYPE == 'WifiLight' && s.PossibleSets.match(/(^| )HSV\b/)
&& s.Readings.hue !== undefined && s.Readings.saturation !== undefined && s.Readings.brightness !== undefined ) {
// WifiLight
if( !this.service_name ) this.service_name = 'light';
this.log.debug( 'detected WifiLight' );
this.mappings.Hue = { reading: 'hue', cmd: 'HSV', max: 359, maxValue: 359 };
this.mappings.Saturation = { reading: 'saturation', cmd: 'HSV', max: 100, maxValue: 100 };
this.mappings.Brightness = { reading: 'brightness', cmd: 'HSV', max: 100, maxValue: 100, delay: true };
var homekit2reading = function(mapping, orig) {
var h = FHEM_cached[mapping.device + '-hue'];
var s = FHEM_cached[mapping.device + '-saturation'];
var v = FHEM_cached[mapping.device + '-brightness'];
//mapping.log( ' from cached : [' + h + ',' + s + ',' + v + ']' );
if( h === undefined ) h = 0;
if( s === undefined ) s = 100;
if( v === undefined ) v = 100;
//mapping.log( ' old : [' + h + ',' + s + ',' + v + ']' );
if( mapping.characteristic_type == 'Hue' ) {
h = orig;
//if( FHEM_cached[mapping.device + '-hue'] === orig ) return undefined;
FHEM_cached[mapping.device + '-hue'] = orig;
} else if( mapping.characteristic_type == 'Saturation' ) {
s = orig;
//if( FHEM_cached[mapping.device + '-saturation'] === orig ) return undefined;
FHEM_cached[mapping.device + '-saturation'] = orig;
} else if( mapping.characteristic_type == 'Brightness' ) {
v = orig;
//if( FHEM_cached[mapping.device + '-brightness'] === orig ) return undefined;
FHEM_cached[mapping.device + '-brightness'] = orig;
}
//mapping.log( ' new : [' + h + ',' + s + ',' + v + ']' );
return h + ',' + s + ',' + v;
}
this.mappings.Hue.homekit2reading = homekit2reading.bind(null, this.mappings.Hue);
this.mappings.Saturation.homekit2reading = homekit2reading.bind(null, this.mappings.Saturation);
this.mappings.Brightness.homekit2reading = homekit2reading.bind(null, this.mappings.Brightness);
}
if( !this.mappings.Hue || s.Internals.TYPE == 'SWAP_0000002200000003' ) {