├── bin ├── coap-client-linux └── coap-client-darwin ├── package.json ├── README.md ├── utils.js └── index.js /bin/coap-client-linux: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stenehall/homebridge-ikea/HEAD/bin/coap-client-linux -------------------------------------------------------------------------------- /bin/coap-client-darwin: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/stenehall/homebridge-ikea/HEAD/bin/coap-client-darwin -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "homebridge-ikea", 3 | "version": "0.4.0", 4 | "author": { 5 | "name": "Johan Stenehall", 6 | "email": "stenehall@gmail.com", 7 | "web": "https://stenehall.se" 8 | }, 9 | "description": "Ikea gateway support for homebridge", 10 | "main": "index.js", 11 | "license": "ISC", 12 | "keywords": [ 13 | "homebridge-plugin", 14 | "homebridge-trådfri", 15 | "homebridge-tradfri", 16 | "trådfri", 17 | "tradfri", 18 | "ikea" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git://github.com/stenehall/homebridge-ikea.git" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/stenehall/homebridge-ikea/issues" 26 | }, 27 | "engines": { 28 | "node": ">=7.7.2", 29 | "homebridge": ">=0.2.0" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Homebridge Ikea 2 | 3 | A [homebridge](https://github.com/nfarina/homebridge) plugin for [Ikeas Trådfri](http://www.ikea.com/se/sv/catalog/categories/departments/lighting/36812/) lamps using [Ikeas Trådfri gateway](http://www.ikea.com/se/sv/catalog/products/40337806/) with an [Ikea trådfri lightbulb](http://www.ikea.com/se/sv/catalog/products/10318263/). As of now it works just fine for turn lamps on/off and setting the brighness and changing the temperature. 4 | 5 | ## Functionality 6 | 7 | - Find all your Ikea lamps connected to your Gateway. 8 | - Uses provided lamp information from the Gateway. 9 | - Turn on and off your lamps. 10 | - Dim the lamps. 11 | - Control the temperature/kelvin of your lamps. Currently doesn't work in HomeKit app, only tested in Eve. 12 | 13 | ## Dependencies 14 | 15 | If you're running macOS or linux the included binaries should work out of the box for you and you shouldn't have to provide your own version. If you're running another OS or if the provided binaries aren't working please as the path to `coap-client` using `coapClient`. Here's how [I compiled the included binaries versions](https://github.com/stenehall/homebridge-ikea/wiki/Compile-coap-client). 16 | 17 | ## Add to your config 18 | 19 | Manually adding all lamps are no fun, right? We want them to just appear for us! 20 | 21 | You'll have to figure out the IP to your gateway yourself (if you've managed to compile coap-client I'm guessing you'll handle that). The PSK will be written under the Gateway. 22 | 23 | ``` 24 | { 25 | "platform": "Ikea", 26 | "name": "Gateway", 27 | "ip": "192.168.x.xxx", 28 | "psk": "xxxxxxxxxxxxxxxx" 29 | } 30 | ``` 31 | 32 | If you need the actual coaps communication for debugging add `debug: true` to your config. 33 | 34 | ## Todos 35 | 36 | - ~~Improve on Kelvin selection~~ (Cheers [sandyjmacdonald](https://github.com/bwssytems/ha-bridge/issues/570#issuecomment-293914023)) 37 | - ~~Get lamp state from Gateway on boot~~ (Cheers [shoghicp](https://github.com/stenehall/homebridge-ikea/pull/2)) 38 | - ~~Don't leak PSKs in log~~ (Cheers [Firehed](https://github.com/stenehall/homebridge-ikea/pull/7)) 39 | - Clean up code, make it actually readable 40 | - Break out all IPSOObjects numbers to utils, hiding it away. 41 | 42 | ## Credits 43 | 44 | Thanks to [r41d](https://github.com/r41d) for figuring out [https://github.com/bwssytems/ha-bridge/issues/570#issuecomment-292188880](https://github.com/bwssytems/ha-bridge/issues/570#issuecomment-292188880 45 | ) 46 | 47 | Thanks to [Hedda](https://github.com/Hedda) for [https://github.com/bwssytems/ha-bridge/issues/570#issuecomment-292081839](https://github.com/bwssytems/ha-bridge/issues/570#issuecomment-292081839) 48 | 49 | And a huge thanks to the rest of the people in [https://github.com/bwssytems/ha-bridge/issues/570](https://github.com/bwssytems/ha-bridge/issues/570) 50 | -------------------------------------------------------------------------------- /utils.js: -------------------------------------------------------------------------------- 1 | var execSync = require('child_process').execSync 2 | 3 | const coap = (method, config, payload = "{}") => `${config.coapClient} -u "Client_identity" -k "${config.psk}" -e '${payload}' -m ${method} coaps://${config.ip}/15001/` 4 | 5 | const put = (config, id, payload) => coap("put", config, payload) + id 6 | const get = (config, id="") => coap("get", config) + id 7 | 8 | const kelvinToProcent = kelvin => (kelvin - 2200) / 18 // 4000 9 | const procentToKelvin = procent => 2200 + (18 * procent) // 4000 10 | const colorX = procent => Math.round(33135 - (82.05 * procent)) // 24930 11 | const colorY = procent => Math.round(27211 - (25.17 * (100 - procent))) // 24694 12 | 13 | const getKelvin = colorX => procentToKelvin(Math.round((33135 - colorX) / 82.05)) 14 | 15 | module.exports.getKelvin = getKelvin 16 | 17 | module.exports.setBrightness = (config, id, brightness, callback) => { 18 | const arguments = `{ "3311" : [{ "5851" : ${brightness}} ] }` 19 | const cmd = put(config, id, arguments) 20 | 21 | if (config.debug) { 22 | config.log(`Setting brightness of ${brightness} for ${id}`) 23 | config.log(cmd) 24 | } 25 | 26 | callback(execSync(cmd, {encoding: "utf8"})) 27 | } 28 | 29 | module.exports.setKelvin = (config, id, kelvin, callback) => { 30 | const arguments = `{ "3311" : [{ "5709" : ${colorX(kelvinToProcent(kelvin))}, "5710": ${colorY(kelvinToProcent(kelvin))} }] }` 31 | var cmd = put(config, id, arguments) 32 | 33 | if (config.debug) { 34 | config.log(cmd) 35 | } 36 | callback(execSync(cmd, {encoding: "utf8"})) 37 | } 38 | 39 | // Source: http://stackoverflow.com/a/9493060 40 | const hslToRgb = (h, s, l) => { 41 | var r, g, b; 42 | 43 | if(s == 0){ 44 | r = g = b = l; // achromatic 45 | } else { 46 | var hue2rgb = function hue2rgb(p, q, t){ 47 | if(t < 0) t += 1; 48 | if(t > 1) t -= 1; 49 | if(t < 1/6) return p + (q - p) * 6 * t; 50 | if(t < 1/2) return q; 51 | if(t < 2/3) return p + (q - p) * (2/3 - t) * 6; 52 | return p; 53 | } 54 | 55 | var q = l < 0.5 ? l * (1 + s) : l + s - l * s; 56 | var p = 2 * l - q; 57 | r = hue2rgb(p, q, h + 1/3); 58 | g = hue2rgb(p, q, h); 59 | b = hue2rgb(p, q, h - 1/3); 60 | } 61 | 62 | return [Math.round(r * 255), Math.round(g * 255), Math.round(b * 255)]; 63 | } 64 | 65 | const hexToRgb = (hex) => { 66 | var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); 67 | return result ? { 68 | r: parseInt(result[1], 16), 69 | g: parseInt(result[2], 16), 70 | b: parseInt(result[3], 16) 71 | } : null; 72 | } 73 | 74 | const rgbToHsl = (r, g, b) => { 75 | r /= 255, g /= 255, b /= 255; 76 | var max = Math.max(r, g, b), min = Math.min(r, g, b); 77 | var h, s, l = (max + min) / 2; 78 | 79 | if(max == min){ 80 | h = s = 0; // achromatic 81 | }else{ 82 | var d = max - min; 83 | s = l > 0.5 ? d / (2 - max - min) : d / (max + min); 84 | switch(max){ 85 | case r: h = (g - b) / d + (g < b ? 6 : 0); break; 86 | case g: h = (b - r) / d + 2; break; 87 | case b: h = (r - g) / d + 4; break; 88 | } 89 | h /= 6; 90 | } 91 | 92 | return [h, s, l]; 93 | } 94 | 95 | // Source http://stackoverflow.com/a/36061908 96 | const rgbToXy = (red,green,blue) => { 97 | red = (red > 0.04045) ? Math.pow((red + 0.055) / (1.0 + 0.055), 2.4) : (red / 12.92); 98 | green = (green > 0.04045) ? Math.pow((green + 0.055) / (1.0 + 0.055), 2.4) : (green / 12.92); 99 | blue = (blue > 0.04045) ? Math.pow((blue + 0.055) / (1.0 + 0.055), 2.4) : (blue / 12.92); 100 | var X = red * 0.664511 + green * 0.154324 + blue * 0.162028; 101 | var Y = red * 0.283881 + green * 0.668433 + blue * 0.047685; 102 | var Z = red * 0.000088 + green * 0.072310 + blue * 0.986039; 103 | var fx = X / (X + Y + Z); 104 | var fy = Y / (X + Y + Z); 105 | return [fx.toPrecision(4),fy.toPrecision(4)]; 106 | } 107 | 108 | module.exports.convertRGBToHSL = (hex) => { 109 | var c = hexToRgb(hex) 110 | return rgbToHsl(c.r, c.g, c.b); 111 | } 112 | 113 | module.exports.setColor = (config, id, color, callback) => { 114 | // First we convert hue and saturation 115 | // to RGB, with 75% lighntess 116 | const rgb = hslToRgb(color.hue, color.saturation, 0.75); 117 | // Then we convert the rgb values to 118 | // CIE L*a*b XY values 119 | const cie = rgbToXy(...rgb).map(item => { 120 | // we need to scale the values 121 | return Math.floor(100000 * item); 122 | }); 123 | 124 | const arguments = `{ "3311" : [{ "5709" : ${cie[0]}, "5710": ${cie[1]} }] }` 125 | const cmd = put(config, id, arguments) 126 | 127 | if (config.debug) { 128 | config.log(cmd) 129 | } 130 | callback(execSync(cmd, {encoding: "utf8"})) 131 | } 132 | 133 | // @TODO: Figure out if the gateway actually don't support this 134 | module.exports.setOnOff = (config, id, state, callback) => { 135 | const arguments = `{ "3311" : [{ "5580" : ${state}} ] }` 136 | var cmd = put(config, id, arguments) 137 | 138 | if (config.debug) { 139 | config.log(cmd) 140 | } 141 | callback(execSync(cmd, {encoding: "utf8"})) 142 | } 143 | 144 | const parseDeviceList = str => { 145 | const split = str.trim().split("\n") 146 | return split.pop().slice(1,-1).split(",") 147 | } 148 | 149 | module.exports.getDevices = config => new Promise((resolve, reject) => { 150 | var cmd = get(config) 151 | if (config.debug) { 152 | config.log(cmd) 153 | } 154 | 155 | resolve(parseDeviceList(execSync(cmd, {encoding: "utf8"}))) 156 | }) 157 | 158 | const parseDevice = str => { 159 | const split = str.trim().split("\n") 160 | const json = JSON.parse(split.pop()) 161 | 162 | return { 163 | name: json["9001"], 164 | type: json["5750"], 165 | createdAt: json["9002"], 166 | instanceId: json["9003"], 167 | details: json["3"], 168 | reachabilityState: json["9019"], 169 | lastSeen: json["9020"], 170 | otaUpdateState: json["9054"], 171 | switch: json["15009"], 172 | light: json["3311"] 173 | } 174 | 175 | /* 176 | light: { 177 | { 178 | onoff: json["3311"]["5580"], 179 | dimmer: json["3311"]["5851"], 180 | color_x: json["3311"]["5709"], 181 | color_y: json["3311"]["5710"], 182 | color: json["3311"]["5706"], 183 | instance_id: json["3311"]["9003"], 184 | "5707":0, 185 | "5708":0, 186 | "5711":0, 187 | } 188 | } 189 | */ 190 | } 191 | 192 | module.exports.getDevice = (config, id) => new Promise((resolve, reject) => { 193 | 194 | var cmd = get(config, id) 195 | if (config.debug) { 196 | config.log(`Get device information for: ${id}`) 197 | config.log(cmd) 198 | } 199 | 200 | resolve(parseDevice(execSync(cmd, {encoding: "utf8"}))) 201 | 202 | }) 203 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | const utils = require('./utils') 2 | const util = require('util') 3 | const os = require('os') 4 | 5 | let Kelvin, Accessory, Service, Characteristic, UUIDGen 6 | 7 | const UUID_KELVIN = 'C4E24248-04AC-44AF-ACFF-40164E829DBA' 8 | 9 | module.exports = function(homebridge) { 10 | Accessory = homebridge.platformAccessory 11 | Service = homebridge.hap.Service 12 | Characteristic = homebridge.hap.Characteristic 13 | UUIDGen = homebridge.hap.uuid // @TODO: Should be using this 14 | 15 | Characteristic.Kelvin = function() { 16 | Characteristic.call(this, 'Kelvin', UUID_KELVIN) 17 | 18 | this.setProps({ 19 | format: Characteristic.Formats.INT, 20 | unit: 'Kelvin', 21 | maxValue: 4000, 22 | minValue: 2200, 23 | minStep: 1, 24 | perms: [Characteristic.Perms.READ, Characteristic.Perms.WRITE] 25 | }); 26 | 27 | this.value = this.getDefaultValue(); 28 | } 29 | util.inherits(Characteristic.Kelvin, Characteristic); 30 | Characteristic.Kelvin.UUID = UUID_KELVIN 31 | 32 | homebridge.registerPlatform("homebridge-ikea", "Ikea", IkeaPlatform) 33 | } 34 | 35 | function IkeaPlatform(log, config) { 36 | this.log = log 37 | this.config = config 38 | this.config.log = this.log 39 | this.devices = [] 40 | 41 | if (!this.config.coapClient && (os.platform() !== "darwin" && os.platform() !== "linux")) { 42 | throw Error("No coap-client found, please specify the path to it using coapClient") 43 | } 44 | this.config.coapClient = this.config.coapClient || `${__dirname}/bin/coap-client-${os.platform()}` 45 | 46 | } 47 | 48 | IkeaPlatform.prototype = { 49 | accessories: async function(callback) { 50 | const self = this 51 | const foundAccessories = [] 52 | 53 | const devices = await utils.getDevices(self.config) 54 | 55 | await Promise.all(devices.map(async deviceId => { 56 | const device = await utils.getDevice(self.config, deviceId) 57 | if (device.type === 2) { 58 | foundAccessories.push(new IkeaAccessory(self.log, self.config, device)) 59 | } 60 | })) 61 | 62 | callback(foundAccessories) 63 | } 64 | } 65 | 66 | function IkeaAccessory(log, config, device) { 67 | this.name = device.name 68 | this.config = config 69 | this.config.log = string => log("[" + this.name + "] " + string) 70 | this.device = device 71 | 72 | this.currentBrightness = this.device.light[0]["5851"] 73 | this.currentState = this.device.light[0]["5850"] 74 | this.previousBrightness = this.currentBrightness 75 | this.color = {} 76 | } 77 | 78 | IkeaAccessory.prototype = { 79 | // Respond to identify request 80 | identify: function(callback) { 81 | this.config.log("Hi!") 82 | callback() 83 | }, 84 | 85 | getServices: function() { 86 | const accessoryInformation = new Service.AccessoryInformation() 87 | accessoryInformation 88 | .setCharacteristic(Characteristic.Name, this.device.name) 89 | .setCharacteristic(Characteristic.Manufacturer, this.device.details["0"]) 90 | .setCharacteristic(Characteristic.Model, this.device.details["1"]) 91 | .setCharacteristic(Characteristic.FirmwareRevision, this.device.details["3"]) 92 | 93 | const self = this 94 | 95 | const lightbulbService = new Service.Lightbulb(self.name) 96 | 97 | lightbulbService 98 | .addCharacteristic(Characteristic.StatusActive) 99 | 100 | lightbulbService 101 | .setCharacteristic(Characteristic.StatusActive, this.device.reachabilityState) 102 | .setCharacteristic(Characteristic.On, this.device.light[0]["5850"]) 103 | .setCharacteristic(Characteristic.Brightness, parseInt(Math.round(this.device.light[0]["5851"] * 100 / 255))) 104 | 105 | 106 | lightbulbService 107 | .getCharacteristic(Characteristic.StatusActive) 108 | .on('get', callback => { 109 | utils.getDevice(self.config, self.device.instanceId).then(device => { 110 | callback(null, device.reachabilityState) 111 | }) 112 | }) 113 | 114 | lightbulbService 115 | .getCharacteristic(Characteristic.On) 116 | .on('get', callback => { 117 | utils.getDevice(self.config, self.device.instanceId).then(device => { 118 | self.currentBrightness = device.light[0]["5851"] 119 | self.currentState = device.light[0]["5850"] 120 | callback(null, self.currentState) 121 | }) 122 | }) 123 | .on('set', (state, callback) => { 124 | if (typeof state !== 'number') { 125 | state = state ? 1 : 0; 126 | } 127 | 128 | if (self.currentState == 1 && state == 0) { // We're turned on but want to turn off. 129 | self.currentState = 0 130 | utils.setBrightness(self.config, self.device.instanceId, 0, result => callback()) 131 | } else if(self.currentState == 0 && state == 1) { 132 | self.currentState = 1 133 | utils.setBrightness(self.config, self.device.instanceId, (self.currentBrightness > 1 ? self.currentBrightness : 255), result => callback()) 134 | } else { 135 | callback() 136 | } 137 | }) 138 | 139 | lightbulbService 140 | .getCharacteristic(Characteristic.Brightness) 141 | .on('get', callback => { 142 | utils.getDevice(self.config, self.device.instanceId).then(device => { 143 | self.currentBrightness = device.light[0]["5851"] 144 | self.currentState = device.light[0]["5850"] 145 | callback(null, parseInt(Math.round(self.currentBrightness * 100 / 255))) 146 | }) 147 | }) 148 | .on('set', (powerOn, callback) => { 149 | self.currentBrightness = Math.floor(255 * (powerOn / 100)) 150 | utils.setBrightness(self.config, self.device.instanceId, Math.round(255 * (powerOn / 100)), result => callback()) 151 | }) 152 | 153 | if(typeof this.device.light[0]["5706"] !== 'undefined'){ 154 | if(this.device.light[0]["5706"].length < 6){ 155 | this.device.light[0]["5706"] = "ffcea6" //Default value when it was offline 156 | } 157 | 158 | var hsl = utils.convertRGBToHSL(this.device.light[0]["5706"]); 159 | 160 | lightbulbService 161 | .addCharacteristic(Characteristic.Kelvin) 162 | 163 | lightbulbService 164 | .setCharacteristic(Characteristic.Kelvin, utils.getKelvin(this.device.light[0]["5709"])) 165 | .setCharacteristic(Characteristic.Hue, hsl[0] * 360) 166 | .setCharacteristic(Characteristic.Saturation, hsl[1] * 100) 167 | 168 | 169 | lightbulbService 170 | .getCharacteristic(Characteristic.Kelvin) 171 | .on('get', callback => { 172 | utils.getDevice(self.config, self.device.instanceId).then(device => { 173 | self.currentKelvin = utils.getKelvin(device.light[0]["5709"]) 174 | callback(null, self.currentKelvin) 175 | }) 176 | }) 177 | .on('set', (kelvin, callback) => { 178 | utils.setKelvin(self.config, self.device.instanceId, kelvin, result => callback()) 179 | }) 180 | 181 | lightbulbService 182 | .getCharacteristic(Characteristic.Hue) 183 | .on('get', callback => { 184 | utils.getDevice(self.config, self.device.instanceId).then(device => { 185 | if(typeof device.light[0]["5706"] !== 'undefined' || device.light[0]["5706"].length < 6){ 186 | device.light[0]["5706"] = "ffcea6" //Default value when it fails polling 187 | } 188 | var hsl = utils.convertRGBToHSL(device.light[0]["5706"]); 189 | callback(null, hsl[0] * 360) 190 | }) 191 | 192 | }) 193 | .on('set', (hue, callback) => { 194 | self.color.hue = hue / 360 195 | if (typeof self.color.saturation !== 'undefined') { 196 | utils.setColor(self.config, self.device.instanceId, self.color, result => callback()) 197 | self.color = {} 198 | }else{ 199 | callback() 200 | } 201 | }) 202 | 203 | lightbulbService 204 | .getCharacteristic(Characteristic.Saturation) 205 | .on('get', callback => { 206 | utils.getDevice(self.config, self.device.instanceId).then(device => { 207 | if(typeof device.light[0]["5706"] !== 'undefined' || device.light[0]["5706"].length < 6){ 208 | device.light[0]["5706"] = "ffcea6" //Default value when it fails polling 209 | } 210 | var hsl = utils.convertRGBToHSL(device.light[0]["5706"]); 211 | callback(null, hsl[1] * 100) 212 | }) 213 | 214 | }) 215 | .on('set', (saturation, callback) => { 216 | self.color.saturation = saturation / 100 217 | if (typeof self.color.hue !== 'undefined') { 218 | utils.setColor(self.config, self.device.instanceId, self.color, result => callback()) 219 | self.color = {} 220 | }else{ 221 | callback() 222 | } 223 | }) 224 | } 225 | 226 | return [accessoryInformation, lightbulbService] 227 | } 228 | } 229 | --------------------------------------------------------------------------------