UNPKG

homebridge-fhem

Version:
1,491 lines (1,206 loc) 130 kB
// 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' + '&timestamp='+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' ) {