Suggestion Request Color Bulb state synchronization

Warning, this post may be annoyingly long, but there is a lot of context to my question. I am hoping maybe in the rubber ducking sense, just laying it all out might help point to a solution.

I am using NR as the central logic for my smart home automation. One of my current goals is to be control interface agnostic as far as I can be. I have succeeded so far in being able to control and synchronize the power state (on/off) across many UIs (NR Dashboard, Hubitat, Zigbee2MQTT (Z2M), Alexa, HASS, Google Home, Homekit, uiBuilder). What I mean is that I can look at any of these interfaces, see the current state and if I change it, then it changes the controlled bulb and the new state is reflected in all of the other interfaces.

I have also been able to achieve this for brightness and color temperature for a color temp capable bulb.

I have run into a problem with doing this for color selection of a color bulb.

My approach has been to take the input from any given interface and convert it into a standard format (i.e. for power, on, On, ON, True all become ON) and then send the format the interface needs as an output.

I thought I would be able to do the same thing with color, but have run into some problems. First the part that works great. I have used the excellent node-red-contrib-color-convert node created by @hardillb. It converts between

  • RGB - Red, Green, Blue
  • HSL - Hue, Saturation, Level
  • HSV - Hue, Saturation, Value (brightness)
  • CSS - CSS color names
  • HEX - A Hex RGB string #ff00ff

I thought HEX was going to be my common format. This is where I started to run into problems. First I found that some of my interfaces were going to need HSV. Thus changing the brightness would change the color value (HEX and real (kind of)). I think I can work around this in an adequate way by storing the brightness value from the brightness flows in a context variable and then using that to get consistent results across the interfaces. While this might not be perfect, it seems workable for my purposes.

Then I ran into my big problem. When I get the input from Z2M from an IKEA color bulb (I haven't tried other brands, but I am trying to build brand agnostic flows), the only option is color_xy. This is described in the Z2M interface as:

Color of this light in the CIE 1931 color space (x/y)

Now maybe this is just a drawback of using Z2M to control the bulb, but oddly, I can send XY, RGB or HEX and maybe (HSL or HSV) to set the bulb color. Gory details at LED1924.

What I am trying to figure out is how do I convert the XY color into HEX (or equivalent format) that I can later send as an input to get the same resulting color?

I have looked for various ways of calculating RGB to XY and XY to RGB online, and none of them allow for a roundtrip like I get from the color-convert node. That is RGB1 becomes XY1 which then fed through conversion become RGB2 instead of back to RGB1.

It is inelegant, but so far the only solution I have been able to come up with is send all 16,777,216 RGB combinations from NR to Z2M, then get the resulting XY. Next, store that combination into a database of some type and then once fully populated us a db lookup to convert XY in the future to RGB.

Any suggestions for a more elegant approach to this?
I am particularly worried that if in the future Z2M make the somewhat unlikely choice to alter their RGB to XY calculation, then the database will have to be rebuilt for the new calculation. I am sure by rounding I can shrink the size of the database some as there is no way to perceive the actual difference between 10,20,30 and 11,20,30 but regardless of optimization like this, the risk of calculation change would remain.

I don't understand your comment about having to store brightness. Is not brightness implicitly included in the formats you mention?

Google found this npm module which allegedly does that conversion. You should be able to invoke that from a Function node as described in Writing Functions : Node-RED.

GitHub - Shnoo/js-CIE-1931-rgb-color-converter: A simple JS lib for converting rgb to cie1931 (Which Philips hue uses) and vice versa.

That is a different and slightly more flexible calculator, but like the other ones that I had found online it does not generate a round trip of calculations that proves lossless. Here are the 2 flows I created to check this. If you want to look at it, I would love to find that I introduced an error.
image

[{"id":"1506189cf70c13eb","type":"group","z":"f1716bd4caacceb3","name":"XY to RGB to XY","style":{"label":true,"fill":"#ffffff"},"nodes":["4992e43182ef4e9a","f0eec26657aa8a73","029be8e19327e7c9"],"x":1474,"y":319,"w":552,"h":122},{"id":"4992e43182ef4e9a","type":"function","z":"f1716bd4caacceb3","g":"1506189cf70c13eb","name":"XY to RGB to XY","func":"class ColorConverter {\n    static getGamutRanges() {\n        let gamutA = {\n            red: [0.704, 0.296],\n            green: [0.2151, 0.7106],\n            blue: [0.138, 0.08]\n        };\n\n        let gamutB = {\n            red: [0.675, 0.322],\n            green: [0.409, 0.518],\n            blue: [0.167, 0.04]\n        };\n\n        let gamutC = {\n            red: [0.692, 0.308],\n            green: [0.17, 0.7],\n            blue: [0.153, 0.048]\n        };\n\n        let defaultGamut = {\n            red: [1.0, 0],\n            green: [0.0, 1.0],\n            blue: [0.0, 0.0]\n        };\n\n        return { \"gamutA\": gamutA, \"gamutB\": gamutB, \"gamutC\": gamutC, \"default\": defaultGamut }\n    }\n\n    static getLightColorGamutRange(modelId = null) {\n        let ranges = ColorConverter.getGamutRanges();\n        let gamutA = ranges.gamutA;\n        let gamutB = ranges.gamutB;\n        let gamutC = ranges.gamutC;\n\n        let philipsModels = {\n            LST001: gamutA,\n            LLC010: gamutA,\n            LLC011: gamutA,\n            LLC012: gamutA,\n            LLC006: gamutA,\n            LLC005: gamutA,\n            LLC007: gamutA,\n            LLC014: gamutA,\n            LLC013: gamutA,\n\n            LCT001: gamutB,\n            LCT007: gamutB,\n            LCT002: gamutB,\n            LCT003: gamutB,\n            LLM001: gamutB,\n\n            LCT010: gamutC,\n            LCT014: gamutC,\n            LCT015: gamutC,\n            LCT016: gamutC,\n            LCT011: gamutC,\n            LLC020: gamutC,\n            LST002: gamutC,\n            LCT012: gamutC,\n        };\n\n        if (!!philipsModels[modelId]) {\n            return philipsModels[modelId];\n        }\n\n        return ranges.default;\n    }\n\n\n    static rgbToXy(red, green, blue, modelId = null) {\n        function getGammaCorrectedValue(value) {\n            return (value > 0.04045) ? Math.pow((value + 0.055) / (1.0 + 0.055), 2.4) : (value / 12.92)\n        }\n\n        let colorGamut = ColorConverter.getLightColorGamutRange(modelId);\n\n        red = parseFloat(red / 255);\n        green = parseFloat(green / 255);\n        blue = parseFloat(blue / 255);\n\n        red = getGammaCorrectedValue(red);\n        green = getGammaCorrectedValue(green);\n        blue = getGammaCorrectedValue(blue);\n\n        let x = red * 0.649926 + green * 0.103455 + blue * 0.197109;\n        let y = red * 0.234327 + green * 0.743075 + blue * 0.022598;\n        let z = red * 0.0000000 + green * 0.053077 + blue * 1.035763;\n\n        let xy = {\n            x: x / (x + y + z),\n            y: y / (x + y + z)\n        };\n\n        if (!ColorConverter.xyIsInGamutRange(xy, colorGamut)) {\n            xy = ColorConverter.getClosestColor(xy, colorGamut);\n        }\n\n        return xy;\n    }\n\n    static xyIsInGamutRange(xy, gamut) {\n        gamut = gamut || ColorConverter.getGamutRanges().gamutC;\n        if (Array.isArray(xy)) {\n            xy = {\n                x: xy[0],\n                y: xy[1]\n            };\n        }\n\n        let v0 = [gamut.blue[0] - gamut.red[0], gamut.blue[1] - gamut.red[1]];\n        let v1 = [gamut.green[0] - gamut.red[0], gamut.green[1] - gamut.red[1]];\n        let v2 = [xy.x - gamut.red[0], xy.y - gamut.red[1]];\n\n        let dot00 = (v0[0] * v0[0]) + (v0[1] * v0[1]);\n        let dot01 = (v0[0] * v1[0]) + (v0[1] * v1[1]);\n        let dot02 = (v0[0] * v2[0]) + (v0[1] * v2[1]);\n        let dot11 = (v1[0] * v1[0]) + (v1[1] * v1[1]);\n        let dot12 = (v1[0] * v2[0]) + (v1[1] * v2[1]);\n\n        let invDenom = 1 / (dot00 * dot11 - dot01 * dot01);\n\n        let u = (dot11 * dot02 - dot01 * dot12) * invDenom;\n        let v = (dot00 * dot12 - dot01 * dot02) * invDenom;\n\n        return ((u >= 0) && (v >= 0) && (u + v < 1));\n    }\n\n    static getClosestColor(xy, gamut) {\n        function getLineDistance(pointA, pointB) {\n            return Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y);\n        }\n\n        function getClosestPoint(xy, pointA, pointB) {\n            let xy2a = [xy.x - pointA.x, xy.y - pointA.y];\n            let a2b = [pointB.x - pointA.x, pointB.y - pointA.y];\n            let a2bSqr = Math.pow(a2b[0], 2) + Math.pow(a2b[1], 2);\n            let xy2a_dot_a2b = xy2a[0] * a2b[0] + xy2a[1] * a2b[1];\n            let t = xy2a_dot_a2b / a2bSqr;\n\n            return {\n                x: pointA.x + a2b[0] * t,\n                y: pointA.y + a2b[1] * t\n            }\n        }\n\n        let greenBlue = {\n            a: {\n                x: gamut.green[0],\n                y: gamut.green[1]\n            },\n            b: {\n                x: gamut.blue[0],\n                y: gamut.blue[1]\n            }\n        };\n\n        let greenRed = {\n            a: {\n                x: gamut.green[0],\n                y: gamut.green[1]\n            },\n            b: {\n                x: gamut.red[0],\n                y: gamut.red[1]\n            }\n        };\n\n        let blueRed = {\n            a: {\n                x: gamut.red[0],\n                y: gamut.red[1]\n            },\n            b: {\n                x: gamut.blue[0],\n                y: gamut.blue[1]\n            }\n        };\n\n        let closestColorPoints = {\n            greenBlue: getClosestPoint(xy, greenBlue.a, greenBlue.b),\n            greenRed: getClosestPoint(xy, greenRed.a, greenRed.b),\n            blueRed: getClosestPoint(xy, blueRed.a, blueRed.b)\n        };\n\n        let distance = {\n            greenBlue: getLineDistance(xy, closestColorPoints.greenBlue),\n            greenRed: getLineDistance(xy, closestColorPoints.greenRed),\n            blueRed: getLineDistance(xy, closestColorPoints.blueRed)\n        };\n\n        let closestDistance;\n        let closestColor;\n        for (let i in distance) {\n            if (distance.hasOwnProperty(i)) {\n                if (!closestDistance) {\n                    closestDistance = distance[i];\n                    closestColor = i;\n                }\n\n                if (closestDistance > distance[i]) {\n                    closestDistance = distance[i];\n                    closestColor = i;\n                }\n            }\n\n        }\n        return closestColorPoints[closestColor];\n    }\n\n    static xyBriToRgb(x, y, bri) {\n        function getReversedGammaCorrectedValue(value) {\n            return value <= 0.0031308 ? 12.92 * value : (1.0 + 0.055) * Math.pow(value, (1.0 / 2.4)) - 0.055;\n        }\n\n        let xy = {\n            x: x,\n            y: y\n        };\n\n        let z = 1.0 - xy.x - xy.y;\n        let Y = bri / 255;\n        let X = (Y / xy.y) * xy.x;\n        let Z = (Y / xy.y) * z;\n        let r = X * 1.656492 - Y * 0.354851 - Z * 0.255038;\n        let g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152;\n        let b = X * 0.051713 - Y * 0.121364 + Z * 1.011530;\n\n        r = getReversedGammaCorrectedValue(r);\n        g = getReversedGammaCorrectedValue(g);\n        b = getReversedGammaCorrectedValue(b);\n\n        let red = parseInt(r * 255) > 255 ? 255 : parseInt(r * 255);\n        let green = parseInt(g * 255) > 255 ? 255 : parseInt(g * 255);\n        let blue = parseInt(b * 255) > 255 ? 255 : parseInt(b * 255);\n\n        red = Math.abs(red);\n        green = Math.abs(green);\n        blue = Math.abs(blue);\n\n        return { r: red, g: green, b: blue };\n    }\n}\n\nlet rgb\nlet xy\nlet distances =[]\nnode.warn(\"Gamut A\");\nxy = { x: parseFloat(msg.x), y: parseFloat(msg.y) }\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    xy = ColorConverter.rgbToXy(rgb.r, rgb.g, rgb.b, msg.modelidA);\n    xy.x = parseFloat(xy.x.toFixed(4));\n    xy.y = parseFloat(xy.y.toFixed(4));\n    distances.push(Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y),2),.5));\n    if (0.00622 >= Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5)) {\n        node.warn({ d:Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y),2),.5),\n        \"Original X\": msg.x,\n        \"Original Y\": msg.y,\n        x: xy.x,\n        y: xy.y,\n        i: index,\n        \"in between r\": rgb.r,\n        \"in between g\": rgb.g,\n        \"in between b\": rgb.b,\n        id: msg.modelidA });\n    }\n}\nnode.warn(\"Gamut B\");\nxy = { x: parseFloat(msg.x), y: parseFloat(msg.y) }\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    xy = ColorConverter.rgbToXy(rgb.r, rgb.g, rgb.b, msg.modelidB);\n    xy.x = parseFloat(xy.x.toFixed(4));\n    xy.y = parseFloat(xy.y.toFixed(4));\n    distances.push(Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5));\n    if (0.00622 >= Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5)) {\n        node.warn({\n            d: Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5),\n            \"Original X\": msg.x,\n            \"Original Y\": msg.y,\n            x: xy.x,\n            y: xy.y,\n            \"in between r\": rgb.r,\n            \"in between g\": rgb.g,\n            \"in between b\": rgb.b,\n            id: msg.modelidA\n        });\n    }\n}\nnode.warn(\"Gamut C\");\nxy = { x: parseFloat(msg.x), y: parseFloat(msg.y) }\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    xy = ColorConverter.rgbToXy(rgb.r, rgb.g, rgb.b, msg.modelidC);\n    xy.x = parseFloat(xy.x.toFixed(4));\n    xy.y = parseFloat(xy.y.toFixed(4));\n    distances.push(Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5));\n    if (0.00622 >= Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5)) {\n        node.warn({\n            d: Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5),\n            \"Original X\": msg.x,\n            \"Original Y\": msg.y,\n            x: xy.x,\n            y: xy.y,\n            \"in between r\": rgb.r,\n            \"in between g\": rgb.g,\n            \"in between b\": rgb.b,\n            id: msg.modelidA\n        });\n    }\n}\nnode.warn(\"Gamut D\");\nxy = { x: parseFloat(msg.x), y: parseFloat(msg.y) }\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    xy = ColorConverter.rgbToXy(rgb.r, rgb.g, rgb.b, null);\n    xy.x = parseFloat(xy.x.toFixed(4));\n    xy.y = parseFloat(xy.y.toFixed(4));\n    distances.push(Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5));\n    if (0.00622 >= Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5)) {\n        node.warn({\n            d: Math.pow(Math.pow(xy.x - parseFloat(msg.x), 2) + Math.pow(xy.y - parseFloat(msg.y), 2), .5),\n            \"Original X\": msg.x,\n            \"Original Y\": msg.y,\n            x: xy.x,\n            y: xy.y,\n            \"in between r\": rgb.r,\n            \"in between g\": rgb.g,\n            \"in between b\": rgb.b,\n            id: msg.modelidA\n        });\n    }\n}\n node.warn(Math.min(...distances));\n\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1910,"y":360,"wires":[[]]},{"id":"f0eec26657aa8a73","type":"inject","z":"f1716bd4caacceb3","g":"1506189cf70c13eb","name":"input XY( 0.5055, 0.4297) orange","props":[{"p":"payload"},{"p":"x","v":" 0.5055","vt":"str"},{"p":"y","v":"0.4297","vt":"str"},{"p":"modelidA","v":"LST001","vt":"str"},{"p":"modelidB","v":"LCT007","vt":"str"},{"p":"modelidC","v":"LCT016","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":1650,"y":360,"wires":[["4992e43182ef4e9a"]]},{"id":"029be8e19327e7c9","type":"inject","z":"f1716bd4caacceb3","g":"1506189cf70c13eb","name":"input XY ( 0.627, 0.3279) red","props":[{"p":"payload"},{"p":"x","v":"0.627","vt":"str"},{"p":"y","v":"0.3279","vt":"str"},{"p":"modelidA","v":"LST001","vt":"str"},{"p":"modelidB","v":"LCT007","vt":"str"},{"p":"modelidC","v":"LCT016","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":1660,"y":400,"wires":[["4992e43182ef4e9a"]]},{"id":"e66acae9d3caa61c","type":"group","z":"f1716bd4caacceb3","name":"RGB to XY to RGB","style":{"label":true,"fill":"#ffffff"},"nodes":["8c436eb521dba370","b65a023af1a2f2b8"],"x":1484,"y":459,"w":562,"h":82},{"id":"8c436eb521dba370","type":"function","z":"f1716bd4caacceb3","g":"e66acae9d3caa61c","name":"RGB to XY to RGB","func":"class ColorConverter {\n    static getGamutRanges() {\n        let gamutA = {\n            red: [0.704, 0.296],\n            green: [0.2151, 0.7106],\n            blue: [0.138, 0.08]\n        };\n\n        let gamutB = {\n            red: [0.675, 0.322],\n            green: [0.409, 0.518],\n            blue: [0.167, 0.04]\n        };\n\n        let gamutC = {\n            red: [0.692, 0.308],\n            green: [0.17, 0.7],\n            blue: [0.153, 0.048]\n        };\n\n        let defaultGamut = {\n            red: [1.0, 0],\n            green: [0.0, 1.0],\n            blue: [0.0, 0.0]\n        };\n\n        return { \"gamutA\": gamutA, \"gamutB\": gamutB, \"gamutC\": gamutC, \"default\": defaultGamut }\n    }\n\n    static getLightColorGamutRange(modelId = null) {\n        let ranges = ColorConverter.getGamutRanges();\n        let gamutA = ranges.gamutA;\n        let gamutB = ranges.gamutB;\n        let gamutC = ranges.gamutC;\n\n        let philipsModels = {\n            LST001: gamutA,\n            LLC010: gamutA,\n            LLC011: gamutA,\n            LLC012: gamutA,\n            LLC006: gamutA,\n            LLC005: gamutA,\n            LLC007: gamutA,\n            LLC014: gamutA,\n            LLC013: gamutA,\n\n            LCT001: gamutB,\n            LCT007: gamutB,\n            LCT002: gamutB,\n            LCT003: gamutB,\n            LLM001: gamutB,\n\n            LCT010: gamutC,\n            LCT014: gamutC,\n            LCT015: gamutC,\n            LCT016: gamutC,\n            LCT011: gamutC,\n            LLC020: gamutC,\n            LST002: gamutC,\n            LCT012: gamutC,\n        };\n\n        if (!!philipsModels[modelId]) {\n            return philipsModels[modelId];\n        }\n\n        return ranges.default;\n    }\n\n\n    static rgbToXy(red, green, blue, modelId = null) {\n        function getGammaCorrectedValue(value) {\n            return (value > 0.04045) ? Math.pow((value + 0.055) / (1.0 + 0.055), 2.4) : (value / 12.92)\n        }\n\n        let colorGamut = ColorConverter.getLightColorGamutRange(modelId);\n\n        red = parseFloat(red / 255);\n        green = parseFloat(green / 255);\n        blue = parseFloat(blue / 255);\n\n        red = getGammaCorrectedValue(red);\n        green = getGammaCorrectedValue(green);\n        blue = getGammaCorrectedValue(blue);\n\n        let x = red * 0.649926 + green * 0.103455 + blue * 0.197109;\n        let y = red * 0.234327 + green * 0.743075 + blue * 0.022598;\n        let z = red * 0.0000000 + green * 0.053077 + blue * 1.035763;\n\n        let xy = {\n            x: x / (x + y + z),\n            y: y / (x + y + z)\n        };\n\n        if (!ColorConverter.xyIsInGamutRange(xy, colorGamut)) {\n            xy = ColorConverter.getClosestColor(xy, colorGamut);\n        }\n\n        return xy;\n    }\n\n    static xyIsInGamutRange(xy, gamut) {\n        gamut = gamut || ColorConverter.getGamutRanges().gamutC;\n        if (Array.isArray(xy)) {\n            xy = {\n                x: xy[0],\n                y: xy[1]\n            };\n        }\n\n        let v0 = [gamut.blue[0] - gamut.red[0], gamut.blue[1] - gamut.red[1]];\n        let v1 = [gamut.green[0] - gamut.red[0], gamut.green[1] - gamut.red[1]];\n        let v2 = [xy.x - gamut.red[0], xy.y - gamut.red[1]];\n\n        let dot00 = (v0[0] * v0[0]) + (v0[1] * v0[1]);\n        let dot01 = (v0[0] * v1[0]) + (v0[1] * v1[1]);\n        let dot02 = (v0[0] * v2[0]) + (v0[1] * v2[1]);\n        let dot11 = (v1[0] * v1[0]) + (v1[1] * v1[1]);\n        let dot12 = (v1[0] * v2[0]) + (v1[1] * v2[1]);\n\n        let invDenom = 1 / (dot00 * dot11 - dot01 * dot01);\n\n        let u = (dot11 * dot02 - dot01 * dot12) * invDenom;\n        let v = (dot00 * dot12 - dot01 * dot02) * invDenom;\n\n        return ((u >= 0) && (v >= 0) && (u + v < 1));\n    }\n\n    static getClosestColor(xy, gamut) {\n        function getLineDistance(pointA, pointB) {\n            return Math.hypot(pointB.x - pointA.x, pointB.y - pointA.y);\n        }\n\n        function getClosestPoint(xy, pointA, pointB) {\n            let xy2a = [xy.x - pointA.x, xy.y - pointA.y];\n            let a2b = [pointB.x - pointA.x, pointB.y - pointA.y];\n            let a2bSqr = Math.pow(a2b[0], 2) + Math.pow(a2b[1], 2);\n            let xy2a_dot_a2b = xy2a[0] * a2b[0] + xy2a[1] * a2b[1];\n            let t = xy2a_dot_a2b / a2bSqr;\n\n            return {\n                x: pointA.x + a2b[0] * t,\n                y: pointA.y + a2b[1] * t\n            }\n        }\n\n        let greenBlue = {\n            a: {\n                x: gamut.green[0],\n                y: gamut.green[1]\n            },\n            b: {\n                x: gamut.blue[0],\n                y: gamut.blue[1]\n            }\n        };\n\n        let greenRed = {\n            a: {\n                x: gamut.green[0],\n                y: gamut.green[1]\n            },\n            b: {\n                x: gamut.red[0],\n                y: gamut.red[1]\n            }\n        };\n\n        let blueRed = {\n            a: {\n                x: gamut.red[0],\n                y: gamut.red[1]\n            },\n            b: {\n                x: gamut.blue[0],\n                y: gamut.blue[1]\n            }\n        };\n\n        let closestColorPoints = {\n            greenBlue: getClosestPoint(xy, greenBlue.a, greenBlue.b),\n            greenRed: getClosestPoint(xy, greenRed.a, greenRed.b),\n            blueRed: getClosestPoint(xy, blueRed.a, blueRed.b)\n        };\n\n        let distance = {\n            greenBlue: getLineDistance(xy, closestColorPoints.greenBlue),\n            greenRed: getLineDistance(xy, closestColorPoints.greenRed),\n            blueRed: getLineDistance(xy, closestColorPoints.blueRed)\n        };\n\n        let closestDistance;\n        let closestColor;\n        for (let i in distance) {\n            if (distance.hasOwnProperty(i)) {\n                if (!closestDistance) {\n                    closestDistance = distance[i];\n                    closestColor = i;\n                }\n\n                if (closestDistance > distance[i]) {\n                    closestDistance = distance[i];\n                    closestColor = i;\n                }\n            }\n\n        }\n        return closestColorPoints[closestColor];\n    }\n\n    static xyBriToRgb(x, y, bri) {\n        function getReversedGammaCorrectedValue(value) {\n            return value <= 0.0031308 ? 12.92 * value : (1.0 + 0.055) * Math.pow(value, (1.0 / 2.4)) - 0.055;\n        }\n\n        let xy = {\n            x: x,\n            y: y\n        };\n\n        let z = 1.0 - xy.x - xy.y;\n        let Y = bri / 255;\n        let X = (Y / xy.y) * xy.x;\n        let Z = (Y / xy.y) * z;\n        let r = X * 1.656492 - Y * 0.354851 - Z * 0.255038;\n        let g = -X * 0.707196 + Y * 1.655397 + Z * 0.036152;\n        let b = X * 0.051713 - Y * 0.121364 + Z * 1.011530;\n\n        r = getReversedGammaCorrectedValue(r);\n        g = getReversedGammaCorrectedValue(g);\n        b = getReversedGammaCorrectedValue(b);\n\n        let red = parseInt(r * 255) > 255 ? 255 : parseInt(r * 255);\n        let green = parseInt(g * 255) > 255 ? 255 : parseInt(g * 255);\n        let blue = parseInt(b * 255) > 255 ? 255 : parseInt(b * 255);\n\n        red = Math.abs(red);\n        green = Math.abs(green);\n        blue = Math.abs(blue);\n\n        return { r: red, g: green, b: blue };\n    }\n}\n// let xy = ColorConverter.rgbToXy(red, green, blue, light.modelid);\n// let xy = ColorConverter.rgbToXy(red, green, blue);\n// let rgb = ColorConverter.xyBriToRgb(x, y, brightness);\n\nnode.warn(\"RGB input \" + msg.red + \" \" + msg.green + \" \" + msg.blue + \" \");\nlet rgb\nnode.warn(\"Gamut A  XY result\");\nlet xy = ColorConverter.rgbToXy(msg.red, msg.green, msg.blue, msg.modelidA);\nnode.warn(xy);\nnode.warn(\"RGB output if the RGB matches red and green\");\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    if ((msg.red == rgb.r)&&(msg.green == rgb.g)) {\n        node.warn(rgb);\n    }\n}\nnode.warn(\"Gamut B  XY result\");\nxy = ColorConverter.rgbToXy(msg.red, msg.green, msg.blue, msg.modelidB);\nnode.warn(xy);\nnode.warn(\"RGB output if the RGB matches red and green\");\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    if ((msg.red == rgb.r) && (msg.green == rgb.g)) {\n        node.warn(rgb);\n    }\n}\nnode.warn(\"Gamut C  XY result\");\nxy = ColorConverter.rgbToXy(msg.red, msg.green, msg.blue, msg.modelidC);\nnode.warn(xy);\nnode.warn(\"RGB output if the RGB matches red and green\");\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    if ((msg.red == rgb.r) && (msg.green == rgb.g)) {\n        node.warn(rgb);\n    }\n}\nnode.warn(\"Gamut D  XY result\");\nxy = ColorConverter.rgbToXy(msg.red, msg.green, msg.blue, msg.modelidD);\nnode.warn(xy);\nnode.warn(\"RGB output if the RGB matches red and green\");\nfor (let index = 1; index < 101; index++) {\n    rgb = ColorConverter.xyBriToRgb(xy.x, xy.y, index);\n    if ((msg.red == rgb.r) && (msg.green == rgb.g)) {\n        node.warn(rgb);\n    }\n}\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1930,"y":500,"wires":[[]]},{"id":"b65a023af1a2f2b8","type":"inject","z":"f1716bd4caacceb3","g":"e66acae9d3caa61c","name":"Input (red, green, blue, light.modelid)","props":[{"p":"payload"},{"p":"red","v":"255","vt":"str"},{"p":"green","v":"228","vt":"str"},{"p":"blue","v":"68","vt":"str"},{"p":"modelidA","v":"LST001","vt":"str"},{"p":"modelidB","v":"LCT001","vt":"str"},{"p":"modelidC","v":"LCT010 ","vt":"str"},{"p":"modelidD","v":"null","vt":"str"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":1670,"y":500,"wires":[["8c436eb521dba370"]]}]

What are you seeing in and out of those? I am not at my computer to try it.

Hi,
I ran into the same issue lately. In my research for my chroma node I found that cie x/y is a incomplete color model missing the z component. Perhaps fine for the last near to the hardware conversion but not as a universal model.

As I do not have inputs from the z2m universe I don’t really care.

I use HSV in my node-red implementations and in my own devices I do the hardware specific conversion like HSV to RGBW inside the firmware where I have the ability to tune the values to an individual a gamma value or LED luminescence depending on the hardware.

I think z2m is not to blame as it only transfers data from zigbee devices to mqtt.

If the solution above works i will include it into my node :wink: if I find some free time

More details than you are likely to care about but here goes:

  • The conversion you linked to offers 3 Gamut settings that Philips Hue bulbs support and a 4th generic Gamut setting that slightly alter the RGB to XY calculations. My 2 node flow for RGB to XY to RGB tries all 4 Gamut options.
  • The conversion you linked to asked for 3 inputs for XY to RGB. They are X,Y, and Z, Z is labelled in multiple places as the brightness level.0-100 scale). In the case of the flow, I used a for loop to try every Z between 1 and 100. Not posted, but I actually tried every Z between 1 and 100 incrementing by .01, but other than taking longer to run, it produced no different results
  • For my test I tried various RGB triplets, but the posted flow has the triplet (255,228,68)
  • I an ideal world, the results would be (255,228,68)->(0.xxxx, 0.yyyy)->(255,228,68).
  • My flow since it is checking so many combinations just compares the output RGB to the input RGB after showing the XY calculated.
  • The Gamut A XY is {"x":0.44815089398328317,"y":0.4907822590958571}
  • The Gamut B XY is {"x":0.4473723371785767,"y":0.4897256462894698}
  • The Gamut C XY is {"x":0.44815089398328317,"y":0.4907822590958571}
  • The Default Gamut XY is {"x":0.44815089398328317,"y":0.4907822590958571}
  • None of the RGB outs (z =1-100 incremented by .01) under any of the gamuts match the input RGB.

Then looking at XY to RGB to XY, I used 2 different XY inputs:

  • XY input 1 (0.5055, 0.4297)
  • XY Input 2 (0.627, 0.3279)

First I looked at which Z (brightness) seemed to cause the smallest deviation in the round trip and found that it is a Z of 1.
Again I tried all 4 gamuts for the RGB to XY portion. The final output from this flow shows the following for each gamut:

  • d - The pythagorean distance between the input XY and the output XY
  • Original X- Exactly what it says
  • Original Y- Exactly what it says
  • x - The output x from the round trip
  • y - The output y from the round trip
  • i - The Z (brightness) used for XY to RGB
  • in between r - The R component of the RGB triplet that is the after conversion 1 of 2
  • in between g - The G component of the RGB triplet that is the after conversion 1 of 2
  • in between b - The B component of the RGB triplet that is the after conversion 1 of 2
  • id: The bulb used to pick the Gamut

The results for combo XY 0.5055....:

  • Gamut A
  • {"d":0.00621288982680365,"Original X":" 0.5055","Original Y":"0.4297","x":0.5089,"y":0.4349,"i":1,"in between r":18,"in between g":10,"in between b":1,"id":"LST001"}
  • Gamut B
  • {"d":0.00621288982680365,"Original X":" 0.5055","Original Y":"0.4297","x":0.5089,"y":0.4349,"in between r":18,"in between g":10,"in between b":1,"id":"LST001"}
  • Gamut C
  • {"d":0.00621288982680365,"Original X":" 0.5055","Original Y":"0.4297","x":0.5089,"y":0.4349,"in between r":18,"in between g":10,"in between b":1,"id":"LST001"}
  • Default Gamut
  • {"d":0.00621288982680365,"Original X":" 0.5055","Original Y":"0.4297","x":0.5089,"y":0.4349,"in between r":18,"in between g":10,"in between b":1,"id":"LST001"}

The other XY combo doesn't get any results as shared as I used a maximum d cutoff in the code and none of them were close enough to get results.

I have, however implemented by lookup table solution and will post that in a separate post.

I went ahead and implemented the lookup table approach. To do this I am sharing 2 flows. The first populated 4096 six item combinations of R,G,B,X,Y,CSS Color into what I refer to as a lookup table, but it is really a global variable hold a 2 dimensional (4096x5) array. It is 4096 as it starts with RGB 0,0,0 and goes up to RGB 240,240,240 and with every combination of Red, Green, and Blue that are multiples of 16.
image

[{"id":"72b265955da5ae7b","type":"group","z":"dbebb084456146f8","name":"Populates the RGB, XY, Colorname global variable array","style":{"label":true,"stroke":"#000000","fill":"#92d04f","color":"#000000"},"nodes":["269d659fbdc8fc46","fb0c420e6e2a2be6","a971f3fb12437678","67ff0ed2bc6305d6","ba4da2bcddd7925e","0f38c6e115ceea84","18420471b045c278"],"x":654,"y":379,"w":359,"h":322},{"id":"269d659fbdc8fc46","type":"inject","z":"dbebb084456146f8","g":"72b265955da5ae7b","name":"start","props":[{"p":"payload"}],"repeat":"","crontab":"","once":false,"onceDelay":0.1,"topic":"","payloadType":"date","x":750,"y":420,"wires":[["18420471b045c278"]]},{"id":"fb0c420e6e2a2be6","type":"color-convert","z":"dbebb084456146f8","g":"72b265955da5ae7b","input":"rgb","output":"css","outputType":"string","scaleInput":false,"x":770,"y":540,"wires":[["67ff0ed2bc6305d6"]]},{"id":"a971f3fb12437678","type":"function","z":"dbebb084456146f8","g":"72b265955da5ae7b","name":"Gen RGB","func":"let i = 0\nlet j = 0\nlet k = 0\nlet xy = rgb_to_cie(k,j,i)\n\nfor (let i = 0; i < 256; i = i + 16) {\n    for (let j = 0; j < 256; j = j + 16) {\n        for (let k = 0; k < 256; k = k + 16) {\n            xy = rgb_to_cie(k, j, i)\n            msg.rgb=[k, j, i]\n            msg.payload = [k, j, i]\n            node.send(msg);\n        }\n    }\n}\n\n\nreturn;\n\n\n\n\n\n/**\n * Converts RGB color space to CIE color space\n * @param {Number} red\n * @param {Number} green\n * @param {Number} blue\n * @return {Array} Array that contains the CIE color values for x and y\n */\nfunction rgb_to_cie(red, green, blue) {\n    //Apply a gamma correction to the RGB values, which makes the color more vivid and more the like the color displayed on the screen of your device\n    var red = (red > 0.04045) ? Math.pow((red + 0.055) / (1.0 + 0.055), 2.4) : (red / 12.92);\n    var green = (green > 0.04045) ? Math.pow((green + 0.055) / (1.0 + 0.055), 2.4) : (green / 12.92);\n    var blue = (blue > 0.04045) ? Math.pow((blue + 0.055) / (1.0 + 0.055), 2.4) : (blue / 12.92);\n\n    //RGB values to XYZ using the Wide RGB D65 conversion formula\n    var X = red * 0.664511 + green * 0.154324 + blue * 0.162028;\n    var Y = red * 0.283881 + green * 0.668433 + blue * 0.047685;\n    var Z = red * 0.000088 + green * 0.072310 + blue * 0.986039;\n\n    //Calculate the xy values from the XYZ values\n    var x = (X / (X + Y + Z)).toFixed(4);\n    var y = (Y / (X + Y + Z)).toFixed(4);\n\n    if (isNaN(x))\n        x = 0;\n\n    if (isNaN(y))\n        y = 0;\n\n\n    return [x, y];\n}\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":760,"y":500,"wires":[["fb0c420e6e2a2be6"]]},{"id":"67ff0ed2bc6305d6","type":"function","z":"dbebb084456146f8","g":"72b265955da5ae7b","name":"Save CSS color name","func":"msg.colorname = msg.payload\nreturn msg;","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":800,"y":580,"wires":[["ba4da2bcddd7925e"]]},{"id":"ba4da2bcddd7925e","type":"function","z":"dbebb084456146f8","g":"72b265955da5ae7b","name":"Make XY payload","func":"let xy = rgb_to_cie(msg.rgb[0], msg.rgb[1], msg.rgb[2])\nxy[0] = parseFloat(xy[0])\nxy[1] = parseFloat(xy[1])\nmsg.payload = { \"color\": { \"x\": parseFloat(xy[0]), \"y\": parseFloat(xy[1]) } }\nmsg.xy = xy\nmsg.table = [msg.rgb[0], msg.rgb[1], msg.rgb[2], xy[0],xy[1],msg.colorname]\nif ((msg.rgb[0] == 48) && (msg.rgb[1] == 48) && (msg.rgb[2] == 240)){\n    node.warn(msg.xy)\n}\nreturn msg;\n\n\n\n\n\n/**\n * Converts RGB color space to CIE color space\n * @param {Number} red\n * @param {Number} green\n * @param {Number} blue\n * @return {Array} Array that contains the CIE color values for x and y\n */\nfunction rgb_to_cie(red, green, blue) {\n    //Apply a gamma correction to the RGB values, which makes the color more vivid and more the like the color displayed on the screen of your device\n    var red = (red > 0.04045) ? Math.pow((red + 0.055) / (1.0 + 0.055), 2.4) : (red / 12.92);\n    var green = (green > 0.04045) ? Math.pow((green + 0.055) / (1.0 + 0.055), 2.4) : (green / 12.92);\n    var blue = (blue > 0.04045) ? Math.pow((blue + 0.055) / (1.0 + 0.055), 2.4) : (blue / 12.92);\n\n    //RGB values to XYZ using the Wide RGB D65 conversion formula\n    var X = red * 0.664511 + green * 0.154324 + blue * 0.162028;\n    var Y = red * 0.283881 + green * 0.668433 + blue * 0.047685;\n    var Z = red * 0.000088 + green * 0.072310 + blue * 0.986039;\n\n    //Calculate the xy values from the XYZ values\n    var x = (X / (X + Y + Z)).toFixed(4);\n    var y = (Y / (X + Y + Z)).toFixed(4);\n\n    if (isNaN(x))\n        x = 0;\n\n    if (isNaN(y))\n        y = 0;\n\n\n    return [x, y];\n}\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":790,"y":620,"wires":[["0f38c6e115ceea84"]]},{"id":"0f38c6e115ceea84","type":"function","z":"dbebb084456146f8","g":"72b265955da5ae7b","name":"populate 1rgbc","func":"let rgbc = global.get(\"1rgbxyc\");\nrgbc.push(msg.table)\n//node.warn(msg.table.join(' '))\nglobal.set(\"1rgbxyc\", rgbc)\n","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":780,"y":660,"wires":[[]]},{"id":"18420471b045c278","type":"function","z":"dbebb084456146f8","g":"72b265955da5ae7b","name":"reset 1rgbxyc","func":"global.set(\"1rgbxyc\",[])\nreturn msg","outputs":1,"noerr":0,"initialize":"","finalize":"","libs":[],"x":780,"y":460,"wires":[["a971f3fb12437678"]]}]

The second one is quite a bit more complicated to explain.


Essentially here is how it works:

  1. The XY value is received via MQTT.
  2. The join node combines the 2 values into a single message
  3. The C2R node converts the CIE XY pair into a RGB triplet. It then checks if that triplet is in the lookup table to see if that combination is there. If it is there, the it sends to the RGB > Hex node that translates the triplet to HEX for used elsewhere in my flow. It then passes through the gate node that by default is open, but I can used the toggle to troubleshoot to close when I get feedback loops from other parts of the flow during development.
  4. If, as is often the case, the XY does not convert cleanly to one of the RGB triplets, it changes the RGB values to the nearest multiple of 16 and then sends to the Make XY payload node
  5. The Make XY converts it back to XY and sends to Zigbee2MQTT
  6. This then feeds back to the MQTT nodes that started the flow. After a 1 or more passes through the failure to match loop, a success will occur leading to the success path described above.

There are several drawbacks to this approach. First is that sometime the color does drift noticeably from the color sent as the initial input. Second, I am am not certain that it can't get into a permanent failure loop situation, though experimentation seems to say that it doesn't.

I think both of the these issues could be addressed by creating a larger lookup table the 16Mx5 would probably eliminate them completely, but I suspect that this abuse of memory would be bad for my overall NR stability if left in a global variable. I haven't made the plunge to do the lookup in a "real" database, but that might be worth it in the long run.

Feel free to reach out if you have questions, need a little guidance toward adapting to your own use, or find a bug in either the logic or the function node Javascript.

I would say that @Colin 's suggested solution does work for cei1931 XY to RGB etc, but if you do add it to Chroma, you should definitely highlight in your notes that the conversion is approximate and so a round trip of XY to RGB to XY or RGB to XY to RGB will not give an output that matches the input.

I will do - when I find some time!
Have to find a place to define make the gamut setting available (as msg.gamut together with the value or a dropdown list in the config)

Just curious what input device do you have in the zigbee world you are desperate to receive the value from?

The specific item I am using for initial development is an Ikea Bulb paired to Zigbee2MQTT. Here is an image of the control page within Z2M


Here is the state info that it shows for the bulb

The funny thing is that internal to Z2M and shown on this page, in the color section, if the Z2M interface is used to change the color, it only updates the X and Y. The HSL and H, S, V are only updated if an update is sent in via the MQTT interface.

Where are these images coming from because I cannot find anything like them in the zigbee2mqtt docs?

I am not sure which version of Z2M they added it, but I get that as part of the dashboard at http://hostname:8080. Then selecting the paired device. Then selecting Exposes tab.

Edit: Found the About tab:

Looks like maybe you need to add this to the config one time (though I don't remember doing it):
"frontend": {
"port": 8080
},

Thanks, that worked