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

  1. The inject node named "Start" defines a list of WLED device IPs (at the top of the code).
  2. Loop through each device, and pull the list of presets from it, and find the ID of the preset named "Spotify".
  3. 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
}
  1. Set flow.wledConfig to this config list.

2. Turn On/Off Lights

  1. Whenever the playing state on Spotify changes:
  2. If it starts playing, make sure I'm home before proceeding.
  3. 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 to presetId.
  4. 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

  1. Whenever anything about the Spotify state changes:
  2. Make sure I'm home before proceeding.
  3. Fetch the album art image from Home Assistant.
  4. Get the top 20 prominent colors using get-image-colors.
  5. 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)
  6. 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.
  7. Make sure we still have 3 colors left, and if not, add in black.
  8. 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.
  9. Return our list of selected colors

4. Set WLED Devices

  1. Go through each device
  2. If updateWithPercent is enabled for the device: Calculate the playback percentage of the current track.
  3. Build the list of URL parameters, and send the API request to the device.

Video

[{"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}]

Flow Info

Created 1 year, 8 months ago
Updated 1 year, 1 month ago
Rating: not yet rated

Owner

Actions

Rate:

Node Types

Core
  • comment (x2)
  • function (x5)
  • inject (x1)
Other

Tags

  • wled
  • homeassistant
  • spotify
Copy this flow JSON to your clipboard and then import into Node-RED using the Import From > Clipboard (Ctrl-I) menu option