From e3bbd82ee7869a30bdb0a133bd8807d72a3d8dc3 Mon Sep 17 00:00:00 2001 From: Peter Murray Date: Fri, 19 Dec 2014 09:39:30 +0000 Subject: [PATCH] - Updating the RGB implementation to utilize a conversion to XY co-ordinates, which should be more accurate and fix #17 --- Changelog.md | 3 + hue-api/index.js | 39 +++++--- hue-api/lightstate.js | 12 ++- hue-api/rgb.js | 175 ++++++++++++++++++++++++++++++++++++ package.json | 2 +- test/lightstate-tests.js | 8 +- test/rgb-tests.js | 136 ++++++++++++++++++++++++++++ test/setLightState-tests.js | 9 ++ test/support/testValues.js | 5 +- 9 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 hue-api/rgb.js create mode 100644 test/rgb-tests.js diff --git a/Changelog.md b/Changelog.md index 2cc9061..93884b3 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,8 @@ # Change Log +## 0.2.5 +- Fixes for RGB conversion into XY co-ordinates for lamps to give better accuracy compared to previous implementation using HSL + ## 0.2.4 - Added ability to configure the timeout when communicating with the Hue Bridge diff --git a/hue-api/index.js b/hue-api/index.js index 4fb7a7f..95bde35 100644 --- a/hue-api/index.js +++ b/hue-api/index.js @@ -1,15 +1,17 @@ "use strict"; -var Q = require("q"), - http = require("./httpPromise"), - ApiError = require("./errors").ApiError, - utils = require("./utils"), - lightsApi = require("./commands/lights-api"), - groupsApi = require("./commands/groups-api"), - schedulesApi = require("./commands/schedules-api"), - configurationApi = require("./commands/configuration-api"), - scheduledEvent = require("./scheduledEvent"), - bridgeDiscovery = require("./bridge-discovery"); +var Q = require("q") + , http = require("./httpPromise") + , ApiError = require("./errors").ApiError + , utils = require("./utils") + , rgb = require("./rgb") + , lightsApi = require("./commands/lights-api") + , groupsApi = require("./commands/groups-api") + , schedulesApi = require("./commands/schedules-api") + , configurationApi = require("./commands/configuration-api") + , scheduledEvent = require("./scheduledEvent") + , bridgeDiscovery = require("./bridge-discovery") + ; function HueApi(host, username, timeout) { @@ -301,8 +303,23 @@ HueApi.prototype.setLightState = function (id, stateValues, cb) { options.values = stateValues; if (!promise) { - promise = http.invoke(lightsApi.setLightState, options); + // We have not errored, so check if we need to convert an rgb value + + if (stateValues.rgb) { + promise = this.lightStatus(id) + .then(function(lightDetails) { + options.values.xy = rgb.convertRGBtoXY(stateValues.rgb, lightDetails); + delete options.values.rgb; + }) + .then(function() { + return http.invoke(lightsApi.setLightState, options); + }) + ; + } else { + promise = http.invoke(lightsApi.setLightState, options); + } } + return utils.promiseOrCallback(promise, cb); }; diff --git a/hue-api/lightstate.js b/hue-api/lightstate.js index bd3dbe5..67aff2d 100644 --- a/hue-api/lightstate.js +++ b/hue-api/lightstate.js @@ -1,7 +1,8 @@ "use strict"; -var utils = require("./utils"), - State = function () { +var utils = require("./utils"); + +var State = function () { }; /** @@ -102,7 +103,11 @@ State.prototype.transition = function (seconds) { * @return {State} */ State.prototype.rgb = function (r, g, b) { - utils.combine(this, _getHSLStateFromRGB(r, g, b)); + // The conversion to rgb is now done in the xy space, but to do so requires knowledge of the limits of the light's + // color gamut. + // To cater for this, we store the rgb value requested, and convert it to xy when the user applies it. + utils.combine(this, {rgb: [r, g, b]}); + //utils.combine(this, _getHSLStateFromRGB(r, g, b)); // Was not particularly reliable conversion return this; }; @@ -167,6 +172,7 @@ function _getEffectState(value) { }; } +//TODO this is not that reliable at the extremes of ranges of values /** * Gets the HSL/HSB value from the RGB values provided * @param red diff --git a/hue-api/rgb.js b/hue-api/rgb.js new file mode 100644 index 0000000..48e875d --- /dev/null +++ b/hue-api/rgb.js @@ -0,0 +1,175 @@ +"use strict"; + +var XY = function (x, y) { + this.x = x; + this.y = y; + } + , hueLimits = { + red: new XY(0.675, 0.322), + green: new XY(0.4091, 0.518), + blue: new XY(0.167, 0.04) + } + , livingColorsLimits = { + red: new XY(0.704, 0.296), + green: new XY(0.2151, 0.7106), + blue: new XY(0.138, 0.08) + } + , defaultLimits = { + red: new XY(1.0, 0), + green: new XY(0.0, 1.0), + blue: new XY(0.0, 0.0) + } + ; + +function _crossProduct(p1, p2) { + return (p1.x * p2.y - p1.y * p2.x); +} + +function _isInColorGamut(p, lampLimits) { + var v1 = new XY( + lampLimits.green.x - lampLimits.red.x + , lampLimits.green.y - lampLimits.red.y + ) + , v2 = new XY( + lampLimits.blue.x - lampLimits.red.x + , lampLimits.blue.y - lampLimits.red.y + ) + , q = new XY(p.x - lampLimits.red.x, p.y - lampLimits.red.y) + , s = _crossProduct(q, v2) / _crossProduct(v1, v2) + , t = _crossProduct(v1, q) / _crossProduct(v1, v2) + ; + + return (s >= 0.0) && (t >= 0.0) && (s + t <= 1.0); +} + +/** + * Find the closest point on a line. This point will be reproducible by the limits. + * + * @param start {XY} The point where the line starts. + * @param stop {XY} The point where the line ends. + * @param point {XY} The point which is close to the line. + * @return {XY} A point that is on the line specified, and closest to the XY provided. + */ +function _getClosestPoint(start, stop, point) { + var AP = new XY(point.x - start.x, point.y - start.y) + , AB = new XY(stop.x - start.x, stop.y - start.y) + , ab2 = AB.x * AB.x + AB.y * AB.y + , ap_ab = AP.x * AB.x + AP.y * AB.y + , t = ap_ab / ab2 + ; + + if (t < 0.0) { + t = 0.0; + } else if (t > 1.0) { + t = 1.0; + } + + return new XY( + start.x + AB.x * t + , start.y + AB.y * t + ); +} + +function _getDistanceBetweenPoints(pOne, pTwo) { + var dx = pOne.x - pTwo.x + , dy = pOne.y - pTwo.y + ; + return Math.sqrt(dx * dx + dy * dy); +} + +function _getXYStateFromRGB(red, green, blue, limits) { + var r = _gammaCorrection(red) + , g = _gammaCorrection(green) + , b = _gammaCorrection(blue) + , X = r * 0.4360747 + g * 0.3850649 + b * 0.0930804 + , Y = r * 0.2225045 + g * 0.7168786 + b * 0.0406169 + , Z = r * 0.0139322 + g * 0.0971045 + b * 0.7141733 + , cx = X / (X + Y + Z) + , cy = Y / (X + Y + Z) + , xyPoint + ; + + cx = isNaN(cx) ? 0.0 : cx; + cy = isNaN(cy) ? 0.0 : cy; + + xyPoint = new XY(cx, cy); + + if (!_isInColorGamut(xyPoint, limits)) { + xyPoint = _resolveXYPointForLamp(xyPoint, limits); + } + + return [xyPoint.x, xyPoint.y]; +} + +/** + * When a color is outside the limits, find the closest point on each line in the CIE 1931 'triangle'. + * @param point {XY} The point that is outside the limits + * @param limits The limits of the bulb (red, green and blue XY points). + * @returns {XY} + */ +function _resolveXYPointForLamp(point, limits) { + + var pAB = _getClosestPoint(limits.red, limits.green, point) + , pAC = _getClosestPoint(limits.blue, limits.red, point) + , pBC = _getClosestPoint(limits.green, limits.blue, point) + , dAB = _getDistanceBetweenPoints(point, pAB) + , dAC = _getDistanceBetweenPoints(point, pAC) + , dBC = _getDistanceBetweenPoints(point, pBC) + , lowest = dAB + , closestPoint = pAB + ; + + if (dAC < lowest) { + lowest = dAC; + closestPoint = pAC; + } + + if (dBC < lowest) { + closestPoint = pBC; + } + + return closestPoint; +} + +function _gammaCorrection(value) { + var result = value; + if (value > 0.04045) { + result = Math.pow((value + 0.055) / (1.0 + 0.055), 2.4); + } else { + result = value / 12.92; + } + return result; +} + +function _getLimits(lightDetails) { + var limits = defaultLimits + , modelId + ; + + if (lightDetails.modelid) { + modelId = lightDetails.modelid.toLowerCase(); + + if (/^lct/.test(modelId)) { + // This is a Hue bulb + limits = hueLimits; + } else if (/^llc/.test(modelId)) { + // This is a Living Color lamp (Bloom, Iris, etc..) + limits = livingColorsLimits; + } else if (/^lwb/.test(modelId)) { + // This is a lux bulb + limits = defaultLimits; + } else { + limits = defaultLimits; + } + } + + return limits; +} + +module.exports = { + convertRGBtoXY: function(rgb, lightDetails) { + var limits = _getLimits(lightDetails); + + return _getXYStateFromRGB(rgb[0], rgb[1], rgb[2], limits); + } +}; \ No newline at end of file diff --git a/package.json b/package.json index 58d1a25..b2795a8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "node-hue-api", - "version": "0.2.4", + "version": "0.2.5", "author": "Peter Murray ", "contributors": [ { diff --git a/test/lightstate-tests.js b/test/lightstate-tests.js index c57aa85..5b3b4cc 100644 --- a/test/lightstate-tests.js +++ b/test/lightstate-tests.js @@ -68,9 +68,11 @@ describe("Light State", function () { }); it("'rgb'", function () { - state.rgb(200, 200, 200); - expect(state).to.have.keys("hue", "sat", "bri"); - //TODO could put checks in for values... + state.rgb(200, 100, 0); + expect(state).to.have.keys("rgb"); + expect(state.rgb[0]).to.equal(200); + expect(state.rgb[1]).to.equal(100); + expect(state.rgb[2]).to.equal(0); }); }); diff --git a/test/rgb-tests.js b/test/rgb-tests.js new file mode 100644 index 0000000..c2a61f9 --- /dev/null +++ b/test/rgb-tests.js @@ -0,0 +1,136 @@ +"use strict"; + +var expect = require("chai").expect + , HueApi = require("../").HueApi + , lightState = require("../").lightState + , testValues = require("./support/testValues.js") + ; + +describe("Hue API", function () { + + describe("#setLightState to RGB", function () { + + var hue = new HueApi(testValues.host, testValues.username); + + describe("for Hue Bulb", function () { + + var id = testValues.hueLightId; + + it("should set 255,0,0", function (done) { + var state = lightState + .create() + .on() + .rgb(255, 0, 0); + + hue.setLightState(id, state) + .then(validateLightStateChange(id)) + .then(validateXY(done, 0.6484, 0.3309)) + .done(); + }); + + it("should set 255,255,255", function (done) { + var state = lightState + .create() + .on() + .rgb(255, 255, 255); + + hue.setLightState(id, state) + .then(validateLightStateChange(id)) + .then(validateXY(done, 0.3362, 0.3604)) + .done(); + }); + + it("should set 0,0,0", function (done) { + var state = lightState + .create() + .on() + .rgb(0, 0, 0); + + hue.setLightState(id, state) + .then(validateLightStateChange(id)) + .then(validateXY(done, 0.167, 0.04)) + .done(); + }); + }); + + describe("for Living Color", function () { + + var id = testValues.livingColorLightId; + + it("should set 255,0,0", function (done) { + var state = lightState + .create() + .on() + .rgb(255, 0, 0); + + hue.setLightState(id, state) + .then(validateLightStateChange(id)) + .then(validateXY(done, 0.6484, 0.3309)) + .done(); + }); + + it("should set 255,255,255", function (done) { + var state = lightState + .create() + .on() + .rgb(255, 255, 255); + + hue.setLightState(id, state) + .then(validateLightStateChange(id)) + .then(validateXY(done, 0.3362, 0.3604)) + .done(); + }); + + it("should set 0,0,0", function (done) { + var state = lightState + .create() + .on() + .rgb(0, 0, 0); + + hue.setLightState(id, state) + .then(validateLightStateChange(id)) + .then(validateXY(done, 0.138, 0.08)) + .done(); + }); + }); + + describe("for Lux Bulb", function () { + + var id = testValues.luxLightId; + + it("should fail when trying to set rgb", function (done) { + var state = lightState + .create() + .on() + .rgb(255, 0, 0); + + hue.setLightState(id, state) + .then(function() { + throw new Error("Lux should error on rgb/xy value"); + }).fail(function(err) { + expect(err.message).to.contain("xy, not available"); + done(); + }) + .done(); + }); + }); + + function validateLightStateChange(id) { + return function (result) { + expect(result).to.be.true; + return hue.lightStatus(id); + } + } + + function validateXY(done, x, y) { + return function (data) { + var ls = data.state; + expect(ls).to.have.property("on", true); + expect(ls).to.have.property("xy"); + expect(ls.xy[0]).to.equal(x); + expect(ls.xy[1]).to.equal(y); + done(); + }; + } + }); +}); \ No newline at end of file diff --git a/test/setLightState-tests.js b/test/setLightState-tests.js index 573f792..0980096 100644 --- a/test/setLightState-tests.js +++ b/test/setLightState-tests.js @@ -113,6 +113,14 @@ describe("Hue API", function () { describe("turn light off", function () { + beforeEach(function(done) { + hue.setLightState(lightId, {on:true}) + .then(function() { + done(); + }) + .done(); + }); + it("using #promise", function (done) { var checkResults = function (results) { expect(results).to.be.true; @@ -125,6 +133,7 @@ describe("Hue API", function () { it("using #callback", function (done) { state.off(); + console.log(state); hue.setLightState(lightId, state, function(err, result) { expect(err).to.be.null; expect(result).to.be.true; diff --git a/test/support/testValues.js b/test/support/testValues.js index a7f1674..ea4f767 100644 --- a/test/support/testValues.js +++ b/test/support/testValues.js @@ -4,5 +4,8 @@ module.exports = { lightsCount : 16, locateTimeout: 7000, maxScheduleNameLength: 32, - testLightId: 4 + testLightId: 6, + hueLightId: 6, + luxLightId: 15, + livingColorLightId: 1 }; \ No newline at end of file