Set WLED to Spotify album art colors
Using the Spotify integration for Home Assistant, pull the currently playing track's album art (entity_picture
), and use the get-image-colors library to pull out the most prominent 3 colors.
With those three colors, set the three colors of a WLED instance accordingly.
Relies on having a WLED preset named "Spotify" that uses a color palette with 1-3 custom colors (e.g. Color 1, Color Gradient, etc.)
How it Works
1. Create Config
- The inject node named "Start" defines a list of WLED device IPs (at the top of the code).
- Loop through each device, and pull the list of presets from it, and find the ID of the preset named "Spotify".
- Build a config list, with each item being an object like so:
{
"colors": [true, true, true], // should we set each of the three colors in WLED?
"updateWithPercent": false, // should we update intensity with playback percent? (for effects like "Percent")
"ip": "192.168.1.1", // IP address of the WLED device
"presetId": 1 // The preset ID found in step 2
}
- Set
flow.wledConfig
to this config list.
2. Turn On/Off Lights
- Whenever the
playing
state on Spotify changes: - If it starts playing, make sure I'm home before proceeding.
- ON: Go through each device in the
flow.wledConfig
, and send an API request to the device to turn it on, and set the preset topresetId
. - OFF: Go through each device in the
flow.wledConfig
, and send an API request to the device to turn it off.
3. Get Prominent Colors
- Whenever anything about the Spotify state changes:
- Make sure I'm home before proceeding.
- Fetch the album art image from Home Assistant.
- Get the top 20 prominent colors using get-image-colors.
- Convert each hex color to a brightness value (0-765), and remove colors that aren't between 200 and 600 (too dark or too white for my preference)
- Loop through each remaining color, convert to Hue (0-255), and remove colors that are too close to each other (within 20). This keeps the colors looking fairly distinct.
- Make sure we still have 3 colors left, and if not, add in black.
- Unused by default Calculate playback position, and use this to alternate colors every X seconds. This works by starting at a different part of the list depending on how far through the song we are. This adds extra spice to things.
To enable, change
slowdownFactor
to something smaller, like 10. - Return our list of selected colors
4. Set WLED Devices
- Go through each device
- If updateWithPercent is enabled for the device: Calculate the playback percentage of the current track.
- Build the list of URL parameters, and send the API request to the device.
[{"id":"25e0354ecbfb4d16","type":"server-state-changed","z":"64a0843b03b54b5b","name":"Spotify Changed","server":"734558bc.cd8f48","version":5,"outputs":2,"exposeAsEntityConfig":"","entityId":"media_player.spotify_ben_scholer","entityIdType":"exact","outputInitially":false,"stateType":"str","ifState":"playing","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":false,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"},{"property":"spotify","propertyType":"msg","value":"","valueType":"eventData"}],"x":120,"y":360,"wires":[["addae87a61045530"],[]]},{"id":"5c5e814b489aac84","type":"function","z":"64a0843b03b54b5b","name":"Set WLED Devices","func":"const wledConfig = flow.get(\"wledConfig\");\n\nconst processData = async ({ ip, colors, updateWithPercent }, selectedColors) => {\n try {\n let params = '';\n\n if (updateWithPercent) {\n const duration = msg.spotify.new_state.attributes.media_duration;\n const position = msg.spotify.new_state.attributes.media_position;\n const percentage = Math.round((position / duration) * 100);\n params += `&IX=${percentage}`;\n }\n\n const colorParams = ['CL', 'C2', 'C3'];\n colors.forEach((isEnabled, index) => {\n if (isEnabled && selectedColors[index]) {\n params += `&${colorParams[index]}=${selectedColors[index]}`;\n }\n });\n\n await fetch(`http://${ip}/win${params}`, { method: 'GET' });\n } catch (error) {\n node.warn('Failed: ' + error);\n }\n};\n\n// Main code execution starts here\n(async () => {\n const selectedColors = msg.selectedColors;\n const config = wledConfig || [];\n await Promise.allSettled(config.map(ipData => processData(ipData, selectedColors))).catch(node.warn);\n})();","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"getColors","module":"get-image-colors"},{"var":"fetch","module":"node-fetch"},{"var":"Color","module":"color"}],"x":750,"y":360,"wires":[[]]},{"id":"4aa72b1555e7162a","type":"server-state-changed","z":"64a0843b03b54b5b","name":"Spotify Playing Changed","server":"734558bc.cd8f48","version":5,"outputs":2,"exposeAsEntityConfig":"","entityId":"media_player.spotify_ben_scholer","entityIdType":"exact","outputInitially":false,"stateType":"str","ifState":"playing","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":150,"y":240,"wires":[["dd03ecb136395fa9"],["a2b3fb5a13edd5d6"]]},{"id":"cd155e4b822c19d7","type":"comment","z":"64a0843b03b54b5b","name":"turn light on/off when Spotify starts/stops playing","info":"","x":220,"y":180,"wires":[]},{"id":"3208f05651f862d3","type":"comment","z":"64a0843b03b54b5b","name":"set colors whenever anything changes","info":"","x":190,"y":320,"wires":[]},{"id":"addae87a61045530","type":"api-current-state","z":"64a0843b03b54b5b","name":"Am I Home?","server":"734558bc.cd8f48","version":3,"outputs":2,"halt_if":"home","halt_if_type":"str","halt_if_compare":"is","entity_id":"device_tracker.bens_phone","state_type":"str","blockInputOverrides":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":350,"y":360,"wires":[["6b80f0c87bd0d1c6"],[]]},{"id":"dd03ecb136395fa9","type":"api-current-state","z":"64a0843b03b54b5b","name":"Am I Home?","server":"734558bc.cd8f48","version":3,"outputs":2,"halt_if":"home","halt_if_type":"str","halt_if_compare":"is","entity_id":"device_tracker.bens_phone","state_type":"str","blockInputOverrides":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":350,"y":220,"wires":[["ef892201db38e47d"],[]]},{"id":"b7d8c743ab118502","type":"poll-state","z":"64a0843b03b54b5b","name":"Poll Spotify Every 0.5s","server":"734558bc.cd8f48","version":3,"exposeAsEntityConfig":"","updateInterval":"0.5","updateIntervalType":"num","updateIntervalUnits":"seconds","outputInitially":false,"outputOnChanged":false,"entityId":"media_player.spotify_ben_scholer","stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputs":1,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"},{"property":"spotify","propertyType":"msg","value":"","valueType":"entity"}],"x":140,"y":420,"wires":[["52d31289e9b81c6b"]]},{"id":"52d31289e9b81c6b","type":"api-call-service","z":"64a0843b03b54b5b","name":"Update Spotify","server":"734558bc.cd8f48","version":5,"debugenabled":false,"domain":"homeassistant","service":"update_entity","areaId":[],"deviceId":[],"entityId":["media_player.spotify_ben_scholer"],"data":"","dataType":"jsonata","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","x":340,"y":420,"wires":[[]]},{"id":"a2b3fb5a13edd5d6","type":"function","z":"64a0843b03b54b5b","name":"Turn Off Lights","func":"const wledIPs = flow.get(\"wledIPs\");\n\nPromise.allSettled(wledIPs.map(async (ip) => {\n const wledUrl = `http://${ip}/win&T=0`;\n\n const res = await fetch(wledUrl, { method: 'GET' });\n node.send({payload: `Turned off ${ip}`})\n})).catch(node.error);","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"fetch","module":"node-fetch"}],"x":520,"y":260,"wires":[[]]},{"id":"bdea0e59317d63ff","type":"inject","z":"64a0843b03b54b5b","name":"Start","props":[{"p":"payload"},{"p":"topic","vt":"str"}],"repeat":"","crontab":"","once":true,"onceDelay":0.1,"topic":"","payload":"","payloadType":"date","x":110,"y":140,"wires":[["7a914e995d7acb58"]]},{"id":"6b80f0c87bd0d1c6","type":"function","z":"64a0843b03b54b5b","name":"Get Prominent Colors","func":"const fetchImageFromHomeAssistant = async () => {\n const response = await fetch(`http://192.168.1.140:8123${msg.spotify.new_state.attributes.entity_picture}`, {\n method: 'GET',\n headers: {}\n });\n\n if (!response.ok) {\n throw new Error(`HTTP error! Status: ${response.status}`);\n }\n\n return response.buffer();\n};\n\n// returns brightness 0-765\nconst hexToBrightness = (hex) => {\n const r = parseInt(hex.substring(0, 2), 16);\n const g = parseInt(hex.substring(2, 4), 16);\n const b = parseInt(hex.substring(4, 6), 16);\n return r + g + b;\n};\n\n// Helper function to convert hex to RGB\nconst hexToRgb = (hex) => {\n let r = 0, g = 0, b = 0;\n // 3 digits\n if (hex.length === 4) {\n r = parseInt(hex[1] + hex[1], 16);\n g = parseInt(hex[2] + hex[2], 16);\n b = parseInt(hex[3] + hex[3], 16);\n }\n // 6 digits\n else if (hex.length === 7) {\n r = parseInt(hex[1] + hex[2], 16);\n g = parseInt(hex[3] + hex[4], 16);\n b = parseInt(hex[5] + hex[6], 16);\n }\n return { r, g, b };\n};\n\n// Convert RGB to Hue\nconst rgbToHue = (r, g, b) => {\n r /= 255, g /= 255, b /= 255;\n const max = Math.max(r, g, b), min = Math.min(r, g, b);\n let h;\n if (max === min) h = 0;\n else if (max === r) h = (60 * ((g - b) / (max - min)) + 360) % 360;\n else if (max === g) h = (60 * ((b - r) / (max - min)) + 120) % 360;\n else if (max === b) h = (60 * ((r - g) / (max - min)) + 240) % 360;\n return h;\n};\n\nconst getProminentColors = async () => {\n const bufferImage = await fetchImageFromHomeAssistant();\n let colorArray = await getColors(bufferImage, { count: 20, type: 'image/jpeg' });\n // Filter by brightness and assume sorted by prominence\n let filteredColors = colorArray.filter(color => {\n const brightness = hexToBrightness(color.hex().substring(1)); // 0-765\n return brightness > 200 && brightness < 600\n });\n\n // Deduplicate based on hue, maintaining order of prominence\n let uniqueHues = [];\n let deduplicatedColors = [];\n\n filteredColors.forEach(color => {\n const { r, g, b } = hexToRgb(color.hex());\n const hue = rgbToHue(r, g, b);\n if (!uniqueHues.some(uHue => Math.abs(uHue - hue) < 20)) {\n uniqueHues.push(hue);\n deduplicatedColors.push(color);\n }\n });\n\n // Ensure at least 3 colors, fill with black if necessary\n while (deduplicatedColors.length < 3) {\n deduplicatedColors.push({ hex: () => '#000000' });\n }\n\n const slowdownFactor = 100000; // Change every x seconds\n const playbackPositionInSeconds = msg.spotify.new_state.attributes.media_position;\n const slowedTime = Math.floor(playbackPositionInSeconds / slowdownFactor);\n const startColorIndex = slowedTime % deduplicatedColors.length;\n\n let selectedColors = [];\n for (let i = 0; i < 3; i++) {\n const colorIndex = (startColorIndex + i) % deduplicatedColors.length;\n selectedColors.push(`H${deduplicatedColors[colorIndex].hex().substring(1)}`);\n }\n return selectedColors;\n};\n\n(async () => {\n const selectedColors = await getProminentColors(); // This function now encapsulates the color extraction and filtering\n node.send({ selectedColors }); // Send the selected colors for use elsewhere in your Node-RED flow\n})();\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"getColors","module":"get-image-colors"},{"var":"fetch","module":"node-fetch"},{"var":"Color","module":"color"}],"x":540,"y":360,"wires":[["5c5e814b489aac84"]]},{"id":"ef892201db38e47d","type":"function","z":"64a0843b03b54b5b","name":"Turn On Lights","func":"// const wledIPs = flow.get(\"wledIPs\");\nconst wledConfig = flow.get(\"wledConfig\")\n\nPromise.allSettled(wledConfig.map(async ({ip, presetId}) => {\n const wledUrl = `http://${ip}/win&T=1&PL=${presetId}`;\n\n const res = await fetch(wledUrl, { method: 'GET' });\n node.send({payload: `Turned on ${ip}`})\n})).catch(node.error);","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"fetch","module":"node-fetch"}],"x":520,"y":220,"wires":[[]]},{"id":"7a914e995d7acb58","type":"function","z":"64a0843b03b54b5b","name":"Create Config","func":"const wledIPs = [\n '192.168.1.215', // cl-basement-01\n '192.168.1.207', // bl-basement\n '192.168.1.211', // cl-basement-02\n '192.168.1.220', // bed-frame\n '192.168.1.212', // cl-bens-room\n '192.168.1.205', // sl-bens-room\n '192.168.1.206', // sl-dining-room\n '192.168.1.213', // cl-dining-room-01\n '192.168.1.209', // cl-dining-room-02\n '192.168.1.208' // cl-kitchen-01\n];\n\nasync function findSpotifyPresetId(ip) {\n const url = `http://${ip}/presets.json`;\n try {\n const response = await fetch(url);\n if (!response.ok) {\n throw new Error(`HTTP error! Status: ${response.status}`);\n }\n const data = await response.json();\n // node.send({res: data})\n for (const [key, value] of Object.entries(data)) {\n // Return the ID of the \"Spotify\"\n if ('n' in value && value.n === \"Spotify\") return key; \n }\n return null; // Return null if \"Spotify\" is not found\n } catch (error) {\n console.error('Failed to fetch presets from WLED device:', error);\n return null; // Ensure function returns null if there's an error\n }\n}\n\n(async () => {\n // Enhanced configuration setup\n const config = await Promise.all(wledIPs.map(async (ip) => {\n const presetId = await findSpotifyPresetId(ip); // Await the preset ID for each IP\n \n return {\n colors: [true, true, true],\n updateWithPercent: false,\n ip,\n presetId,\n };\n }));\n flow.set(\"wledConfig\", config);\n node.send({config, ...msg})\n})();","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[{"var":"fetch","module":"node-fetch"}],"x":260,"y":140,"wires":[[]]},{"id":"734558bc.cd8f48","type":"server","name":"Home Assistant","addon":true}]