diff --git a/README.md b/README.md index 4fae78b..641e32f 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Since Siri supports devices added through HomeKit, this means that with Homebrid * _Siri, turn off the Speakers._ ([Sonos](http://www.sonos.com)) * _Siri, turn on the Dehumidifier._ ([WeMo](http://www.belkin.com/us/Products/home-automation/c/wemo-home-automation/)) * _Siri, turn on Away Mode._ ([Xfinity Home](http://www.comcast.com/home-security.html)) - * _Siri, turn on the living room lights._ ([Wink](http://www.wink.com), [SmartThings](http://www.smartthings.com), [X10](http://github.com/edc1591/rest-mochad), [Philips Hue](http://meethue.com), [LimitlessLED/MiLight/Easybulb](http://www.limitlessled.com/)) + * _Siri, turn on the living room lights._ ([Wink](http://www.wink.com), [SmartThings](http://www.smartthings.com), [X10](http://github.com/edc1591/rest-mochad), [Philips Hue](http://meethue.com), [Home Assistant](http://home-assistant.io) [LimitlessLED/MiLight/Easybulb](http://www.limitlessled.com/), [LIFx](http://www.lifx.com/)) * _Siri, set the movie scene._ ([Logitech Harmony](http://myharmony.com/)) If you would like to support any other devices, please write a shim and create a pull request and I'd be happy to add it to this official list. diff --git a/accessories/FileSensor.js b/accessories/FileSensor.js new file mode 100644 index 0000000..e377dc6 --- /dev/null +++ b/accessories/FileSensor.js @@ -0,0 +1,76 @@ +var Service = require("HAP-NodeJS").Service; +var Characteristic = require("HAP-NodeJS").Characteristic; +var chokidar = require("chokidar"); +var debug = require("debug")("FileSensorAccessory"); +var crypto = require("crypto"); + +module.exports = { + accessory: FileSensorAccessory +} + +function FileSensorAccessory(log, config) { + this.log = log; + + // url info + this.name = config["name"]; + this.path = config["path"]; + this.window_seconds = config["window_seconds"] || 5; + this.sensor_type = config["sensor_type"] || "m"; + this.inverse = config["inverse"] || false; + + if(config["sn"]){ + this.sn = config["sn"]; + } else { + var shasum = crypto.createHash('sha1'); + shasum.update(this.path); + this.sn = shasum.digest('base64'); + debug('Computed SN ' + this.sn); + } +} + +FileSensorAccessory.prototype = { + + getServices: function() { + + // you can OPTIONALLY create an information service if you wish to override + // the default values for things like serial number, model, etc. + var informationService = new Service.AccessoryInformation(); + + informationService + .setCharacteristic(Characteristic.Name, this.name) + .setCharacteristic(Characteristic.Manufacturer, "Homebridge") + .setCharacteristic(Characteristic.Model, "File Sensor") + .setCharacteristic(Characteristic.SerialNumber, this.sn); + + var service, changeAction; + if(this.sensor_type === "c"){ + service = new Service.ContactSensor(); + changeAction = function(newState){ + service.getCharacteristic(Characteristic.ContactSensorState) + .setValue(newState ? Characteristic.ContactSensorState.CONTACT_DETECTED : Characteristic.ContactSensorState.CONTACT_NOT_DETECTED); + }; + } else { + service = new Service.MotionSensor(); + changeAction = function(newState){ + service.getCharacteristic(Characteristic.MotionDetected) + .setValue(newState); + }; + } + + var changeHandler = function(path, stats){ + var d = new Date(); + if(d.getTime() - stats.mtime.getTime() <= (this.window_seconds * 1000)){ + var newState = this.inverse ? false : true; + changeAction(newState); + if(this.timer !== undefined) clearTimeout(this.timer); + this.timer = setTimeout(function(){changeAction(!newState);}, this.window_seconds * 1000); + } + }.bind(this); + + var watcher = chokidar.watch(this.path, {alwaysStat: true}); + watcher.on('add', changeHandler); + watcher.on('change', changeHandler); + + return [informationService, service]; + } +}; diff --git a/accessories/Lockitron.js b/accessories/Lockitron.js index b5fd023..04414af 100644 --- a/accessories/Lockitron.js +++ b/accessories/Lockitron.js @@ -1,196 +1,73 @@ -var types = require("HAP-NodeJS/accessories/types.js"); +var Service = require('HAP-NodeJS').Service; +var Characteristic = require('HAP-NodeJS').Characteristic; var request = require("request"); +module.exports = { + accessory: LockitronAccessory +} + function LockitronAccessory(log, config) { this.log = log; this.name = config["name"]; - this.lockID = config["lock_id"]; this.accessToken = config["api_token"]; + this.lockID = config["lock_id"]; } -LockitronAccessory.prototype = { - getState: function(callback) { - this.log("Getting current state..."); - - var that = this; - - var query = { - access_token: this.accessToken - }; - - request.get({ - url: "https://api.lockitron.com/v2/locks/"+this.lockID, - qs: query - }, function(err, response, body) { - - if (!err && response.statusCode == 200) { - var json = JSON.parse(body); - var state = json.state; // "lock" or "unlock" - var locked = state == "lock" - callback(locked); - } - else { - that.log("Error getting state (status code "+response.statusCode+"): " + err) - callback(undefined); - } - }); - }, +LockitronAccessory.prototype.getState = function(callback) { + this.log("Getting current state..."); - setState: function(state) { - this.log("Set state to " + state); + request.get({ + url: "https://api.lockitron.com/v2/locks/"+this.lockID, + qs: { access_token: this.accessToken } + }, function(err, response, body) { + + if (!err && response.statusCode == 200) { + var json = JSON.parse(body); + var state = json.state; // "lock" or "unlock" + this.log("Lock state is %s", state); + var locked = state == "lock" + callback(null, locked); // success + } + else { + this.log("Error getting state (status code %s): %s", response.statusCode, err); + callback(err); + } + }.bind(this)); +} + +LockitronAccessory.prototype.setState = function(state, callback) { + var lockitronState = (state == 1) ? "lock" : "unlock"; - var lockitronState = (state == 1) ? "lock" : "unlock"; - var that = this; + this.log("Set state to %s", lockitronState); - var query = { - access_token: this.accessToken, - state: lockitronState - }; + request.put({ + url: "https://api.lockitron.com/v2/locks/"+this.lockID, + qs: { access_token: this.accessToken, state: lockitronState } + }, function(err, response, body) { - request.put({ - url: "https://api.lockitron.com/v2/locks/"+this.lockID, - qs: query - }, function(err, response, body) { + if (!err && response.statusCode == 200) { + this.log("State change complete."); + callback(null); // success + } + else { + this.log("Error '%s' setting lock state. Response: %s", err, body); + callback(err || new Error("Error setting lock state.")); + } + }.bind(this)); +}, - if (!err && response.statusCode == 200) { - that.log("State change complete."); - } - else { - that.log("Error '"+err+"' setting lock state: " + body); - } - }); - }, - - getServices: function() { - var that = this; - return [{ - sType: types.ACCESSORY_INFORMATION_STYPE, - characteristics: [{ - cType: types.NAME_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: this.name, - supportEvents: false, - supportBonjour: false, - manfDescription: "Name of the accessory", - designedMaxLength: 255 - },{ - cType: types.MANUFACTURER_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: "Apigee", - supportEvents: false, - supportBonjour: false, - manfDescription: "Manufacturer", - designedMaxLength: 255 - },{ - cType: types.MODEL_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: "Rev-2", - supportEvents: false, - supportBonjour: false, - manfDescription: "Model", - designedMaxLength: 255 - },{ - cType: types.SERIAL_NUMBER_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: "A1S2NASF88EW", - supportEvents: false, - supportBonjour: false, - manfDescription: "SN", - designedMaxLength: 255 - },{ - cType: types.IDENTIFY_CTYPE, - onUpdate: null, - perms: ["pw"], - format: "bool", - initialValue: false, - supportEvents: false, - supportBonjour: false, - manfDescription: "Identify Accessory", - designedMaxLength: 1 - }] - },{ - sType: types.LOCK_MECHANISM_STYPE, - characteristics: [{ - cType: types.NAME_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: "Lock Mechanism", - supportEvents: false, - supportBonjour: false, - manfDescription: "Name of service", - designedMaxLength: 255 - },{ - cType: types.CURRENT_LOCK_MECHANISM_STATE_CTYPE, - onRead: function(callback) { that.getState(callback); }, - onUpdate: function(value) { that.log("Update current state to " + value); }, - perms: ["pr","ev"], - format: "int", - initialValue: 0, - supportEvents: false, - supportBonjour: false, - manfDescription: "BlaBla", - designedMinValue: 0, - designedMaxValue: 3, - designedMinStep: 1, - designedMaxLength: 1 - },{ - cType: types.TARGET_LOCK_MECHANISM_STATE_CTYPE, - onUpdate: function(value) { that.setState(value); }, - perms: ["pr","pw","ev"], - format: "int", - initialValue: 0, - supportEvents: false, - supportBonjour: false, - manfDescription: "BlaBla", - designedMinValue: 0, - designedMaxValue: 1, - designedMinStep: 1, - designedMaxLength: 1 - }] - },{ - sType: types.LOCK_MANAGEMENT_STYPE, - characteristics: [{ - cType: types.NAME_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: "Lock Management", - supportEvents: false, - supportBonjour: false, - manfDescription: "Name of service", - designedMaxLength: 255 - },{ - cType: types.LOCK_MANAGEMENT_CONTROL_POINT_CTYPE, - onUpdate: function(value) { that.log("Update control point to " + value); }, - perms: ["pw"], - format: "data", - initialValue: 0, - supportEvents: false, - supportBonjour: false, - manfDescription: "BlaBla", - designedMaxLength: 255 - },{ - cType: types.VERSION_CTYPE, - onUpdate: function(value) { that.log("Update version to " + value); }, - perms: ["pr"], - format: "string", - initialValue: "1.0", - supportEvents: false, - supportBonjour: false, - manfDescription: "BlaBla", - designedMaxLength: 255 - }] - }]; - } -}; - -module.exports.accessory = LockitronAccessory; \ No newline at end of file +LockitronAccessory.prototype.getServices = function() { + + var service = new Service.LockMechanism(this.name); + + service + .getCharacteristic(Characteristic.LockCurrentState) + .on('get', this.getState.bind(this)); + + service + .getCharacteristic(Characteristic.LockTargetState) + .on('get', this.getState.bind(this)) + .on('set', this.setState.bind(this)); + + return [service]; +} diff --git a/accessories/MiLight.js b/accessories/MiLight.js deleted file mode 100644 index 85b09d6..0000000 --- a/accessories/MiLight.js +++ /dev/null @@ -1,132 +0,0 @@ -var Service = require("HAP-NodeJS").Service; -var Characteristic = require("HAP-NodeJS").Characteristic; -var Milight = require('node-milight-promise').MilightController; -var commands = require('node-milight-promise').commands; - -module.exports = { - accessory: MiLight -} - -function MiLight(log, config) { - this.log = log; - - // config info - this.ip_address = config["ip_address"]; - this.port = config["port"]; - this.name = config["name"]; - this.zone = config["zone"]; - this.type = config["type"]; - this.delay = config["delay"]; - this.repeat = config["repeat"]; -} - -var light = new Milight({ - ip: this.ip_address, - port: this.port, - delayBetweenCommands: this.delay, - commandRepeat: this.repeat -}); - -MiLight.prototype = { - - setPowerState: function(powerOn, callback) { - if (powerOn) { - light.sendCommands(commands[this.type].on(this.zone)); - this.log("Setting power state to on"); - } else { - light.sendCommands(commands[this.type].off(this.zone)); - this.log("Setting power state to off"); - } - callback(); - }, - - setBrightness: function(level, callback) { - if (level <= 2 && (this.type == "rgbw" || this.type == "white")) { - // If setting brightness to 2 or lower, instead set night mode for lamps that support it - this.log("Setting night mode", level); - - light.sendCommands(commands[this.type].off(this.zone)); - // Not sure if this timing is going to work or not? It's supposed to be 100ms after the off command - light.sendCommands(commands[this.type].nightMode(this.zone)); - } else { - this.log("Setting brightness to %s", level); - - // Send on command to ensure we're addressing the right bulb - light.sendCommands(commands[this.type].on(this.zone)); - - // If this is an rgbw lamp, set the absolute brightness specified - if (this.type == "rgbw") { - light.sendCommands(commands.rgbw.brightness(level)); - } else { - // If this is an rgb or a white lamp, they only support brightness up and down. - // Set brightness up when value is >50 and down otherwise. Not sure how well this works real-world. - if (level >= 50) { - if (this.type == "white" && level == 100) { - // But the white lamps do have a "maximum brightness" command - light.sendCommands(commands.white.maxBright(this.zone)); - } else { - light.sendCommands(commands[this.type].brightUp()); - } - } else { - light.sendCommands(commands[this.type].brightDown()); - } - } - } - callback(); - }, - - setHue: function(value, callback) { - this.log("Setting hue to %s", value); - - // Send on command to ensure we're addressing the right bulb - light.sendCommands(commands[this.type].on(this.zone)); - - if (this.type == "rgbw") { - if (value == 0) { - light.sendCommands(commands.rgbw.whiteMode(this.zone)); - } else { - light.sendCommands(commands.rgbw.hue(commands.rgbw.hsvToMilightColor(Array(value, 0, 0)))); - } - } else if (this.type == "rgb") { - light.sendCommands(commands.rgb.hue(commands.rgbw.hsvToMilightColor(Array(value, 0, 0)))); - } else if (this.type == "white") { - // Again, white lamps don't support setting an absolue colour temp, so trying to do warmer/cooler step at a time based on colour - if (value >= 180) { - light.sendCommands(commands.white.warmer()); - } else { - light.sendCommands(commands.white.cooler()); - } - } - - }, - - identify: function(callback) { - this.log("Identify requested!"); - callback(); // success - }, - - getServices: function() { - var informationService = new Service.AccessoryInformation(); - - informationService - .setCharacteristic(Characteristic.Manufacturer, "MiLight") - .setCharacteristic(Characteristic.Model, this.type) - .setCharacteristic(Characteristic.SerialNumber, "MILIGHT12345"); - - var lightbulbService = new Service.Lightbulb(); - - lightbulbService - .getCharacteristic(Characteristic.On) - .on('set', this.setPowerState.bind(this)); - - lightbulbService - .addCharacteristic(new Characteristic.Brightness()) - .on('set', this.setBrightness.bind(this)); - - lightbulbService - .addCharacteristic(new Characteristic.Hue()) - .on('set', this.setHue.bind(this)); - - return [informationService, lightbulbService]; - } -}; diff --git a/accessories/WeMo.js b/accessories/WeMo.js index 26ef822..df16f56 100644 --- a/accessories/WeMo.js +++ b/accessories/WeMo.js @@ -30,6 +30,30 @@ WeMoAccessory.prototype.search = function() { }.bind(this)); } +WeMoAccessory.prototype.getMotion = function(callback) { + + if (!this.device) { + this.log("No '%s' device found (yet?)", this.wemoName); + callback(new Error("Device not found"), false); + return; + } + + this.log("Getting motion state on the '%s'...", this.wemoName); + + this.device.getBinaryState(function(err, result) { + if (!err) { + var binaryState = parseInt(result); + var powerOn = binaryState > 0; + this.log("Motion state for the '%s' is %s", this.wemoName, binaryState); + callback(null, powerOn); + } + else { + this.log("Error getting motion state on the '%s': %s", this.wemoName, err.message); + callback(err); + } + }.bind(this)); +} + WeMoAccessory.prototype.getPowerOn = function(callback) { if (!this.device) { @@ -122,6 +146,15 @@ WeMoAccessory.prototype.getServices = function() { return [garageDoorService]; } + else if (this.service == "MotionSensor") { + var motionSensorService = new Service.MotionSensor(this.name); + + motionSensorService + .getCharacteristic(Characteristic.MotionDetected) + .on('get', this.getMotion.bind(this)); + + return [motionSensorService]; + } else { throw new Error("Unknown service type '%s'", this.service); } diff --git a/accessories/mpdclient.js b/accessories/mpdclient.js new file mode 100644 index 0000000..8fee5eb --- /dev/null +++ b/accessories/mpdclient.js @@ -0,0 +1,89 @@ +var Service = require("HAP-NodeJS").Service; +var Characteristic = require("HAP-NodeJS").Characteristic; +var request = require("request"); +var komponist = require('komponist') + +module.exports = { + accessory: MpdClient +} + +function MpdClient(log, config) { + this.log = log; + this.host = config["host"] || 'localhost'; + this.port = config["port"] || 6600; +} + +MpdClient.prototype = { + + setPowerState: function(powerOn, callback) { + + var log = this.log; + + komponist.createConnection(this.port, this.host, function(error, client) { + + if (error) { + return callback(error); + } + + if (powerOn) { + client.play(function(error) { + log("start playing"); + client.destroy(); + callback(error); + }); + } else { + client.stop(function(error) { + log("stop playing"); + client.destroy(); + callback(error); + }); + } + + }); + }, + + getPowerState: function(callback) { + + komponist.createConnection(this.port, this.host, function(error, client) { + + if (error) { + return callback(error); + } + + client.status(function(error, status) { + + client.destroy(); + + if (status['state'] == 'play') { + callback(error, 1); + } else { + callback(error, 0); + } + }); + + }); + }, + + identify: function(callback) { + this.log("Identify requested!"); + callback(); // success + }, + + getServices: function() { + + var informationService = new Service.AccessoryInformation(); + + informationService + .setCharacteristic(Characteristic.Manufacturer, "MPD") + .setCharacteristic(Characteristic.Model, "MPD Client") + .setCharacteristic(Characteristic.SerialNumber, "81536334"); + + var switchService = new Service.Switch(); + + switchService.getCharacteristic(Characteristic.On) + .on('get', this.getPowerState.bind(this)) + .on('set', this.setPowerState.bind(this)); + + return [informationService, switchService]; + } +}; diff --git a/config-sample-knx.json b/config-sample-knx.json index df0de60..a8e52b1 100644 --- a/config-sample-knx.json +++ b/config-sample-knx.json @@ -72,7 +72,7 @@ }, { "accessory_type": "knxdevice", - "description":"sample device with multiple services. Multiple services of different types are widely supported" + "description":"sample device with multiple services. Multiple services of different types are widely supported", "name": "Office", "services": [ { @@ -118,4 +118,4 @@ } ], "accessories": [] -} \ No newline at end of file +} diff --git a/config-sample.json b/config-sample.json index 4245bd5..49fc923 100644 --- a/config-sample.json +++ b/config-sample.json @@ -71,7 +71,32 @@ "platform": "YamahaAVR", "play_volume": -35, "setMainInputTo": "AirPlay" - } + }, + { + "platform": "ZWayServer", + "url": "http://192.168.1.10:8083/", + "login": "zwayusername", + "password": "zwayuserpassword", + "poll_interval": 2, + "split_services": false + }, + { + "platform": "MiLight", + "name": "MiLight", + "ip_address": "255.255.255.255", + "port": 8899, + "type": "rgbw", + "delay": 30, + "repeat": 3, + "zones":["Kitchen Lamp","Bedroom Lamp","Living Room Lamp","Hallway Lamp"] + }, + { + "platform": "HomeAssistant", + "name": "HomeAssistant", + "host": "http://192.168.1.10:8123", + "password": "XXXXX", + "supported_types": ["light", "switch", "media_player", "scene"] + } ], "accessories": [ @@ -152,16 +177,6 @@ "port" : 4999, // Port the SER2SOCK process is running on "pin": "1234" // PIN used for arming / disarming }, - { - "accessory":"MiLight", - "name": "Lamp", - "ip_address": "255.255.255.255", - "port": 8899, - "zone": 1, - "type": "rgbw", - "delay": 35, - "repeat": 3 - }, { "accessory": "Tesla", "name": "Tesla", @@ -172,9 +187,25 @@ { "accessory": "Hyperion", "name": "TV Backlight", - "description": "Control the Hyperion TV backlight server. https://github.com/tvdzwan/hyperion" + "description": "Control the Hyperion TV backlight server. https://github.com/tvdzwan/hyperion", "host": "localhost", "port": "19444" + }, + { + "accessory": "mpdclient", + "name" : "mpd", + "host" : "localhost", + "port" : 6600, + "description": "Allows some control of an MPD server" + }, + { + "accessory": "FileSensor", + "name": "File Time Motion Sensor", + "path": "/tmp/CameraDump/", + "window_seconds": 5, + "sensor_type": "m", + "inverse": false } + ] } diff --git a/package.json b/package.json index 9fa4cb5..d0eb41c 100644 --- a/package.json +++ b/package.json @@ -13,17 +13,22 @@ "dependencies": { "ad2usb": "git+https://github.com/alistairg/node-ad2usb.git#local", "carwingsjs": "0.0.x", + "chokidar": "^1.0.5", "color": "0.10.x", - "elkington": "kevinohara80/elkington", "eibd": "^0.3.1", - "hap-nodejs": "git+https://github.com/KhaosT/HAP-NodeJS#6bf0f9eaaa2d87db8d1768114c61f4acbb095c41", + "elkington": "kevinohara80/elkington", + "hap-nodejs": "git+https://github.com/KhaosT/HAP-NodeJS#0030b35856e04ee2b42f0d05839feaa5c44cbd1f", "harmonyhubjs-client": "^1.1.4", "harmonyhubjs-discover": "git+https://github.com/swissmanu/harmonyhubjs-discover.git", + "lifx-api": "^1.0.1", + "lifx": "git+https://github.com/magicmonkey/lifxjs.git", "mdns": "^2.2.4", "node-hue-api": "^1.0.5", "node-icontrol": "^0.1.4", "node-milight-promise": "0.0.x", "node-persist": "0.0.x", + "q": "1.4.x", + "tough-cookie": "^2.0.0", "request": "2.49.x", "sonos": "0.8.x", "telldus-live": "0.2.x", @@ -33,6 +38,8 @@ "wink-js": "0.0.5", "xml2js": "0.4.x", "xmldoc": "0.1.x", - "yamaha-nodejs": "0.4.x" + "komponist" : "0.1.0", + "yamaha-nodejs": "0.4.x", + "debug": "^2.2.0" } } diff --git a/platforms/Domoticz.js b/platforms/Domoticz.js index de5b466..8930011 100644 --- a/platforms/Domoticz.js +++ b/platforms/Domoticz.js @@ -8,6 +8,9 @@ // - Added support for Scenes // - Sorting device names // +// 22 July 2015 [lukeredpath] +// - Added SSL and basic auth support +// // 26 August 2015 [EddyK69] // - Added parameter in config.json: 'loadscenes' for enabling/disabling loading scenes // - Fixed issue with dimmer-range; was 0-100, should be 0-16 @@ -17,6 +20,10 @@ // - Fixed issue that 'on-off'-type lights would not react on Siri 'Switch on/off light'; On/Off types are now handled as Lights instead of Switches // (Cannot determine if 'on/off'-type device is a Light or a Switch :( ) // +// 14 September 2015 [lukeredpath] +// - Fixed incorrect dimmer range for LightwaveRF lights (0-32 required, MaxDimLevel should be honored) +// +// // Domoticz JSON API required // https://www.domoticz.com/wiki/Domoticz_API/JSON_URL's#Lights_and_switches // @@ -183,9 +190,7 @@ DomoticzAccessory.prototype = { url = this.platform.urlForQuery("type=command¶m=setcolbrightnessvalue&idx=" + this.idx + "&hue=" + value + "&brightness=100" + "&iswhite=false"); } else if (c == "setLevel") { - //Range should be 0-16 instead of 0-100 - //See http://www.domoticz.com/wiki/Domoticz_API/JSON_URL%27s#Set_a_dimmable_light_to_a_certain_level - value = Math.round((value / 100) * 16) + value = this.dimmerLevelForValue(value) url = this.platform.urlForQuery("type=command¶m=switchlight&idx=" + this.idx + "&switchcmd=Set%20Level&level=" + value); } else if (value != undefined) { @@ -208,11 +213,19 @@ DomoticzAccessory.prototype = { that.log("There was a problem sending command " + c + " to" + that.name); that.log(url); } else { - that.log(that.name + " sent command " + c); + that.log(that.name + " sent command " + c + " (value: " + value + ")"); } }) }, + // translates the HomeKit dim level as a percentage to whatever scale the device requires + dimmerLevelForValue: function(value) { + if (this.MaxDimLevel == 100) { + return value; + } + return Math.round((value / 100.0) * this.MaxDimLevel) + }, + informationCharacteristics: function() { return [ { @@ -362,4 +375,4 @@ DomoticzAccessory.prototype = { }; module.exports.accessory = DomoticzAccessory; -module.exports.platform = DomoticzPlatform; \ No newline at end of file +module.exports.platform = DomoticzPlatform; diff --git a/platforms/FHEM.js b/platforms/FHEM.js index ada3c47..03f3ee1 100644 --- a/platforms/FHEM.js +++ b/platforms/FHEM.js @@ -16,7 +16,11 @@ // When you attempt to add a device, it will ask for a "PIN code". // The default code for all HomeBridge accessories is 031-45-154. +var Service = require("HAP-NodeJS").Service; +var Characteristic = require("HAP-NodeJS").Characteristic; + var types = require('HAP-NodeJS/accessories/types.js'); + var util = require('util'); @@ -38,31 +42,19 @@ FHEM_update(inform_id, value, no_update) { || FHEM_cached[inform_id] === value ) return; - //if( FHEM_internal['.'+subscription.accessory.device+'-homekitID'] == undefined ) { - // var info = subscription.characteristic.accessoryController.tcpServer.accessoryInfo; - - // if( info.username ) { - // var accessory = subscription.accessory; - // var cmd = '{$defs{'+ accessory.device +'}->{homekitID} = "'+info.username+'" if(defined($defs{'+ accessory.device +'}));;}'; - // //accessory.execute( cmd ); - - // FHEM_internal['.'+accessory.device+'-homekitID'] = info.username; - // } - //} - FHEM_cached[inform_id] = value; //FHEM_cached[inform_id] = { 'value': value, 'timestamp': Date.now() }; console.log(" caching: " + inform_id + ": " + value + " as " + typeof(value) ); if( !no_update ) - subscription.characteristic.updateValue(value, null); + subscription.characteristic.setValue(value, undefined, 'fromFhem'); } } -var FHEM_lastEventTimestamp; +var FHEM_lastEventTime; var FHEM_longpoll_running = false; -//FIXME: force reconnect on xxx bytes received ?, add filter, add since +//FIXME: add filter function FHEM_startLongpoll(connection) { if( FHEM_longpoll_running ) return; @@ -70,6 +62,8 @@ function FHEM_startLongpoll(connection) { var filter = ".*"; var since = "null"; + if( FHEM_lastEventTime ) + since = FHEM_lastEventTime/1000; var query = "/fhem.pl?XHR=1"+ "&inform=type=status;filter="+filter+";since="+since+";fmt=JSON"+ "×tamp="+Date.now() @@ -85,8 +79,9 @@ function FHEM_startLongpoll(connection) { return; input += data; + var lastEventTime = Date.now(); for(;;) { - var nOff = input.indexOf("\n", FHEM_longpollOffset); + var nOff = input.indexOf('\n', FHEM_longpollOffset); if(nOff < 0) break; var l = input.substr(FHEM_longpollOffset, nOff-FHEM_longpollOffset); @@ -113,14 +108,12 @@ function FHEM_startLongpoll(connection) { var subscription = FHEM_subscriptions[d[0]]; if( subscription != undefined ) { //console.log( "Rcvd: "+(l.length>132 ? l.substring(0,132)+"...("+l.length+")":l) ); - FHEM_lastEventTimestamp = Date.now(); + FHEM_lastEventTime = lastEventTime; var accessory = subscription.accessory; var value = d[1]; if( value.match( /^set-/ ) ) continue; - if( value.match( /^set_/ ) ) - continue; var match = d[0].match(/([^-]*)-(.*)/); var device = match[1]; @@ -140,12 +133,12 @@ function FHEM_startLongpoll(connection) { continue; } else if( accessory.mappings.lock ) { - var lock = 0; + var lock = Characteristic.LockCurrentState.UNSECURED; if( value.match( /^locked/ ) ) - lock = 1; + lock = Characteristic.LockCurrentState.SECURED; if( value.match( /uncertain/ ) ) - level = 4; + level = Characteristic.LockCurrentState.UNKNOWN; FHEM_update( accessory.mappings.lock.informId, lock ); continue; @@ -333,12 +326,19 @@ FHEMPlatform.prototype = { var foundAccessories = []; + // mechanism to ensure callback is only executed once all requests complete + var asyncCalls = 0; + function callbackLater() { if (--asyncCalls == 0) callback(foundAccessories); } + var cmd = 'jsonlist2'; if( this.filter ) cmd += " " + this.filter; var url = encodeURI( this.connection.base_url + "/fhem?cmd=" + cmd + "&XHR=1"); this.log( 'fetching: ' + url ); + + asyncCalls++; + var that = this; this.connection.request.get( { url: url, json: true, gzip: true }, function(err, response, json) { @@ -388,17 +388,22 @@ FHEMPlatform.prototype = { } else if( s.Readings.humidity ) { accessory = new FHEMAccessory(that.log, that.connection, s); + } else if( s.Readings.voc ) { + accessory = new FHEMAccessory(that.log, that.connection, s); + } else { that.log( 'ignoring ' + s.Internals.NAME ); } - if( accessory ) + if( accessory && Object.getOwnPropertyNames(accessory).length ) foundAccessories.push(accessory); }); } - callback(foundAccessories); + + //callback(foundAccessories); + callbackLater(); } else { that.log("There was a problem connecting to FHEM (1)."); @@ -417,6 +422,20 @@ FHEMAccessory(log, connection, s) { //log("got json: " + util.inspect(s) ); //log("got json: " + util.inspect(s.Internals) ); + if( !(this instanceof FHEMAccessory) ) + return new FHEMAccessory(log, connection, s); + + if( s.Attributes.disable == 1 ) { + that.log( s.Internals.NAME + ' is disabled'); + return null; + + } else if( s.Internals.TYPE == 'structure' ) { + that.log( s.Internals.NAME + ' is a structure'); + return null; + + } + + this.mappings = {}; var match; @@ -467,6 +486,9 @@ FHEMAccessory(log, connection, s) { if( s.Readings.humidity ) this.mappings.humidity = { reading: 'humidity' }; + if( s.Readings.voc ) + this.mappings.airquality = { reading: 'voc' }; + if( s.Readings.motor ) this.mappings.motor = { reading: 'motor' }; @@ -529,8 +551,11 @@ FHEMAccessory(log, connection, s) { if( s.Internals.TYPE == 'SONOSPLAYER' ) //FIXME: use sets [Pp]lay/[Pp]ause/[Ss]top this.mappings.onOff = { reading: 'transportState', cmdOn: 'play', cmdOff: 'pause' }; else if( s.PossibleSets.match(/[\^ ]on\b/) - && s.PossibleSets.match(/[\^ ]off\b/) ) + && s.PossibleSets.match(/[\^ ]off\b/) ) { this.mappings.onOff = { reading: 'state', cmdOn: 'on', cmdOff: 'off' }; + if( !s.Readings.state ) + delete this.mappings.onOff.reading; + } var event_map = s.Attributes.eventMap; if( event_map ) { @@ -559,23 +584,25 @@ FHEMAccessory(log, connection, s) { else if( this.mappings.thermostat ) log( s.Internals.NAME + ' is thermostat ['+ this.mappings.thermostat.reading +']' ); else if( this.mappings.contact ) - log( s.Internals.NAME + ' is contactsensor [' + this.mappings.contact.reading +']' ); + log( s.Internals.NAME + ' is contact sensor [' + this.mappings.contact.reading +']' ); else if( this.mappings.occupancy ) - log( s.Internals.NAME + ' is occupancysensor' ); + log( s.Internals.NAME + ' is occupancy sensor' ); else if( this.mappings.rgb ) - log( s.Internals.NAME + ' has RGB [0-' + this.mappings.rgb.reading +']'); + log( s.Internals.NAME + ' has RGB [' + this.mappings.rgb.reading +']'); else if( this.mappings.pct ) log( s.Internals.NAME + ' is dimable ['+ this.mappings.pct.reading +']' ); else if( s.hasDim ) log( s.Internals.NAME + ' is dimable [0-'+ s.pctMax +']' ); else if( s.isLight ) log( s.Internals.NAME + ' is light' ); - else + else if( this.mappings.onOff || s.isSwitch ) log( s.Internals.NAME + ' is switchable' ); + else if( !this.mappings ) + return {}; - if( this.hasOnOff ) - log( s.Internals.NAME + ' has OnOff [' + this.hasOnOff + ']' ); + if( this.mappings.onOff ) + log( s.Internals.NAME + ' has onOff [' + this.mappings.onOff.reading + ';' + this.mappings.onOff.cmdOn +',' + this.mappings.onOff.cmdOff + ']' ); if( this.mappings.hue ) log( s.Internals.NAME + ' has hue [0-' + this.mappings.hue.max +']' ); if( this.mappings.sat ) @@ -584,17 +611,23 @@ FHEMAccessory(log, connection, s) { log( s.Internals.NAME + ' has temperature ['+ this.mappings.temperature.reading +']' ); if( this.mappings.humidity ) log( s.Internals.NAME + ' has humidity ['+ this.mappings.humidity.reading +']' ); + if( this.mappings.airquality ) + log( s.Internals.NAME + ' has voc ['+ this.mappings.airquality.reading +']' ); if( this.mappings.motor ) log( s.Internals.NAME + ' has motor' ); if( this.mappings.direction ) log( s.Internals.NAME + ' has direction' ); +//log( util.inspect(s) ); + // device info this.name = s.Internals.NAME; this.alias = s.Attributes.alias ? s.Attributes.alias : s.Internals.NAME; this.device = s.Internals.NAME; this.type = s.Internals.TYPE; - this.model = s.Attributes.model ? s.Attributes.model : s.Internals.model; + this.model = s.Readings.model ? s.Readings.model.Value + : (s.Attributes.model ? s.Attributes.model + : ( s.Internals.model ? s.Internals.model : '' ) ); this.PossibleSets = s.PossibleSets; if( this.type == 'CUL_HM' ) { @@ -635,6 +668,7 @@ FHEMAccessory(log, connection, s) { if( value != undefined ) { var inform_id = that.device +'-'+ reading; that.mappings[key].informId = inform_id; + if( !that.mappings[key].nocache ) FHEM_cached[inform_id] = value; } @@ -649,6 +683,9 @@ FHEM_dim_values = [ 'dim06%', 'dim12%', 'dim18%', 'dim25%', 'dim31%', 'dim37%', FHEMAccessory.prototype = { reading2homekit: function(reading,value) { + if( value == undefined ) + return undefined; + if( reading == 'hue' ) { value = Math.round(value * 360 / this.mappings.hue ? this.mappings.hue.max : 360); @@ -658,21 +695,21 @@ FHEMAccessory.prototype = { } else if( reading == 'pct' ) { value = parseInt( value ); - } else if(reading == 'direction') { - if( value.match(/^up/)) - value = 1; - else if( value.match(/^down/)) - value = 0; - else - value = 2; - } else if(reading == 'motor') { - if( value.match(/^opening/)) - value = 1; - else if( value.match(/^closing/)) - value = 0; + if( value.match(/^up/)) + value = Characteristic.PositionState.INCREASING; + else if( value.match(/^down/)) + value = Characteristic.PositionState.DECREASING; else - value = 2; + value = Characteristic.PositionState.STOPPED; + + } else if(reading == 'direction') { + if( value.match(/^opening/)) + value = PositionState.INCREASING; + else if( value.match(/^closing/)) + value = Characteristic.PositionState.DECREASING; + else + value = Characteristic.PositionState.STOPPED; } else if( reading == 'transportState' ) { if( value == 'PLAYING' ) @@ -686,24 +723,23 @@ FHEMAccessory.prototype = { } else if( reading == 'contact' ) { if( value.match( /^closed/ ) ) - value = 1; + value = Characteristic.ContactSensorState.CONTACT_DETECTED; else - value = 0; + value = Characteristic.ContactSensorState.CONTACT_NOT_DETECTED; } else if( reading == 'Window' ) { if( value.match( /^Closed/ ) ) - value = 1; + value = Characteristic.ContactSensorState.CONTACT_DETECTED; else - value = 0; + value = Characteristic.ContactSensorState.CONTACT_NOT_DETECTED; } else if( reading == 'lock' ) { - if( value.match( /^locked/ ) ) - value = 1; - else - value = 0; - if( value.match( /uncertain/ ) ) - value = 4; + value = Characteristic.LockCurrentState.UNKNOWN; + else if( value.match( /^locked/ ) ) + value = Characteristic.LockCurrentState.SECURED; + else + value = Characteristic.LockCurrentState.UNSECURED; } else if( reading == 'temperature' || reading == 'measured-temp' @@ -714,11 +750,24 @@ FHEMAccessory.prototype = { } else if( reading == 'humidity' ) { value = parseInt( value ); + } else if( reading == 'voc' ) { + value = parseInt( value ); + if( value > 1500 ) + Characteristic.AirQuality.POOR; + else if( value > 1000 ) + Characteristic.AirQuality.INFERIOR; + else if( value > 800 ) + Characteristic.AirQuality.FAIR; + else if( value > 600 ) + Characteristic.AirQuality.GOOD; + else if( value > 0 ) + Characteristic.AirQuality.EXCELLENT; + else + Characteristic.AirQuality.UNKNOWN; + } else if( reading == 'state' ) { if( value.match(/^set-/ ) ) return undefined; - if( value.match(/^set_/ ) ) - return undefined; if( this.event_map != undefined ) { var mapped = this.event_map[value]; @@ -728,11 +777,13 @@ FHEMAccessory.prototype = { if( value == 'off' ) value = 0; + else if( value == 'present' ) + value = Characteristic.OccupancyDetected.OCCUPANCY_DETECTED; else if( value == 'absent' ) - value = 0; + value = Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED; else if( value == '000000' ) value = 0; - else if( value.match( /^[A-D]0$/ ) ) // FIXME: should be handled by event_map now + else if( value.match( /^[A-D]0$/ ) ) //FIXME: is handled by event_map now value = 0; else value = 1; @@ -760,7 +811,7 @@ FHEMAccessory.prototype = { if( this.type == 'HUEDevice' ) cmd = "set " + this.device + "alert select"; else - cmd = "set " + this.device + " toggle;; sleep 1;; set "+ this.device + " toggle"; + cmd = "set " + this.device + " toggle; sleep 1; set "+ this.device + " toggle"; } else if( c == 'set' ) { cmd = "set " + this.device + " " + value; @@ -864,14 +915,21 @@ FHEMAccessory.prototype = { }, query: function(reading, callback) { + if( reading == undefined ) { + if( callback != undefined ) + callback( 1 ); + return; + } + this.log("query: " + this.name + "-" + reading); var result = FHEM_cached[this.device + '-' + reading]; if( result != undefined ) { this.log(" cached: " + result); if( callback != undefined ) - callback( result ); - return( result ); + callback( undefined, result ); + return result; + } else this.log(" not cached" ); @@ -931,13 +989,12 @@ FHEMAccessory.prototype = { } else if( reading == 'lock' && query_reading == 'state') { - if( value.match( /^locked/ ) ) - value = 1; - else - value = 0; - if( value.match( /uncertain/ ) ) - value = 4; + value = Characteristic.LockCurrentState.UNKNOWN; + else if( value.match( /^locked/ ) ) + value = Characteristic.LockCurrentState.SECURED; + else + value = Characteristic.LockCurrentState.UNSECURED; } else if(reading == 'hue' && query_reading == that.mappings.rgb) { //FHEM_update( that.device+'-'+query_reading, value ); @@ -962,711 +1019,515 @@ FHEMAccessory.prototype = { that.log(" mapped: " + value); FHEM_update( that.device + '-' + reading, value, true ); - if( value == undefined ) - return; - if( callback != undefined ) - callback(value); - return(value); + if( callback != undefined ) { + if( value == undefined ) + callback(1); + else + callback(undefined, value); + } + + return value ; } ); }, - informationCharacteristics: function() { - return [ - { - cType: types.NAME_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: this.alias, - supportEvents: false, - supportBonjour: false, - manfDescription: "Name of the accessory", - designedMaxLength: 255 - },{ - cType: types.MANUFACTURER_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: "FHEM:"+this.type, - supportEvents: false, - supportBonjour: false, - manfDescription: "Manufacturer", - designedMaxLength: 255 - },{ - cType: types.MODEL_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: this.model ? this.model : '', - supportEvents: false, - supportBonjour: false, - manfDescription: "Model", - designedMaxLength: 255 - },{ - cType: types.SERIAL_NUMBER_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: this.serial ? this.serial : "", - supportEvents: false, - supportBonjour: false, - manfDescription: "SN", - designedMaxLength: 255 - },{ - cType: types.IDENTIFY_CTYPE, - onUpdate: function(value) { - if( this.mappings.onOff ) - that.command( 'identify' ); - }, - perms: ["pw"], - format: "bool", - initialValue: false, - supportEvents: false, - supportBonjour: false, - manfDescription: "Identify Accessory", - designedMaxLength: 1 - }]; - }, + createDeviceService: function() { + var name = this.alias + 'xxx'; - controlCharacteristics: function(that) { - cTypes = [{ - cType: types.NAME_CTYPE, - onUpdate: null, - perms: ["pr"], - format: "string", - initialValue: this.alias, - supportEvents: true, - supportBonjour: false, - manfDescription: "Name of service", - designedMaxLength: 255 - }] - - if( this.mappings.onOff ) { - cTypes.push({ - cType: types.POWER_STATE_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.onOff.informId, that); - }, - onUpdate: function(value) { - that.command( 'set', value == 0 ? that.mappings.onOff.cmdOff : that.mappings.onOff.cmdOn ); - }, - onRead: function(callback) { - that.query( that.mappings.onOff.reading, function(state){ callback(state) } ); - }, - perms: ["pw","pr","ev"], - format: "bool", - initialValue: FHEM_cached[that.mappings.onOff.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Change the power state", - designedMaxLength: 1 - }); - } - - if( this.mappings.pct ) { - cTypes.push({ - cType: types.BRIGHTNESS_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.pct.informId, that); - }, - onUpdate: function(value) { that.command('pct', value); }, - onRead: function(callback) { - that.query(that.mappings.pct.reading, function(pct){ - callback(pct); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: FHEM_cached[that.mappings.pct.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust Brightness of the Light", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1, - unit: "%" - }); - } else if( this.hasDim ) { - cTypes.push({ - cType: types.BRIGHTNESS_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - // state is alreadi subscribed from POWER_STATE_CTYPE - FHEM_subscribe(characteristic, that.name+'-pct', that); - }, - onUpdate: function(value) { that.delayed('dim', value); }, - onRead: function(callback) { - that.query('pct', function(pct){ - callback(pct); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 0, - //initialValue: FHEM_cached[that.mappings.dim.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust Brightness of the Light", - designedMinValue: 0, - designedMaxValue: this.pctMax, - designedMinStep: 1, - unit: "%" - }); - } - - if( that.mappings.hue ) { - cTypes.push({ - cType: types.HUE_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.hue.informId, that); - }, - onUpdate: function(value) { that.command('hue', value); }, - onRead: function(callback) { - that.query(that.mappings.hue.reading, function(hue){ - callback(hue); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: FHEM_cached[that.mappings.hue.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust the Hue of the Light", - designedMinValue: 0, - designedMaxValue: 360, - designedMinStep: 1, - unit: "arcdegrees" - }); - } else if( this.mappings.rgb ) { - cTypes.push({ - cType: types.HUE_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.name+'-hue', that); - FHEM_subscribe(characteristic, that.mappings.rgb.informId, that); - }, - onUpdate: function(value) { that.command('H-rgb', value); }, - onRead: function(callback) { - that.query('hue', function(hue){ - callback(hue); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 0, - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust the Hue of the Light", - designedMinValue: 0, - designedMaxValue: 360, - designedMinStep: 1, - unit: "arcdegrees" - }); - - if( !this.mappings.sat ) - cTypes.push({ - cType: types.SATURATION_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.name+'-sat', that); - }, - onUpdate: function(value) { that.command('S-rgb', value); }, - onRead: function(callback) { - that.query('sat', function(sat){ - callback(sat); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 100, - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust the Saturation of the Light", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1, - unit: "%" - }); - - if( !this.mappings.pct ) - cTypes.push({ - cType: types.BRIGHTNESS_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.name+'-bri', that); - }, - onUpdate: function(value) { that.command('B-rgb', value); }, - onRead: function(callback) { - that.query('bri', function(bri){ - callback(bri); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 0, - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust Brightness of the Light", - designedMinValue: 0, - designedMaxValue: this.pctMax, - designedMinStep: 1, - unit: "%" - }); - } - - if( this.mappings.sat ) { - cTypes.push({ - cType: types.SATURATION_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.sat.informId, that); - }, - onUpdate: function(value) { that.command('sat', value); }, - onRead: function(callback) { - that.query(that.mappings.sat.reading, function(sat){ - callback(sat); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: FHEM_cached[that.mappings.sat.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust the Saturation of the Light", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1, - unit: "%" - }); - } - - //FIXME: use mapping.volume - if( this.mappings.volume ) { - cTypes.push({ - cType: types.OUTPUTVOLUME_CTYPE, - onUpdate: function(value) { that.delayed('volume', value); }, - onRegister: function(characteristic) { - if( !that.mappings.volume.nocache ) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.volume.informId, that); - } - }, - onRead: function(callback) { - that.query(that.mappings.volume.reading, function(volume){ - callback(volume); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 10, - //initialValue: FHEM_cached[that.mappings.volume.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Adjust the Volume of this device", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1 - //unit: "%" - }); - } - - if( this.mappings.blind ) { - cTypes.push({ - cType: types.WINDOW_COVERING_TARGET_POSITION_CTYPE, - onUpdate: function(value) { that.delayed('targetPosition', value, 1500); }, - //onRegister: function(characteristic) { - // characteristic.eventEnabled = true; - // FHEM_subscribe(characteristic, that.mappings.blind.informId, that); - //}, - onRead: function(callback) { - that.query(that.mappings.blind.reading, function(pct){ - callback(pct); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: FHEM_cached[that.mappings.blind.informId], - supportEvents: false, - supportBonjour: false, - manfDescription: "Target Blind Position", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1, - unit: "%" - }); - cTypes.push({ - cType: types.WINDOW_COVERING_CURRENT_POSITION_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.blind.informId, that); - }, - onRead: function(callback) { - that.query(that.mappings.blind.reading, function(pos){ - callback(pos); - }); - }, - perms: ["pr","ev"], - format: "int", - initialValue: FHEM_cached[that.mappings.blind.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Current Blind Position", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1, - unit: "%" - }); - - cTypes.push({ - cType: types.WINDOW_COVERING_OPERATION_STATE_CTYPE, - onRegister: function(characteristic) { - if( that.mappings.motor ) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.motor.informId, that); - } - }, - onRead: function(callback) { - if( that.mappings.motor ) - that.query(that.mappings.motor.reading, function(state){ - callback(state); - }); - }, - perms: ["pr","ev"], - format: "int", - initialValue: that.mappings.motor?FHEM_cached[that.mappings.motor.informId]:2, - supportEvents: false, - supportBonjour: false, - manfDescription: "Position State", - designedMinValue: 0, - designedMaxValue: 2, - designedMinStep: 1, - }); - } - - if( this.mappings.window ) { - cTypes.push({ - cType: types.WINDOW_COVERING_TARGET_POSITION_CTYPE, - onUpdate: function(value) { that.delayed('targetPosition', value, 1500); }, - onRead: function(callback) { - that.query(that.mappings.window.reading, function(level){ - callback(level); - }); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 50, - supportEvents: false, - supportBonjour: false, - manfDescription: "Target Window Position", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1, - unit: "%" - }); - cTypes.push({ - cType: types.WINDOW_COVERING_CURRENT_POSITION_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.name+'-state', that); - FHEM_subscribe(characteristic, that.mappings.window.informId, that); - }, - onRead: function(callback) { - that.query(that.mappings.window.reading, function(pos){ - callback(pos); - }); - }, - perms: ["pr","ev"], - format: "int", - initialValue: FHEM_cached[that.mappings.window.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Current Window Position", - designedMinValue: 0, - designedMaxValue: 100, - designedMinStep: 1, - unit: "%" - }); - - cTypes.push({ - cType: types.WINDOW_COVERING_OPERATION_STATE_CTYPE, - onRegister: function(characteristic) { - if( that.mappings.direction ) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.direction.informId, that); - } - }, - onRead: function(callback) { - if( that.mappings.direction ) - that.query(that.mappings.direction.reading, function(direction){ - callback(direction); - }); - }, - perms: ["pr","ev"], - format: "int", - initialValue: that.mappings.direction?FHEM_cached[that.mappings.direction.informId]:2, - supportEvents: false, - supportBonjour: false, - manfDescription: "Position State", - designedMinValue: 0, - designedMaxValue: 2, - designedMinStep: 1, - }); - } - - if( this.mappings.garage ) { - cTypes.push({ - onUpdate: function(value) { - that.command( 'set', value == 0 ? that.mappings.garage.cmdOpen : that.mappings.garage.cmdClose ); - }, - cType: types.TARGET_DOORSTATE_CTYPE, - onRead: function(callback) { - callback(1); - }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 1, - supportEvents: false, - supportBonjour: false, - manfDescription: "Target GarageDoor Position", - designedMinValue: 0, - designedMaxValue: 1, - designedMinStep: 1, - designedMaxLength: 1 - }); - cTypes.push({ - cType: types.CURRENT_DOOR_STATE_CTYPE, - onRead: function(callback) { - callback(4); - }, - perms: ["pr","ev"], - format: "int", - initialValue: 4, - supportEvents: true, - supportBonjour: false, - manfDescription: "Current GarageDoor State", - designedMinValue: 0, - designedMaxValue: 4, - designedMinStep: 1, - designedMaxLength: 1 - }); - - cTypes.push({ - cType: types.OBSTRUCTION_DETECTED_CTYPE, - onRead: function(callback) { - callback(false); - }, - perms: ["pr","ev"], - format: "bool", - initialValue: 0, - supportEvents: false, - supportBonjour: false, - manfDescription: "Obstruction Detected", - designedMaxLength: 1 - }); - } - - //FIXME: parse range and set designedMinValue & designedMaxValue & designedMinStep - if( this.mappings.thermostat ) { - cTypes.push({ - cType: types.TARGET_TEMPERATURE_CTYPE, - onUpdate: function(value) { that.delayed('targetTemperature', value, 1500); }, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.thermostat.informId, that); - }, - onRead: function(callback) { - that.query(that.mappings.thermostat.reading, function(temperature){ - callback(temperature); - }); - }, - perms: ["pw","pr","ev"], - format: "float", - initialValue: FHEM_cached[that.mappings.thermostat.informId], - supportEvents: false, - supportBonjour: false, - manfDescription: "Target Temperature", - designedMinValue: 5.0, - designedMaxValue: 30.0, - //designedMinStep: 0.5, - unit: "celsius" - }); - cTypes.push({ - cType: types.CURRENTHEATINGCOOLING_CTYPE, - perms: ["pr","ev"], - format: "int", - initialValue: 0, - supportEvents: false, - supportBonjour: false, - manfDescription: "Current Mode", - designedMaxLength: 1, - designedMinValue: 0, - designedMaxValue: 2, - designedMinStep: 1, - }); - cTypes.push({ - cType: types.TARGETHEATINGCOOLING_CTYPE, - onUpdate: function(value) { that.command('targetMode', value); }, - perms: ["pw","pr","ev"], - format: "int", - initialValue: 0, - supportEvents: false, - supportBonjour: false, - manfDescription: "Target Mode", - designedMinValue: 0, - designedMaxValue: 3, - designedMinStep: 1, - }); - - cTypes.push({ - cType: types.TEMPERATURE_UNITS_CTYPE, - perms: ["pr","ev"], - format: "int", - initialValue: 0, - supportEvents: false, - supportBonjour: false, - manfDescription: "Unit", - }); - } - - if( this.mappings.contact ) { - cTypes.push({ - cType: types.CONTACT_SENSOR_STATE_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.contact.informId, that); - }, - onRead: function(callback) { - that.query(that.mappings.contact.reading, function(state){ - callback(state); - }); - }, - perms: ["pr","ev"], - format: "bool", - initialValue: FHEM_cached[that.mappings.contact.informId], - supportEvents: false, - supportBonjour: false, - manfDescription: "Contact State", - designedMaxLength: 1 - }); - } - - if( this.mappings.occupancy ) { - cTypes.push({ - cType: types.OCCUPANCY_DETECTED_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.occupancy.informId, that); - }, - onRead: function(callback) { - that.query(that.mappings.occupancy.reading, function(state){ - callback(state); - }); - }, - perms: ["pr","ev"], - format: "bool", - initialValue: FHEM_cached[that.mappings.occupancy.informId], - supportEvents: false, - supportBonjour: false, - manfDescription: "Occupancy State", - designedMaxLength: 1 - }); - } - - if( this.mappings.temperature ) { - cTypes.push({ - cType: types.CURRENT_TEMPERATURE_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.temperature.informId, that); - }, - onRead: function(callback) { - that.query(that.mappings.temperature.reading, function(temperature){ - callback(temperature); - }); - }, - perms: ["pr","ev"], - format: "float", - initialValue: FHEM_cached[that.mappings.temperature.informId], - supportEvents: true, - supportBonjour: false, - manfDescription: "Current Temperature", - unit: "celsius" - }); - } - - if( this.mappings.humidity ) { - cTypes.push({ - cType: types.CURRENT_RELATIVE_HUMIDITY_CTYPE, - onRegister: function(characteristic) { - characteristic.eventEnabled = true; - FHEM_subscribe(characteristic, that.mappings.humidity.informId, that); - }, - onRead: function(callback) { - that.query(that.mappings.humidity.reading, function(humidity){ - callback(humidity); - }); - }, - perms: ["pr","ev"], - format: "int", - initialValue: FHEM_cached[that.mappings.humidity.informId], - designedMinValue: 0, - designedMaxValue: 100, - supportEvents: true, - supportBonjour: false, - manfDescription: "Current Humidity", - unit: "%" - }); - - } - - return cTypes; - }, - - sType: function() { - if( match = this.PossibleSets.match(/[\^ ]volume\b/) ) { - return types.SPEAKER_STYPE; - } else if( this.isSwitch ) { - return types.SWITCH_STYPE; + if( this.isSwitch ) { + this.log(" switch service for " + this.name) + return new Service.Switch(name); } else if( this.mappings.garage ) { - return types.GARAGE_DOOR_OPENER_STYPE; + this.log(" garage door opener service for " + this.name) + return new Service.GarageDoorOpener(name); } else if( this.mappings.window ) { - return types.WINDOW_STYPE; + this.log(" window service for " + this.name) + return new Service.Window(name); } else if( this.mappings.blind ) { - return types.WINDOW_COVERING_STYPE; + this.log(" window covering service for " + this.name) + return new Service.WindowCovering(name); } else if( this.mappings.thermostat ) { - return types.THERMOSTAT_STYPE; + this.log(" thermostat service for " + this.name) + return new Service.Thermostat(name); } else if( this.mappings.contact ) { - return types.CONTACT_SENSOR_STYPE; + this.log(" contact sensor service for " + this.name) + return new Service.ContactSensor(name); } else if( this.mappings.occupancy ) { - return types.OCCUPANCY_SENSOR_STYPE; + this.log(" occupancy sensor service for " + this.name) + return new Service.OccupancySensor(name); } else if( this.isLight || this.mappings.pct || this.mappings.hue || this.mappings.rgb ) { - return types.LIGHTBULB_STYPE; + this.log(" lightbulb service for " + this.name) + return new Service.Lightbulb(name); } else if( this.mappings.temperature ) { - return types.TEMPERATURE_SENSOR_STYPE; + this.log(" temperature sensor service for " + this.name) + return new Service.TemperatureSensor(name); } else if( this.mappings.humidity ) { - return types.HUMIDITY_SENSOR_STYPE; - } else { - return types.SWITCH_STYPE; + this.log(" humidity sensor service for " + this.name) + return new Service.HumiditySensor(name); + } else if( this.mappings.airquality ) { + this.log(" humidity sensor service for " + this.name) + return new Service.AirQualitySensor(name); } + + this.log(" switch service for " + this.name) + return new Service.Switch(name); + }, + + identify: function(callback) { + this.log('['+this.name+'] identify requested!'); + if( match = this.PossibleSets.match(/[\^ ]toggle\b/) ) { + this.command( 'identify' ); + } + callback(); }, getServices: function() { + this.log("creating services for " + this.name) + + this.log(" information service for " + this.name) + var informationService = new Service.AccessoryInformation(); + + informationService + .setCharacteristic(Characteristic.Manufacturer, "FHEM:"+this.type) + .setCharacteristic(Characteristic.Model, "FHEM:"+this.model ? this.model : '') + .setCharacteristic(Characteristic.SerialNumber, this.serial ? this.serial : ''); + + var controlService = this.createDeviceService(); + var that = this; - var services = [{ - sType: types.ACCESSORY_INFORMATION_STYPE, - characteristics: this.informationCharacteristics(), - }, - { - sType: this.sType(), - characteristics: this.controlCharacteristics(that) - }]; - this.log("Loaded services for " + this.name) - return services; + if( this.mappings.onOff ) { + this.log(" power characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.On); + + FHEM_subscribe(characteristic, that.mappings.onOff.informId, that); + if( FHEM_cached[that.mappings.onOff.informId] != undefined ) + characteristic.value = FHEM_cached[that.mappings.onOff.informId]; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command( 'set', value == 0 ? that.mappings.onOff.cmdOff : that.mappings.onOff.cmdOn ); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.onOff.reading, callback); + }.bind(this) ); + } + + if( this.mappings.pct ) { + this.log(" brightness characteristic for " + this.name) + + var characteristic = controlService.addCharacteristic(Characteristic.Brightness); + + FHEM_subscribe(characteristic, that.mappings.pct.informId, that); + if( FHEM_cached[that.mappings.pct.informId] != undefined ) + characteristic.value = FHEM_cached[that.mappings.pct.informId]; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command('pct', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.pct.reading, callback); + }.bind(this) ); + + } else if( this.hasDim ) { + this.log(" fake brightness characteristic for " + this.name) + + var characteristic = controlService.addCharacteristic(Characteristic.Brightness); + + FHEM_subscribe(characteristic, that.name+'-pct', that); + characteristic.value = 0; + characteristic.maximumValue = this.pctMax; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.delayed('dim', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query('pct', callback); + }.bind(this) ); + + } + + if( that.mappings.hue ) { + this.log(" hue characteristic for " + this.name) + + var characteristic = controlService.addCharacteristic(Characteristic.Hue); + + FHEM_subscribe(characteristic, that.mappings.hue.informId, that); + if( FHEM_cached[that.mappings.hue.informId] != undefined ) + characteristic.value = FHEM_cached[that.mappings.hue.informId]; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command('hue', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.hue.reading, callback); + }.bind(this) ); + + } else if( this.mappings.rgb ) { + this.log(" fake hue characteristic for " + this.name) + + var characteristic = controlService.addCharacteristic(Characteristic.Hue); + + FHEM_subscribe(characteristic, that.name+'-hue', that); + FHEM_subscribe(characteristic, that.mappings.rgb.informId, that); + characteristic.value = 0; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command('H-rgb', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query('hue', callback); + }.bind(this) ); + + if( !this.mappings.sat ) { + this.log(" fake saturation characteristic for " + this.name) + + var characteristic = controlService.addCharacteristic(Characteristic.Saturation); + + FHEM_subscribe(characteristic, that.name+'-sat', that); + characteristic.value = 100; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command('S-rgb', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query('sat', callback); + }.bind(this) ); + } + + if( !this.mappings.pct ) { + this.log(" fake brightness characteristic for " + this.name) + + var characteristic = controlService.addCharacteristic(Characteristic.Brightness); + + FHEM_subscribe(characteristic, that.name+'-bri', that); + characteristic.value = 0; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command('B-rgb', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query('bri', callback); + }.bind(this) ); + } + + } + + if( this.mappings.sat ) { + this.log(" saturation characteristic for " + this.name) + + var characteristic = controlService.addCharacteristic(Characteristic.Saturation); + + FHEM_subscribe(characteristic, that.mappings.sat.informId, that); + if( FHEM_cached[that.mappings.sat.informId] != undefined ) + characteristic.value = FHEM_cached[that.mappings.sat.informId]; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command('sat', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.sat.reading, callback); + }.bind(this) ); + } + + if( this.mappings.volume ) { + this.log(" custom volume characteristic for " + this.name) + + var characteristic = new Characteristic('Volume', '00000027-0000-1000-8000-0026BB765291'); // FIXME!!! + controlService.addCharacteristic(characteristic); + + if( !that.mappings.volume.nocache ) { + FHEM_subscribe(characteristic, that.mappings.volume.informId, that); + characteristic.value = FHEM_cached[that.mappings.volume.informId]; + } else { + characteristic.value = 10; + } + + characteristic.format = 'uint8'; + characteristic.unit = 'percentage'; + characteristic.maximumValue = 100; + characteristic.minimumValue = 0; + characteristic.stepValue = 1; + characteristic.readable = true; + characteristic.writable = true; + characteristic.supportsEventNotification = true; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.delayed('volume', value); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.volume.reading, callback); + }.bind(this) ); + + } + + if( this.mappings.blind ) { + this.log(" current position characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.CurrentPosition); + + FHEM_subscribe(characteristic, that.mappings.blind.informId, that); + characteristic.value = FHEM_cached[that.mappings.blind.informId]; + + characteristic + .on('get', function(callback) { + that.query(that.mappings.blind.reading, callback); + }.bind(this) ); + + + this.log(" target position characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.TargetPosition); + + characteristic.value = FHEM_cached[that.mappings.blind.informId]; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.delayed('targetPosition', value, 1500); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.blind.reading, callback); + }.bind(this) ); + + + this.log(" position state characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.PositionState); + + if( that.mappings.motor ) + FHEM_subscribe(characteristic, that.mappings.motor.informId, that); + characteristic.value = that.mappings.motor?FHEM_cached[that.mappings.motor.informId]:Characteristic.PositionState.STOPPED; + + characteristic + .on('get', function(callback) { + if( that.mappings.motor ) + that.query(that.mappings.motor.reading, callback); + }.bind(this) ); + + } + + if( this.mappings.window ) { + this.log(" current position characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.CurrentPosition); + + FHEM_subscribe(characteristic, that.name+'-state', that); + FHEM_subscribe(characteristic, that.mappings.window.informId, that); + characteristic.value = FHEM_cached[that.mappings.window.informId]; + + characteristic + .on('get', function(callback) { + that.query(that.mappings.window.reading, callback); + }.bind(this) ); + + + this.log(" target position characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.TargetPosition); + + characteristic.value = FHEM_cached[that.mappings.window.informId]; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.delayed('targetPosition', value, 1500); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.window.reading, callback); + }.bind(this) ); + + + this.log(" position state characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.PositionState); + + if( that.mappings.direction ) + FHEM_subscribe(characteristic, that.mappings.direction.informId, that); + characteristic.value = that.mappings.direction?FHEM_cached[that.mappings.direction.informId]:Characteristic.PositionState.STOPPED; + + characteristic + .on('get', function(callback) { + if( that.mappings.direction ) + that.query(that.mappings.direction.reading, callback); + }.bind(this) ); + + } + + if( this.mappings.garage ) { + this.log(" current door state characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.CurrentDoorState); + + characteristic.value = Characteristic.CurrentDoorState.STOPPED; + + characteristic + .on('get', function(callback) { + callback(undefined, Characteristic.CurrentDoorState.STOPPED); + }.bind(this) ); + + + this.log(" target door state characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.TargetDoorState); + + characteristic.value = 1; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.command( 'set', value == 0 ? that.mappings.garage.cmdOpen : that.mappings.garage.cmdClose ); + callback(); + }.bind(this) ) + .on('get', function(callback) { + callback(undefined,0); + }.bind(this) ); + + + this.log(" obstruction detected characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.ObstructionDetected); + + //FHEM_subscribe(characteristic, that.mappings.direction.informId, that); + characteristic.value = 0; + + characteristic + .on('get', function(callback) { + callback(undefined,1); + }.bind(this) ); + + } + + if( this.mappings.temperature ) { + this.log(" temperature characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.CurrentTemperature) + || controlService.addCharacteristic(Characteristic.CurrentTemperature); + + FHEM_subscribe(characteristic, that.mappings.temperature.informId, that); + characteristic.value = FHEM_cached[that.mappings.temperature.informId]; + + characteristic + .on('get', function(callback) { + that.query(that.mappings.temperature.reading, callback); + }.bind(this) ); + + } + + if( this.mappings.humidity ) { + this.log(" humidity characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.CurrentRelativeHumidity) + || controlService.addCharacteristic(Characteristic.CurrentRelativeHumidity); + + FHEM_subscribe(characteristic, that.mappings.humidity.informId, that); + characteristic.value = FHEM_cached[that.mappings.humidity.informId]; + + characteristic + .on('get', function(callback) { + that.query(that.mappings.humidity.reading, callback); + }.bind(this) ); + + } + + if( this.mappings.airquality ) { + this.log(" air quality characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.AirQuality) + || controlService.addCharacteristic(Characteristic.AirQuality); + + FHEM_subscribe(characteristic, that.mappings.airquality.informId, that); + characteristic.value = FHEM_cached[that.mappings.airquality.informId]; + + characteristic + .on('get', function(callback) { + that.query(that.mappings.airquality.reading, callback); + }.bind(this) ); + } + + + //FIXME: parse range and set designedMinValue & designedMaxValue & designedMinStep + if( this.mappings.thermostat ) { + this.log(" target temperature characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.TargetTemperature); + + FHEM_subscribe(characteristic, that.mappings.thermostat.informId, that); + characteristic.value = FHEM_cached[that.mappings.thermostat.informId]; + + characteristic + .on('set', function(value, callback, context) { + if( context !== 'fromFhem' ) + that.delayed('targetTemperature', value, 1500); + callback(); + }.bind(this) ) + .on('get', function(callback) { + that.query(that.mappings.thermostat.reading, callback); + }.bind(this) ); + + } + + if( this.mappings.contact ) { + this.log(" contact sensor characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.ContactSensorState); + + FHEM_subscribe(characteristic, that.mappings.contact.informId, that); + characteristic.value = FHEM_cached[that.mappings.contact.informId]; + + characteristic + .on('get', function(callback) { + that.query(that.mappings.contact.reading, callback); + }.bind(this) ); + + } + + if( this.mappings.occupancy ) { + this.log(" occupancy detected characteristic for " + this.name) + + var characteristic = controlService.getCharacteristic(Characteristic.OccupancyDetected); + + FHEM_subscribe(characteristic, that.mappings.occupancy.informId, that); + characteristic.value = FHEM_cached[that.mappings.occupancy.informId]; + + characteristic + .on('get', function(callback) { + that.query(that.mappings.occupancy.reading, callback); + }.bind(this) ); + + } + + return [informationService, controlService]; } + }; //module.exports.accessory = FHEMAccessory; @@ -1677,47 +1538,23 @@ module.exports.platform = FHEMPlatform; //http server for debugging var http = require('http'); -const FHEMdebug_PORT=8080; +const FHEMdebug_PORT=8081; function FHEMdebug_handleRequest(request, response){ //console.log( request ); if( request.url == "/cached" ) { response.write( "home

" ); - if( FHEM_lastEventTimestamp ) - response.write( "FHEM_lastEventTime: "+ new Date(FHEM_lastEventTimestamp) +"

" ); + if( FHEM_lastEventTime ) + response.write( "FHEM_lastEventTime: "+ new Date(FHEM_lastEventTime) +"

" ); response.end( "cached: " + util.inspect(FHEM_cached).replace(/\n/g, '
') ); } else if( request.url == "/subscriptions" ) { response.write( "home

" ); response.end( "subscriptions: " + util.inspect(FHEM_subscriptions, {depth: 4}).replace(/\n/g, '
') ); - } else if( request.url == "/persist" ) { - response.write( "home

" ); - var unique = {}; - Object.keys(FHEM_subscriptions).forEach(function(key) { - var characteristic = FHEM_subscriptions[key].characteristic; - var info = characteristic.accessoryController.tcpServer.accessoryInfo; - if( unique[info.displayName] ) - return; - unique[info.displayName] = info.username; - - var accessory = FHEM_subscriptions[key].accessory; - - //var cmd = '{$defs{'+ accessory.device +'}->{homekitID} = "'+info.username+'" if(defined($defs{'+ accessory.device +'}));;}'; - //accessory.execute( cmd ); - } ); - - var keys = Object.keys(unique); - keys.sort(); - for( i = 0; i < keys.length; i++ ) { - var k = keys[i]; - response.write( k +': '+ unique[k] +'
' ); - } - response.end( "" ); - } else - response.end( "cached
persist
subscriptions" ); + response.end( "cached
subscriptions" ); } var FHEMdebug_server = http.createServer( FHEMdebug_handleRequest ); diff --git a/platforms/HomeAssistant.js b/platforms/HomeAssistant.js new file mode 100644 index 0000000..5a23ab5 --- /dev/null +++ b/platforms/HomeAssistant.js @@ -0,0 +1,533 @@ +// Home Assistant +// +// Current Support: lights +// +// This is a shim to publish lights maintained by Home Assistant. +// Home Assistant is an open-source home automation platform. +// URL: http://home-assistant.io +// GitHub: https://github.com/balloob/home-assistant +// +// HA accessories supported: Lights, Switches, Media Players, Scenes. +// +// Optional Devices - Edit the supported_types key in the config to pick which +// of the 4 types you would like to expose to HomeKit from +// Home Assistant. light, switch, media_player, scene. +// +// +// Scene Support +// +// You can optionally import your Home Assistant scenes. These will appear to +// HomeKit as switches. You can simply say "turn on party time". In some cases +// scenes names are already rerved in HomeKit...like "Good Morning" and +// "Good Night". You will be able to just say "Good Morning" or "Good Night" to +// have these triggered. +// +// You might want to play with the wording to figure out what ends up working well +// for your scene names. It's also important to not populate any actual HomeKit +// scenes with the same names, as Siri will pick these instead of your Home +// Assistant scenes. +// +// +// +// Media Player Support +// +// Media players on your Home Assistant will be added to your HomeKit as a light. +// While this seems like a hack at first, it's actually quite useful. You can +// turn them on, off, and set their volume (as a function of brightness). +// +// There are some rules to know about how on/off treats your media player. If +// your media player supports play/pause, then turning them on and off via +// HomeKit will play and pause them. If they do not support play/pause but then +// support on/off they will be turned on and then off. +// +// HomeKit does not have a characteristic of Volume or a Speaker type. So we are +// using the light device type here. So to turn your speaker up and down, you +// will need to use the same language you use to set the brighness of a light. +// You can play around with language to see what fits best. +// +// +// +// Examples +// +// Dim the Kitchen Speaker to 40% - sets volume to 40% +// Dim the the Kitchen Speaker 10% - lowers the volume by 10% +// Set the brightness of the Kitchen Speaker to 40% +// +// Remember to add platform to config.json. Example: +// "platforms": [ +// { +// "platform": "HomeAssistant", +// "name": "HomeAssistant", +// "host": "http://192.168.1.50:8123", +// "password": "xxx", +// "supported_types": ["light", "switch", "media_player", "scene"] +// } +// ] +// +// When you attempt to add a device, it will ask for a "PIN code". +// The default code for all HomeBridge accessories is 031-45-154. + +var Service = require("HAP-NodeJS").Service; +var Characteristic = require("HAP-NodeJS").Characteristic; +var url = require('url') +var request = require("request"); + +var communicationError = new Error('Can not communicate with Home Assistant.') + +function HomeAssistantPlatform(log, config){ + + // auth info + this.host = config["host"]; + this.password = config["password"]; + this.supportedTypes = config["supported_types"]; + + this.log = log; +} + +HomeAssistantPlatform.prototype = { + _request: function(method, path, options, callback) { + var self = this + var requestURL = this.host + '/api' + path + options = options || {} + options.query = options.query || {} + + var reqOpts = { + url: url.parse(requestURL), + method: method || 'GET', + qs: options.query, + body: JSON.stringify(options.body), + headers: { + 'Accept': 'application/json', + 'Content-Type': 'application/json', + 'x-ha-access': this.password + } + } + + request(reqOpts, function onResponse(error, response, body) { + if (error) { + callback(error, response) + return + } + + if (response.statusCode === 401) { + callback(new Error('You are not authenticated'), response) + return + } + + json = JSON.parse(body) + callback(error, response, json) + }) + + }, + fetchState: function(entity_id, callback){ + this._request('GET', '/states/' + entity_id, {}, function(error, response, data){ + if (error) { + callback(null) + }else{ + callback(data) + } + }) + }, + callService: function(domain, service, service_data, callback){ + var options = {} + options.body = service_data + + this._request('POST', '/services/' + domain + '/' + service, options, function(error, response, data){ + if (error) { + callback(null) + }else{ + callback(data) + } + }) + }, + accessories: function(callback) { + this.log("Fetching HomeAssistant devices."); + + var that = this; + var foundAccessories = []; + + this._request('GET', '/states', {}, function(error, response, data){ + + for (var i = 0; i < data.length; i++) { + entity = data[i] + entity_type = entity.entity_id.split('.')[0] + + if (that.supportedTypes.indexOf(entity_type) == -1) { + continue; + } + + var accessory = null + + if (entity_type == 'light') { + accessory = new HomeAssistantLight(that.log, entity, that) + }else if (entity_type == 'switch'){ + accessory = new HomeAssistantSwitch(that.log, entity, that) + }else if (entity_type == 'scene'){ + accessory = new HomeAssistantSwitch(that.log, entity, that, 'scene') + }else if (entity_type == 'media_player' && entity.attributes && entity.attributes.supported_media_commands){ + accessory = new HomeAssistantMediaPlayer(that.log, entity, that) + } + + if (accessory) { + foundAccessories.push(accessory) + } + } + + callback(foundAccessories) + }) + + } +} + +function HomeAssistantLight(log, data, client) { + // device info + this.domain = "light" + this.data = data + this.entity_id = data.entity_id + if (data.attributes && data.attributes.friendly_name) { + this.name = data.attributes.friendly_name + }else{ + this.name = data.entity_id.split('.').pop().replace(/_/g, ' ') + } + + this.client = client + this.log = log; +} + +HomeAssistantLight.prototype = { + getPowerState: function(callback){ + this.log("fetching power state for: " + this.name); + + this.client.fetchState(this.entity_id, function(data){ + if (data) { + powerState = data.state == 'on' + callback(null, powerState) + }else{ + callback(communicationError) + } + }.bind(this)) + }, + getBrightness: function(callback){ + this.log("fetching brightness for: " + this.name); + + this.client.fetchState(this.entity_id, function(data){ + if (data && data.attributes) { + brightness = ((data.attributes.brightness || 0) / 255)*100 + callback(null, brightness) + }else{ + callback(communicationError) + } + }.bind(this)) + }, + setPowerState: function(powerOn, callback) { + var that = this; + var service_data = {} + service_data.entity_id = this.entity_id + + if (powerOn) { + this.log("Setting power state on the '"+this.name+"' to on"); + + this.client.callService(this.domain, 'turn_on', service_data, function(data){ + if (data) { + that.log("Successfully set power state on the '"+that.name+"' to on"); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + }else{ + this.log("Setting power state on the '"+this.name+"' to off"); + + this.client.callService(this.domain, 'turn_off', service_data, function(data){ + if (data) { + that.log("Successfully set power state on the '"+that.name+"' to off"); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + } + }, + setBrightness: function(level, callback) { + var that = this; + var service_data = {} + service_data.entity_id = this.entity_id + + service_data.brightness = 255*(level/100.0) + + this.log("Setting brightness on the '"+this.name+"' to " + level); + + this.client.callService(this.domain, 'turn_on', service_data, function(data){ + if (data) { + that.log("Successfully set brightness on the '"+that.name+"' to " + level); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + }, + getServices: function() { + var lightbulbService = new Service.Lightbulb(); + var informationService = new Service.AccessoryInformation(); + + informationService + .setCharacteristic(Characteristic.Manufacturer, "Home Assistant") + .setCharacteristic(Characteristic.Model, "Light") + .setCharacteristic(Characteristic.SerialNumber, "xxx"); + + lightbulbService + .getCharacteristic(Characteristic.On) + .on('get', this.getPowerState.bind(this)) + .on('set', this.setPowerState.bind(this)); + + lightbulbService + .addCharacteristic(Characteristic.Brightness) + .on('get', this.getBrightness.bind(this)) + .on('set', this.setBrightness.bind(this)); + + return [informationService, lightbulbService]; + } + +} + +function HomeAssistantMediaPlayer(log, data, client) { + var SUPPORT_PAUSE = 1 + var SUPPORT_SEEK = 2 + var SUPPORT_VOLUME_SET = 4 + var SUPPORT_VOLUME_MUTE = 8 + var SUPPORT_PREVIOUS_TRACK = 16 + var SUPPORT_NEXT_TRACK = 32 + var SUPPORT_YOUTUBE = 64 + var SUPPORT_TURN_ON = 128 + var SUPPORT_TURN_OFF = 256 + + // device info + this.domain = "media_player" + this.data = data + this.entity_id = data.entity_id + this.supportsVolume = false + this.supportedMediaCommands = data.attributes.supported_media_commands + + if (data.attributes && data.attributes.friendly_name) { + this.name = data.attributes.friendly_name + }else{ + this.name = data.entity_id.split('.').pop().replace(/_/g, ' ') + } + + if ((this.supportedMediaCommands | SUPPORT_PAUSE) == this.supportedMediaCommands) { + this.onState = "playing" + this.offState = "paused" + this.onService = "media_play" + this.offService = "media_pause" + }else if ((this.supportedMediaCommands | SUPPORT_TURN_ON) == this.supportedMediaCommands && (this.supportedMediaCommands | SUPPORT_TURN_OFF) == this.supportedMediaCommands) { + this.onState = "on" + this.offState = "off" + this.onService = "turn_on" + this.offService = "turn_off" + } + + if ((this.supportedMediaCommands | SUPPORT_VOLUME_SET) == this.supportedMediaCommands) { + this.supportsVolume = true + } + + this.client = client + this.log = log; +} + +HomeAssistantMediaPlayer.prototype = { + getPowerState: function(callback){ + this.log("fetching power state for: " + this.name); + + this.client.fetchState(this.entity_id, function(data){ + if (data) { + powerState = data.state == this.onState + callback(null, powerState) + }else{ + callback(communicationError) + } + }.bind(this)) + }, + getVolume: function(callback){ + this.log("fetching volume for: " + this.name); + that = this + this.client.fetchState(this.entity_id, function(data){ + if (data && data.attributes) { + that.log(JSON.stringify(data.attributes)) + level = data.attributes.volume_level ? data.attributes.volume_level*100 : 0 + callback(null, level) + }else{ + callback(communicationError) + } + }.bind(this)) + }, + setPowerState: function(powerOn, callback) { + var that = this; + var service_data = {} + service_data.entity_id = this.entity_id + + if (powerOn) { + this.log("Setting power state on the '"+this.name+"' to on"); + + this.client.callService(this.domain, this.onService, service_data, function(data){ + if (data) { + that.log("Successfully set power state on the '"+that.name+"' to on"); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + }else{ + this.log("Setting power state on the '"+this.name+"' to off"); + + this.client.callService(this.domain, this.offService, service_data, function(data){ + if (data) { + that.log("Successfully set power state on the '"+that.name+"' to off"); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + } + }, + setVolume: function(level, callback) { + var that = this; + var service_data = {} + service_data.entity_id = this.entity_id + + service_data.volume_level = level/100.0 + + this.log("Setting volume on the '"+this.name+"' to " + level); + + this.client.callService(this.domain, 'volume_set', service_data, function(data){ + if (data) { + that.log("Successfully set volume on the '"+that.name+"' to " + level); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + }, + getServices: function() { + var lightbulbService = new Service.Lightbulb(); + var informationService = new Service.AccessoryInformation(); + + informationService + .setCharacteristic(Characteristic.Manufacturer, "Home Assistant") + .setCharacteristic(Characteristic.Model, "Media Player") + .setCharacteristic(Characteristic.SerialNumber, "xxx"); + + lightbulbService + .getCharacteristic(Characteristic.On) + .on('get', this.getPowerState.bind(this)) + .on('set', this.setPowerState.bind(this)); + + + if (this.supportsVolume) { + lightbulbService + .addCharacteristic(Characteristic.Brightness) + .on('get', this.getVolume.bind(this)) + .on('set', this.setVolume.bind(this)); + } + + return [informationService, lightbulbService]; + } + +} + + +function HomeAssistantSwitch(log, data, client, type) { + // device info + this.domain = type || "switch" + this.data = data + this.entity_id = data.entity_id + if (data.attributes && data.attributes.friendly_name) { + this.name = data.attributes.friendly_name + }else{ + this.name = data.entity_id.split('.').pop().replace(/_/g, ' ') + } + + this.client = client + this.log = log; +} + +HomeAssistantSwitch.prototype = { + getPowerState: function(callback){ + this.log("fetching power state for: " + this.name); + + this.client.fetchState(this.entity_id, function(data){ + if (data) { + powerState = data.state == 'on' + callback(null, powerState) + }else{ + callback(communicationError) + } + }.bind(this)) + }, + setPowerState: function(powerOn, callback) { + var that = this; + var service_data = {} + service_data.entity_id = this.entity_id + + if (powerOn) { + this.log("Setting power state on the '"+this.name+"' to on"); + + this.client.callService(this.domain, 'turn_on', service_data, function(data){ + if (data) { + that.log("Successfully set power state on the '"+that.name+"' to on"); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + }else{ + this.log("Setting power state on the '"+this.name+"' to off"); + + this.client.callService(this.domain, 'turn_off', service_data, function(data){ + if (data) { + that.log("Successfully set power state on the '"+that.name+"' to off"); + callback() + }else{ + callback(communicationError) + } + }.bind(this)) + } + }, + getServices: function() { + var switchService = new Service.Switch(); + var informationService = new Service.AccessoryInformation(); + var model; + + switch (this.domain) { + case "scene": + model = "Scene" + break; + default: + model = "Switch" + } + + informationService + .setCharacteristic(Characteristic.Manufacturer, "Home Assistant") + .setCharacteristic(Characteristic.Model, model) + .setCharacteristic(Characteristic.SerialNumber, "xxx"); + + if (this.domain == 'switch') { + switchService + .getCharacteristic(Characteristic.On) + .on('get', this.getPowerState.bind(this)) + .on('set', this.setPowerState.bind(this)); + + }else{ + switchService + .getCharacteristic(Characteristic.On) + .on('set', this.setPowerState.bind(this)); + } + + return [informationService, switchService]; + } + +} + +module.exports.accessory = HomeAssistantLight; +module.exports.accessory = HomeAssistantMediaPlayer; +module.exports.accessory = HomeAssistantSwitch; +module.exports.platform = HomeAssistantPlatform; diff --git a/platforms/LIFx.js b/platforms/LIFx.js new file mode 100644 index 0000000..79988eb --- /dev/null +++ b/platforms/LIFx.js @@ -0,0 +1,302 @@ +'use strict'; + +// LiFX Platform Shim for HomeBridge +// +// Remember to add platform to config.json. Example: +// "platforms": [ +// { +// "platform": "LIFx", // required +// "name": "LIFx", // required +// "access_token": "access token", // required +// "use_lan": "true" // optional set to "true" (gets and sets over the lan) or "get" (gets only over the lan) +// } +// ], +// +// When you attempt to add a device, it will ask for a "PIN code". +// The default code for all HomeBridge accessories is 031-45-154. +// + +var Service = require("HAP-NodeJS").Service; +var Characteristic = require("HAP-NodeJS").Characteristic; +var lifxRemoteObj = require('lifx-api'); +var lifx_remote; + +var lifxLanObj; +var lifx_lan; +var use_lan; + +function LIFxPlatform(log, config){ + // auth info + this.access_token = config["access_token"]; + + lifx_remote = new lifxRemoteObj(this.access_token); + + // use remote or lan api ? + use_lan = config["use_lan"] || false; + + if (use_lan != false) { + lifxLanObj = require('lifx'); + lifx_lan = lifxLanObj.init(); + } + + this.log = log; +} + +LIFxPlatform.prototype = { + accessories: function(callback) { + this.log("Fetching LIFx devices."); + + var that = this; + var foundAccessories = []; + + lifx_remote.listLights("all", function(body) { + var bulbs = JSON.parse(body); + + for(var i = 0; i < bulbs.length; i ++) { + var accessory = new LIFxBulbAccessory(that.log, bulbs[i]); + foundAccessories.push(accessory); + } + callback(foundAccessories) + }); + } +} + +function LIFxBulbAccessory(log, bulb) { + // device info + this.name = bulb.label; + this.model = bulb.product_name; + this.deviceId = bulb.id; + this.serial = bulb.uuid; + this.capabilities = bulb.capabilities; + this.log = log; +} + +LIFxBulbAccessory.prototype = { + getLan: function(type, callback){ + var that = this; + + if (!lifx_lan.bulbs[this.deviceId]) { + callback(new Error("Device not found"), false); + return; + } + + lifx_lan.requestStatus(); + lifx_lan.on('bulbstate', function(bulb) { + if (callback == null) { + return; + } + + if (bulb.addr.toString('hex') == that.deviceId) { + switch(type) { + case "power": + callback(null, bulb.state.power > 0); + break; + case "brightness": + callback(null, Math.round(bulb.state.brightness * 100 / 65535)); + break; + case "hue": + callback(null, Math.round(bulb.state.hue * 360 / 65535)); + break; + case "saturation": + callback(null, Math.round(bulb.state.saturation * 100 / 65535)); + break; + } + + callback = null + } + }); + }, + getRemote: function(type, callback){ + var that = this; + + lifx_remote.listLights("id:"+ that.deviceId, function(body) { + var bulb = JSON.parse(body); + + if (bulb.connected != true) { + callback(new Error("Device not found"), false); + return; + } + + switch(type) { + case "power": + callback(null, bulb.power == "on" ? 1 : 0); + break; + case "brightness": + callback(null, Math.round(bulb.brightness * 100)); + break; + case "hue": + callback(null, bulb.color.hue); + break; + case "saturation": + callback(null, Math.round(bulb.color.saturation * 100)); + break; + } + }); + }, + identify: function(callback) { + lifx_remote.breatheEffect("id:"+ this.deviceId, 'green', null, 1, 3, false, true, 0.5, function (body) { + callback(); + }); + }, + setLanColor: function(type, value, callback){ + var bulb = lifx_lan.bulbs[this.deviceId]; + + if (!bulb) { + callback(new Error("Device not found"), false); + return; + } + + var state = { + hue: bulb.state.hue, + saturation: bulb.state.saturation, + brightness: bulb.state.brightness, + kelvin: bulb.state.kelvin + }; + + var scale = type == "hue" ? 360 : 100; + + state[type] = Math.round(value * 65535 / scale) & 0xffff; + lifx_lan.lightsColour(state.hue, state.saturation, state.brightness, state.kelvin, 0, bulb); + + callback(null); + }, + setLanPower: function(state, callback){ + var bulb = lifx_lan.bulbs[this.deviceId]; + + if (!bulb) { + callback(new Error("Device not found"), false); + return; + } + + if (state) { + lifx_lan.lightsOn(bulb); + } + else { + lifx_lan.lightsOff(bulb); + } + + callback(null); + }, + setRemoteColor: function(type, value, callback){ + var color; + + switch(type) { + case "brightness": + color = "brightness:" + (value / 100); + break; + case "hue": + color = "hue:" + value; + break; + case "saturation": + color = "saturation:" + (value / 100); + break; + } + + lifx_remote.setColor("id:"+ this.deviceId, color, 0, null, function (body) { + callback(); + }); + }, + setRemotePower: function(state, callback){ + var that = this; + + lifx_remote.setPower("id:"+ that.deviceId, (state == 1 ? "on" : "off"), 0, function (body) { + callback(); + }); + }, + getServices: function() { + var that = this; + var services = [] + var service = new Service.Lightbulb(this.name); + + switch(use_lan) { + case true: + case "true": + // gets and sets over the lan api + service + .getCharacteristic(Characteristic.On) + .on('get', function(callback) { that.getLan("power", callback);}) + .on('set', function(value, callback) {that.setLanPower(value, callback);}); + + service + .addCharacteristic(Characteristic.Brightness) + .on('get', function(callback) { that.getLan("brightness", callback);}) + .on('set', function(value, callback) { that.setLanColor("brightness", value, callback);}); + + if (this.capabilities.has_color == true) { + service + .addCharacteristic(Characteristic.Hue) + .on('get', function(callback) { that.getLan("hue", callback);}) + .on('set', function(value, callback) { that.setLanColor("hue", value, callback);}); + + service + .addCharacteristic(Characteristic.Saturation) + .on('get', function(callback) { that.getLan("saturation", callback);}) + .on('set', function(value, callback) { that.setLanColor("saturation", value, callback);}); + } + break; + case "get": + // gets over the lan api, sets over the remote api + service + .getCharacteristic(Characteristic.On) + .on('get', function(callback) { that.getLan("power", callback);}) + .on('set', function(value, callback) {that.setRemotePower(value, callback);}); + + service + .addCharacteristic(Characteristic.Brightness) + .on('get', function(callback) { that.getLan("brightness", callback);}) + .on('set', function(value, callback) { that.setRemoteColor("brightness", value, callback);}); + + if (this.capabilities.has_color == true) { + service + .addCharacteristic(Characteristic.Hue) + .on('get', function(callback) { that.getLan("hue", callback);}) + .on('set', function(value, callback) { that.setRemoteColor("hue", value, callback);}); + + service + .addCharacteristic(Characteristic.Saturation) + .on('get', function(callback) { that.getLan("saturation", callback);}) + .on('set', function(value, callback) { that.setRemoteColor("saturation", value, callback);}); + } + break; + default: + // gets and sets over the remote api + service + .getCharacteristic(Characteristic.On) + .on('get', function(callback) { that.getRemote("power", callback);}) + .on('set', function(value, callback) {that.setRemotePower(value, callback);}); + + service + .addCharacteristic(Characteristic.Brightness) + .on('get', function(callback) { that.getRemote("brightness", callback);}) + .on('set', function(value, callback) { that.setRemoteColor("brightness", value, callback);}); + + if (this.capabilities.has_color == true) { + service + .addCharacteristic(Characteristic.Hue) + .on('get', function(callback) { that.getRemote("hue", callback);}) + .on('set', function(value, callback) { that.setRemoteColor("hue", value, callback);}); + + service + .addCharacteristic(Characteristic.Saturation) + .on('get', function(callback) { that.getRemote("saturation", callback);}) + .on('set', function(value, callback) { that.setRemoteColor("saturation", value, callback);}); + } + } + + services.push(service); + + service = new Service.AccessoryInformation(); + + service + .setCharacteristic(Characteristic.Manufacturer, "LIFX") + .setCharacteristic(Characteristic.Model, this.model) + .setCharacteristic(Characteristic.SerialNumber, this.serial); + + services.push(service); + + return services; + } +} + +module.exports.accessory = LIFxBulbAccessory; +module.exports.platform = LIFxPlatform; diff --git a/platforms/MiLight.js b/platforms/MiLight.js new file mode 100644 index 0000000..3869e74 --- /dev/null +++ b/platforms/MiLight.js @@ -0,0 +1,242 @@ +/* + +MiLight platform shim for Homebridge +Written by Sam Edwards (https://samedwards.ca/) + +Uses the node-milight-promise library (https://github.com/mwittig/node-milight-promise) which features some code from +applamp.nl (http://www.applamp.nl/service/applamp-api/) and uses other details from (http://www.limitlessled.com/dev/) + +Configure in config.json as follows: + +"platforms": [ + { + "platform":"MiLight", + "name":"MiLight", + "ip_address": "255.255.255.255", + "port": 8899, + "type": "rgbw", + "delay": 30, + "repeat": 3, + "zones":["Kitchen Lamp","Bedroom Lamp","Living Room Lamp","Hallway Lamp"] + } +] + +Where the parameters are: + *platform (required): This must be "MiLight", and refers to the name of the accessory as exported from this file + *name (optional): The display name used for logging output by Homebridge. Best to set to "MiLight" + *ip_address (optional): The IP address of the WiFi Bridge. Default to the broadcast address of 255.255.255.255 if not specified + *port (optional): Port of the WiFi bridge. Defaults to 8899 if not specified + *type (optional): One of either "rgbw", "rgb", or "white", depending on the type of bulb being controlled. This applies to all zones. Defaults to rgbw. + *delay (optional): Delay between commands sent over UDP. Default 30ms. May cause delays when sending a lot of commands. Try decreasing to improve. + *repeat (optional): Number of times to repeat the UDP command for better reliability. Default 3 + *zones (required): An array of the names of the zones, in order, 1-4. Use null if a zone is skipped. RGB lamps can only have a single zone. + +Tips and Tricks: + *Setting the brightness of an rgbw or a white bulb will set it to "night mode", which is dimmer than the lowest brightness setting + *White and rgb bulbs don't support absolute brightness setting, so we just send a brightness up/brightness down command depending + if we got a percentage above/below 50% respectively + *The only exception to the above is that white bulbs support a "maximum brightness" command, so we send that when we get 100% + *Implemented warmer/cooler for white lamps in a similar way to brightnes, except this time above/below 180 degrees on the colour wheel + *I welcome feedback on a better way to work the brightness/hue for white and rgb bulbs + +Troubleshooting: +The node-milight-promise library provides additional debugging output when the MILIGHT_DEBUG environmental variable is set + +TODO: + *Possibly build in some sort of state logging and persistance so that we can answswer HomeKit status queries to the best of our ability + +*/ + +var Service = require("HAP-NodeJS").Service; +var Characteristic = require("HAP-NodeJS").Characteristic; +var Milight = require('node-milight-promise').MilightController; +var commands = require('node-milight-promise').commands; + +module.exports = { + accessory: MiLightAccessory, + platform: MiLightPlatform +} + +function MiLightPlatform(log, config) { + this.log = log; + + this.config = config; +} + +MiLightPlatform.prototype = { + accessories: function(callback) { + var zones = []; + + // Various error checking + if (this.config.zones) { + var zoneLength = this.config.zones.length; + } else { + this.log("ERROR: Could not read zones from configuration."); + return; + } + + if (!this.config["type"]) { + this.log("INFO: Type not specified, defaulting to rgbw"); + this.config["type"] = "rgbw"; + } + + if (zoneLength == 0) { + this.log("ERROR: No zones found in configuration."); + return; + } else if (this.config["type"] == "rgb" && zoneLength > 1) { + this.log("WARNING: RGB lamps only have a single zone. Only the first defined zone will be used."); + zoneLength = 1; + } else if (zoneLength > 4) { + this.log("WARNING: Only a maximum of 4 zones are supported per bridge. Only recognizing the first 4 zones."); + zoneLength = 4; + } + + // Create lamp accessories for all of the defined zones + for (var i=0; i < zoneLength; i++) { + if (!!this.config.zones[i]) { + this.config["name"] = this.config.zones[i]; + this.config["zone"] = i+1; + lamp = new MiLightAccessory(this.log, this.config); + zones.push(lamp); + } + } + if (zones.length > 0) { + callback(zones); + } else { + this.log("ERROR: Unable to find any valid zones"); + return; + } + } +} + +function MiLightAccessory(log, config) { + this.log = log; + + // config info + this.ip_address = config["ip_address"]; + this.port = config["port"]; + this.name = config["name"]; + this.zone = config["zone"]; + this.type = config["type"]; + this.delay = config["delay"]; + this.repeat = config["repeat"]; + + this.light = new Milight({ + ip: this.ip_address, + port: this.port, + delayBetweenCommands: this.delay, + commandRepeat: this.repeat + }); + +} +MiLightAccessory.prototype = { + + setPowerState: function(powerOn, callback) { + if (powerOn) { + this.log("["+this.name+"] Setting power state to on"); + this.light.sendCommands(commands[this.type].on(this.zone)); + } else { + this.log("["+this.name+"] Setting power state to off"); + this.light.sendCommands(commands[this.type].off(this.zone)); + } + callback(); + }, + + setBrightness: function(level, callback) { + if (level == 0) { + // If brightness is set to 0, turn off the lamp + this.log("["+this.name+"] Setting brightness to 0 (off)"); + this.light.sendCommands(commands[this.type].off(this.zone)); + } else if (level <= 2 && (this.type == "rgbw" || this.type == "white")) { + // If setting brightness to 2 or lower, instead set night mode for lamps that support it + this.log("["+this.name+"] Setting night mode", level); + + this.light.sendCommands(commands[this.type].off(this.zone)); + // Ensure we're pausing for 100ms between these commands as per the spec + this.light.pause(100); + this.light.sendCommands(commands[this.type].nightMode(this.zone)); + + } else { + this.log("["+this.name+"] Setting brightness to %s", level); + + // Send on command to ensure we're addressing the right bulb + this.light.sendCommands(commands[this.type].on(this.zone)); + + // If this is an rgbw lamp, set the absolute brightness specified + if (this.type == "rgbw") { + this.light.sendCommands(commands.rgbw.brightness(level)); + } else { + // If this is an rgb or a white lamp, they only support brightness up and down. + // Set brightness up when value is >50 and down otherwise. Not sure how well this works real-world. + if (level >= 50) { + if (this.type == "white" && level == 100) { + // But the white lamps do have a "maximum brightness" command + this.light.sendCommands(commands.white.maxBright(this.zone)); + } else { + this.light.sendCommands(commands[this.type].brightUp()); + } + } else { + this.light.sendCommands(commands[this.type].brightDown()); + } + } + } + callback(); + }, + + setHue: function(value, callback) { + this.log("["+this.name+"] Setting hue to %s", value); + + var hue = Array(value, 0, 0); + + // Send on command to ensure we're addressing the right bulb + this.light.sendCommands(commands[this.type].on(this.zone)); + + if (this.type == "rgbw") { + if (value == 0) { + this.light.sendCommands(commands.rgbw.whiteMode(this.zone)); + } else { + this.light.sendCommands(commands.rgbw.hue(commands.rgbw.hsvToMilightColor(hue))); + } + } else if (this.type == "rgb") { + this.light.sendCommands(commands.rgb.hue(commands.rgbw.hsvToMilightColor(hue))); + } else if (this.type == "white") { + // Again, white lamps don't support setting an absolue colour temp, so trying to do warmer/cooler step at a time based on colour + if (value >= 180) { + this.light.sendCommands(commands.white.cooler()); + } else { + this.light.sendCommands(commands.white.warmer()); + } + } + callback(); + }, + + identify: function(callback) { + this.log("["+this.name+"] Identify requested!"); + callback(); // success + }, + + getServices: function() { + var informationService = new Service.AccessoryInformation(); + + informationService + .setCharacteristic(Characteristic.Manufacturer, "MiLight") + .setCharacteristic(Characteristic.Model, this.type) + .setCharacteristic(Characteristic.SerialNumber, "MILIGHT12345"); + + var lightbulbService = new Service.Lightbulb(); + + lightbulbService + .getCharacteristic(Characteristic.On) + .on('set', this.setPowerState.bind(this)); + + lightbulbService + .addCharacteristic(new Characteristic.Brightness()) + .on('set', this.setBrightness.bind(this)); + + lightbulbService + .addCharacteristic(new Characteristic.Hue()) + .on('set', this.setHue.bind(this)); + + return [informationService, lightbulbService]; + } +}; diff --git a/platforms/Sonos.js b/platforms/Sonos.js index 67d086e..1d19c2f 100644 --- a/platforms/Sonos.js +++ b/platforms/Sonos.js @@ -6,6 +6,8 @@ function SonosPlatform(log, config){ this.config = config; this.name = config["name"]; this.playVolume = config["play_volume"]; + // timeout for device discovery + this.discoveryTimeout = (config.deviceDiscoveryTimeout || 10)*1000; // assume 10sec as a default } SonosPlatform.prototype = { @@ -16,6 +18,18 @@ SonosPlatform.prototype = { // track found devices so we don't add duplicates var roomNamesFound = {}; + // collector array for the devices from callbacks + var devicesFound = []; + // tell the sonos callbacks if timeout already occured + var timeout = false; + + // the timeout event will push the accessories back + setTimeout(function(){ + timeout=true; + callback(devicesFound); + }, this.discoveryTimeout); + + sonos.search(function (device) { that.log("Found device at " + device.host); @@ -26,9 +40,13 @@ SonosPlatform.prototype = { if (!roomNamesFound[roomName]) { roomNamesFound[roomName] = true; that.log("Found playable device - " + roomName); + if (timeout) { + that.log("Ignored: Discovered after timeout (Set deviceDiscoveryTimeout parameter in Sonos section of config.json)"); + } // device is an instance of sonos.Sonos var accessory = new SonosAccessory(that.log, that.config, device, description); - callback([accessory]); + // add it to the collector array + devicesFound.push(accessory); } else { that.log("Ignoring playable device with duplicate room name - " + roomName); diff --git a/platforms/YamahaAVR.js b/platforms/YamahaAVR.js index f0d10c1..f554fa0 100644 --- a/platforms/YamahaAVR.js +++ b/platforms/YamahaAVR.js @@ -1,13 +1,19 @@ var types = require("HAP-NodeJS/accessories/types.js"); var Yamaha = require('yamaha-nodejs'); var mdns = require('mdns'); +//workaround for raspberry pi +var sequence = [ + mdns.rst.DNSServiceResolve(), + 'DNSServiceGetAddrInfo' in mdns.dns_sd ? mdns.rst.DNSServiceGetAddrInfo() : mdns.rst.getaddrinfo({families:[4]}), + mdns.rst.makeAddressesUnique() +]; function YamahaAVRPlatform(log, config){ this.log = log; this.config = config; this.playVolume = config["play_volume"]; this.setMainInputTo = config["setMainInputTo"]; - this.browser = mdns.createBrowser(mdns.tcp('http')); + this.browser = mdns.createBrowser(mdns.tcp('http'), {resolverSequence: sequence}); } YamahaAVRPlatform.prototype = { diff --git a/platforms/ZWayServer.js b/platforms/ZWayServer.js new file mode 100644 index 0000000..5b79c8e --- /dev/null +++ b/platforms/ZWayServer.js @@ -0,0 +1,672 @@ +var debug = require('debug')('ZWayServer'); +var Service = require("HAP-NodeJS").Service; +var Characteristic = require("HAP-NodeJS").Characteristic; +var types = require("HAP-NodeJS/accessories/types.js"); +var request = require("request"); +var tough = require('tough-cookie'); +var Q = require("q"); + +function ZWayServerPlatform(log, config){ + this.log = log; + this.url = config["url"]; + this.login = config["login"]; + this.password = config["password"]; + this.name_overrides = config["name_overrides"]; + this.batteryLow = config["battery_low_level"] || 15; + this.pollInterval = config["poll_interval"] || 2; + this.splitServices= config["split_services"] || false; + this.lastUpdate = 0; + this.cxVDevMap = {}; + this.vDevStore = {}; + this.sessionId = ""; + this.jar = request.jar(new tough.CookieJar()); +} + +ZWayServerPlatform.getVDevTypeKey = function(vdev){ + return vdev.deviceType + (vdev.metrics && vdev.metrics.probeTitle ? "." + vdev.metrics.probeTitle : "") +} + +ZWayServerPlatform.prototype = { + + zwayRequest: function(opts){ + var that = this; + var deferred = Q.defer(); + + opts.jar = true;//this.jar; + opts.json = true; + opts.headers = { + "Cookie": "ZWAYSession=" + this.sessionId + }; + + request(opts, function(error, response, body){ + if(response.statusCode == 401){ + debug("Authenticating..."); + request({ + method: "POST", + url: that.url + 'ZAutomation/api/v1/login', + body: { //JSON.stringify({ + "form": true, + "login": that.login, + "password": that.password, + "keepme": false, + "default_ui": 1 + }, + headers: { + "Accept": "application/json", + "Content-Type": "application/json" + }, + json: true, + jar: true//that.jar + }, function(error, response, body){ + if(response.statusCode == 200){ + that.sessionId = body.data.sid; + opts.headers["Cookie"] = "ZWAYSession=" + that.sessionId; + debug("Authenticated. Resubmitting original request..."); + request(opts, function(error, response, body){ + if(response.statusCode == 200){ + deferred.resolve(body); + } else { + deferred.reject(response); + } + }); + } else { + deferred.reject(response); + } + }); + } else if(response.statusCode == 200) { + deferred.resolve(body); + } else { + deferred.reject(response); + } + }); + return deferred.promise; + } + , + + accessories: function(callback) { + debug("Fetching Z-Way devices..."); + + //TODO: Unify this with getVDevServices, so there's only one place with mapping between service and vDev type. + //Note: Order matters! + var primaryDeviceClasses = [ + "switchBinary", + "thermostat", + "sensorBinary.Door/Window", + "sensorMultilevel.Temperature", + "switchMultilevel" + ]; + + var that = this; + var foundAccessories = []; + + this.zwayRequest({ + method: "GET", + url: this.url + 'ZAutomation/api/v1/devices' + }).then(function(result){ + this.lastUpdate = result.data.updateTime; + + var devices = result.data.devices; + var groupedDevices = {}; + for(var i = 0; i < devices.length; i++){ + var vdev = devices[i]; + if(vdev.tags.indexOf("Homebridge:Skip") >= 0) { debug("Tag says skip!"); continue; } + var gdid = vdev.id.replace(/^(.*?)_zway_(\d+-\d+)-\d.*/, '$1_$2'); + var gd = groupedDevices[gdid] || (groupedDevices[gdid] = {devices: [], types: {}, primary: undefined}); + gd.devices.push(vdev); + gd.types[ZWayServerPlatform.getVDevTypeKey(vdev)] = gd.devices.length - 1; + gd.types[vdev.deviceType] = gd.devices.length - 1; // also include the deviceType only as a possibility + } + //TODO: Make a second pass, re-splitting any devices that don't make sense together + for(var gdid in groupedDevices) { + if(!groupedDevices.hasOwnProperty(gdid)) continue; + + // Debug/log... + debug('Got grouped device ' + gdid + ' consiting of devices:'); + var gd = groupedDevices[gdid]; + for(var j = 0; j < gd.devices.length; j++){ + debug(gd.devices[j].id + " - " + gd.devices[j].deviceType + (gd.devices[j].metrics && gd.devices[j].metrics.probeTitle ? "." + gd.devices[j].metrics.probeTitle : "")); + } + + var accessory = null; + for(var ti = 0; ti < primaryDeviceClasses.length; ti++){ + if(gd.types[primaryDeviceClasses[ti]] !== undefined){ + gd.primary = gd.types[primaryDeviceClasses[ti]]; + var pd = gd.devices[gd.primary]; + var name = pd.metrics && pd.metrics.title ? pd.metrics.title : pd.id; + debug("Using primary device with type " + primaryDeviceClasses[ti] + ", " + name + " (" + pd.id + ") as primary."); + accessory = new ZWayServerAccessory(name, gd, that); + break; + } + } + + if(!accessory) + debug("WARN: Didn't find suitable device class!"); + else + foundAccessories.push(accessory); + + } +//foundAccessories = foundAccessories.slice(0, 10); // Limit to a few devices for testing... + callback(foundAccessories); + + // Start the polling process... + this.pollingTimer = setTimeout(this.pollUpdate.bind(this), this.pollInterval*1000); + + }.bind(this)); + + } + , + + pollUpdate: function(){ + //debug("Polling for updates since " + this.lastUpdate + "..."); + return this.zwayRequest({ + method: "GET", + url: this.url + 'ZAutomation/api/v1/devices', + qs: {since: this.lastUpdate} + }).then(function(result){ + this.lastUpdate = result.data.updateTime; + if(result.data && result.data.devices && result.data.devices.length){ + var updates = result.data.devices; + debug("Got " + updates.length + " updates."); + for(var i = 0; i < updates.length; i++){ + var upd = updates[i]; + if(this.cxVDevMap[upd.id]){ + var vdev = this.vDevStore[upd.id]; + vdev.metrics.level = upd.metrics.level; + vdev.updateTime = upd.updateTime; + var cxs = this.cxVDevMap[upd.id]; + for(var j = 0; j < cxs.length; j++){ + var cx = cxs[j]; + if(typeof cx.zway_getValueFromVDev !== "function") continue; + var oldValue = cx.value; + var newValue = cx.zway_getValueFromVDev(vdev); + if(oldValue !== newValue){ + cx.value = newValue; + cx.emit('change', { oldValue:oldValue, newValue:cx.value, context:null }); + debug("Updated characteristic " + cx.displayName + " on " + vdev.metrics.title); + } + } + } + } + } + + // setup next poll... + this.pollingTimer = setTimeout(this.pollUpdate.bind(this), this.pollInterval*1000); + }.bind(this)); + } + +} + +function ZWayServerAccessory(name, devDesc, platform) { + // device info + this.name = name; + this.devDesc = devDesc; + this.platform = platform; + this.log = platform.log; +} + + +ZWayServerAccessory.prototype = { + + getVDev: function(vdev){ + return this.platform.zwayRequest({ + method: "GET", + url: this.platform.url + 'ZAutomation/api/v1/devices/' + vdev.id + })//.then(function()); + } + , + command: function(vdev, command, value) { + return this.platform.zwayRequest({ + method: "GET", + url: this.platform.url + 'ZAutomation/api/v1/devices/' + vdev.id + '/command/' + command, + qs: (value === undefined ? undefined : value) + }); + }, + + getVDevServices: function(vdev){ + var typeKey = ZWayServerPlatform.getVDevTypeKey(vdev); + var services = [], service; + switch (typeKey) { + case "switchBinary": + services.push(new Service.Switch(vdev.metrics.title)); + break; + case "switchMultilevel": + services.push(new Service.Lightbulb(vdev.metrics.title)); + break; + case "thermostat": + services.push(new Service.Thermostat(vdev.metrics.title)); + break; + case "sensorMultilevel.Temperature": + services.push(new Service.TemperatureSensor(vdev.metrics.title)); + break; + case "sensorBinary.Door/Window": + services.push(new Service.GarageDoorOpener(vdev.metrics.title)); + break; + case "battery.Battery": + services.push(new Service.BatteryService(vdev.metrics.title)); + break; + case "sensorMultilevel.Luminiscence": + services.push(new Service.LightSensor(vdev.metrics.title)); + break; + } + + var validServices =[]; + for(var i = 0; i < services.length; i++){ + if(this.configureService(services[i], vdev)) + validServices.push(services[i]); + } + + return validServices; + } + , + uuidToTypeKeyMap: null + , + extraCharacteristicsMap: { + "battery.Battery": [Characteristic.BatteryLevel, Characteristic.StatusLowBattery], + "sensorMultilevel.Temperature": [Characteristic.CurrentTemperature, Characteristic.TemperatureDisplayUnits], + "sensorMultilevel.Luminiscence": [Characteristic.CurrentAmbientLightLevel] + } + , + getVDevForCharacteristic: function(cx, vdevPreferred){ + var map = this.uuidToTypeKeyMap; + if(!map){ + this.uuidToTypeKeyMap = map = {}; + map[(new Characteristic.On).UUID] = ["switchBinary","switchMultilevel"]; + map[(new Characteristic.Brightness).UUID] = ["switchMultilevel"]; + map[(new Characteristic.CurrentTemperature).UUID] = ["sensorMultilevel.Temperature","thermostat"]; + map[(new Characteristic.TargetTemperature).UUID] = ["thermostat"]; + map[(new Characteristic.TemperatureDisplayUnits).UUID] = ["sensorMultilevel.Temperature","thermostat"]; //TODO: Always a fixed result + map[(new Characteristic.CurrentHeatingCoolingState).UUID] = ["thermostat"]; //TODO: Always a fixed result + map[(new Characteristic.TargetHeatingCoolingState).UUID] = ["thermostat"]; //TODO: Always a fixed result + map[(new Characteristic.CurrentDoorState).UUID] = ["sensorBinary.Door/Window","sensorBinary"]; + map[(new Characteristic.TargetDoorState).UUID] = ["sensorBinary.Door/Window","sensorBinary"]; //TODO: Always a fixed result + map[(new Characteristic.ObstructionDetected).UUID] = ["sensorBinary.Door/Window","sensorBinary"]; //TODO: Always a fixed result + map[(new Characteristic.BatteryLevel).UUID] = ["battery.Battery"]; + map[(new Characteristic.StatusLowBattery).UUID] = ["battery.Battery"]; + map[(new Characteristic.ChargingState).UUID] = ["battery.Battery"]; //TODO: Always a fixed result + map[(new Characteristic.CurrentAmbientLightLevel).UUID] = ["sensorMultilevel.Luminiscence"]; + } + + if(cx instanceof Characteristic.Name) return vdevPreferred; + + // Special case!: If cx is a CurrentTemperature, ignore the preferred device...we want the sensor if available! + if(cx instanceof Characteristic.CurrentTemperature) vdevPreferred = null; + // + + var typekeys = map[cx.UUID]; + if(typekeys === undefined) return null; + + if(vdevPreferred && typekeys.indexOf(ZWayServerPlatform.getVDevTypeKey(vdevPreferred)) >= 0){ + return vdevPreferred; + } + + var candidates = this.devDesc.devices; + for(var i = 0; i < typekeys.length; i++){ + for(var j = 0; j < candidates.length; j++){ + if(ZWayServerPlatform.getVDevTypeKey(candidates[j]) === typekeys[i]) return candidates[j]; + } + } + + return null; + } + , + configureCharacteristic: function(cx, vdev){ + var that = this; + + // Add this combination to the maps... + if(!this.platform.cxVDevMap[vdev.id]) this.platform.cxVDevMap[vdev.id] = []; + this.platform.cxVDevMap[vdev.id].push(cx); + if(!this.platform.vDevStore[vdev.id]) this.platform.vDevStore[vdev.id] = vdev; + + if(cx instanceof Characteristic.Name){ + cx.zway_getValueFromVDev = function(vdev){ + return vdev.metrics.title; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + callback(false, that.name); + }); + cx.writable = false; + return cx; + } + + if(cx instanceof Characteristic.On){ + cx.zway_getValueFromVDev = function(vdev){ + var val = false; + if(vdev.metrics.level === "on"){ + val = true; + } else if(vdev.metrics.level <= 5) { + val = false; + } else if (vdev.metrics.level > 5) { + val = true; + } + return val; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + cx.on('set', function(powerOn, callback){ + this.command(vdev, powerOn ? "on" : "off").then(function(result){ + callback(); + }); + }.bind(this)); + cx.on('change', function(ev){ + debug("Device " + vdev.metrics.title + ", characteristic " + cx.displayName + " changed from " + ev.oldValue + " to " + ev.newValue); + }); + return cx; + } + + if(cx instanceof Characteristic.Brightness){ + cx.zway_getValueFromVDev = function(vdev){ + return vdev.metrics.level; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + cx.on('set', function(level, callback){ + this.command(vdev, "exact", {level: parseInt(level, 10)}).then(function(result){ + callback(); + }); + }.bind(this)); + return cx; + } + + if(cx instanceof Characteristic.CurrentTemperature){ + cx.zway_getValueFromVDev = function(vdev){ + return vdev.metrics.level; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + cx.minimumValue = vdev.metrics && vdev.metrics.min !== undefined ? vdev.metrics.min : -40; + cx.maximumValue = vdev.metrics && vdev.metrics.max !== undefined ? vdev.metrics.max : 999; + return cx; + } + + if(cx instanceof Characteristic.TargetTemperature){ + cx.zway_getValueFromVDev = function(vdev){ + return vdev.metrics.level; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + cx.on('set', function(level, callback){ + this.command(vdev, "exact", {level: parseInt(level, 10)}).then(function(result){ + //debug("Got value: " + result.data.metrics.level + ", for " + vdev.metrics.title + "."); + callback(); + }); + }.bind(this)); + cx.minimumValue = vdev.metrics && vdev.metrics.min !== undefined ? vdev.metrics.min : 5; + cx.maximumValue = vdev.metrics && vdev.metrics.max !== undefined ? vdev.metrics.max : 40; + return cx; + } + + if(cx instanceof Characteristic.TemperatureDisplayUnits){ + //TODO: Always in °C for now. + cx.zway_getValueFromVDev = function(vdev){ + return Characteristic.TemperatureDisplayUnits.CELSIUS; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + callback(false, Characteristic.TemperatureDisplayUnits.CELSIUS); + }); + cx.writable = false; + return cx; + } + + if(cx instanceof Characteristic.CurrentHeatingCoolingState){ + //TODO: Always HEAT for now, we don't have an example to work with that supports another function. + cx.zway_getValueFromVDev = function(vdev){ + return Characteristic.CurrentHeatingCoolingState.HEAT; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + callback(false, Characteristic.CurrentHeatingCoolingState.HEAT); + }); + return cx; + } + + if(cx instanceof Characteristic.TargetHeatingCoolingState){ + //TODO: Always HEAT for now, we don't have an example to work with that supports another function. + cx.zway_getValueFromVDev = function(vdev){ + return Characteristic.TargetHeatingCoolingState.HEAT; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + callback(false, Characteristic.TargetHeatingCoolingState.HEAT); + }); + // Hmm... apparently if this is not setable, we can't add a thermostat change to a scene. So, make it writable but a no-op. + cx.writable = true; + cx.on('set', function(newValue, callback){ + debug("WARN: Set of TargetHeatingCoolingState not yet implemented, resetting to HEAT!") + callback(undefined, Characteristic.TargetHeatingCoolingState.HEAT); + }.bind(this)); + return cx; + } + + if(cx instanceof Characteristic.CurrentDoorState){ + cx.zway_getValueFromVDev = function(vdev){ + return vdev.metrics.level === "off" ? Characteristic.CurrentDoorState.CLOSED : Characteristic.CurrentDoorState.OPEN; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + cx.on('change', function(ev){ + debug("Device " + vdev.metrics.title + ", characteristic " + cx.displayName + " changed from " + ev.oldValue + " to " + ev.newValue); + }); + } + + if(cx instanceof Characteristic.TargetDoorState){ + //TODO: We only support this for Door sensors now, so it's a fixed value. + cx.zway_getValueFromVDev = function(vdev){ + return Characteristic.TargetDoorState.CLOSED; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + callback(false, Characteristic.TargetDoorState.CLOSED); + }); + //cx.readable = false; + cx.writable = false; + } + + if(cx instanceof Characteristic.ObstructionDetected){ + //TODO: We only support this for Door sensors now, so it's a fixed value. + cx.zway_getValueFromVDev = function(vdev){ + return false; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + callback(false, false); + }); + //cx.readable = false; + cx.writable = false; + } + + if(cx instanceof Characteristic.BatteryLevel){ + cx.zway_getValueFromVDev = function(vdev){ + return vdev.metrics.level; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + } + + if(cx instanceof Characteristic.StatusLowBattery){ + cx.zway_getValueFromVDev = function(vdev){ + return vdev.metrics.level <= that.platform.batteryLow ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + } + + if(cx instanceof Characteristic.ChargingState){ + //TODO: No known chargeable devices(?), so always return false. + cx.zway_getValueFromVDev = function(vdev){ + return Characteristic.ChargingState.NOT_CHARGING; + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + callback(false, Characteristic.ChargingState.NOT_CHARGING); + }); + //cx.readable = false; + cx.writable = false; + } + + if(cx instanceof Characteristic.CurrentAmbientLightLevel){ + cx.zway_getValueFromVDev = function(vdev){ + if(vdev.metrics.scaleTitle === "%"){ + // Completely unscientific guess, based on test-fit data and Wikipedia real-world lux values. + // This will probably change! + var lux = 0.0005 * (vdev.metrics.level^3.6); + if(lux < cx.minimumValue) return cx.minimumValue; + if(lux > cx.maximumValue) return cx.maximumValue; + return lux; + } else { + return vdev.metrics.level; + } + }; + cx.value = cx.zway_getValueFromVDev(vdev); + cx.on('get', function(callback, context){ + debug("Getting value for " + vdev.metrics.title + ", characteristic \"" + cx.displayName + "\"..."); + this.getVDev(vdev).then(function(result){ + debug("Got value: " + cx.zway_getValueFromVDev(result.data) + ", for " + vdev.metrics.title + "."); + callback(false, cx.zway_getValueFromVDev(result.data)); + }); + }.bind(this)); + cx.on('change', function(ev){ + debug("Device " + vdev.metrics.title + ", characteristic " + cx.displayName + " changed from " + ev.oldValue + " to " + ev.newValue); + }); + return cx; + } + } + , + configureService: function(service, vdev){ + var success = true; + for(var i = 0; i < service.characteristics.length; i++){ + var cx = service.characteristics[i]; + var vdev = this.getVDevForCharacteristic(cx, vdev); + if(!vdev){ + success = false; + debug("ERROR! Failed to configure required characteristic \"" + service.characteristics[i].displayName + "\"!"); + } + cx = this.configureCharacteristic(cx, vdev); + } + for(var i = 0; i < service.optionalCharacteristics.length; i++){ + var cx = service.optionalCharacteristics[i]; + var vdev = this.getVDevForCharacteristic(cx); + if(!vdev) continue; + cx = this.configureCharacteristic(cx, vdev); + if(cx) service.addCharacteristic(cx); + } + return success; + } + , + getServices: function() { + var that = this; + + var informationService = new Service.AccessoryInformation(); + + informationService + .setCharacteristic(Characteristic.Name, this.name) + .setCharacteristic(Characteristic.Manufacturer, "Z-Wave.me") + .setCharacteristic(Characteristic.Model, "Virtual Device (VDev version 1)") + .setCharacteristic(Characteristic.SerialNumber, "VDev-" + this.devDesc.devices[this.devDesc.primary].h) //FIXME: Is this valid?); + + var services = [informationService]; + + services = services.concat(this.getVDevServices(this.devDesc.devices[this.devDesc.primary])); + + if(this.platform.splitServices){ + if(this.devDesc.types["battery.Battery"]){ + services = services.concat(this.getVDevServices(this.devDesc.devices[this.devDesc.types["battery.Battery"]])); + } + + // Odds and ends...if there are sensors that haven't been used, add services for them... + + var tempSensor = this.devDesc.types["sensorMultilevel.Temperature"] !== undefined ? this.devDesc.devices[this.devDesc.types["sensorMultilevel.Temperature"]] : false; + if(tempSensor && !this.platform.cxVDevMap[tempSensor.id]){ + services = services.concat(this.getVDevServices(tempSensor)); + } + + var lightSensor = this.devDesc.types["sensorMultilevel.Luminiscence"] !== undefined ? this.devDesc.devices[this.devDesc.types["sensorMultilevel.Luminiscence"]] : false; + if(lightSensor && !this.platform.cxVDevMap[lightSensor.id]){ + services = services.concat(this.getVDevServices(lightSensor)); + } + } else { + // Everything outside the primary service gets added as optional characteristics... + var service = services[1]; + var existingCxUUIDs = {}; + for(var i = 0; i < service.characteristics.length; i++) existingCxUUIDs[service.characteristics[i].UUID] = true; + + for(var i = 0; i < this.devDesc.devices.length; i++){ + var vdev = this.devDesc.devices[i]; + if(this.platform.cxVDevMap[vdev.id]) continue; // Don't double-use anything + var extraCxClasses = this.extraCharacteristicsMap[ZWayServerPlatform.getVDevTypeKey(vdev)]; + var extraCxs = []; + if(!extraCxClasses || extraCxClasses.length === 0) continue; + for(var j = 0; j < extraCxClasses.length; j++){ + var cx = new extraCxClasses[j](); + if(existingCxUUIDs[cx.UUID]) continue; // Don't have two of the same Characteristic type in one service! + var vdev2 = this.getVDevForCharacteristic(cx, vdev); // Just in case...will probably return vdev. + if(!vdev2){ + // Uh oh... one of the extraCxClasses can't be configured! Abort all extras for this vdev! + extraCxs = []; // to wipe out any already setup cxs. + break; + } + this.configureCharacteristic(cx, vdev2); + extraCxs.push(cx); + } + for(var j = 0; j < extraCxs.length; j++) + service.addCharacteristic(extraCxs[j]); + } + } + + debug("Loaded services for " + this.name); + return services; + } +}; + +module.exports.accessory = ZWayServerAccessory; +module.exports.platform = ZWayServerPlatform;