/** * WLED Universal Driver for Hubitat Elevation * * Author: Original by bryan@joyful.house * Optimization & Refactoring: obeisser * Enhanced by: Kiro AI Assistant * Date: 2025-09-30 * Version: 1.2.1 * * Latest Changes (v1.2.1): * * Added Features: * - Added command descriptions for better user interface clarity * * Fixed Issues: * - Fixed alarm effects to use actual available WLED effects (Chase Flash, Strobe, Strobe Mega) * * Previous Changes (v1.2): * * Added Features: * - Added effect selection by name with smart matching (exact and partial) * - Added palette selection by name with intelligent fallback * - Added comprehensive playlist support with name-based control * - Added reverse effect switch capability for easier automation control * - Added discovery commands: listEffects(), listPalettes(), listPlaylists() * - Added device info tracking and firmware version reporting * - Added support for additional WLED API endpoints (/json/info, /json/playlists) * - Added new attributes: effectId, paletteId, playlistId, playlistName, playlistState, reverse * - Added new commands: reverseOn(), reverseOff(), getDeviceInfo(), testConnection() * * Improved Features: * - Improved state variables to show effect/palette/playlist IDs alongside names * - Improved retry logic for failed network requests with exponential backoff * - Improved error recovery and connection state management with health monitoring * - Improved segment validation and error handling with detailed logging * - Improved code organization with constants for better maintainability * - Improved documentation and modular architecture * * Fixed Issues: * - Fixed null pointer exceptions in state synchronization methods * - Fixed boolean handling in switch and level calculations * - Fixed @Field constant accessibility issues in Hubitat environment * - Fixed device info parsing from WLED API response structure * - Fixed playlist information handling with proper null safety * * Key Features: * - Fully Asynchronous: All network calls are non-blocking * - Robust Initialization: State machine with proper error handling * - Resilient Error Handling: Auto-retry and self-healing capabilities * - Stable Scheduler: Pre-validated cron expressions * - Optimized API Calls: Efficient state synchronization * - Enhanced WLED API Coverage: Support for more WLED features */ import groovy.json.JsonOutput import groovy.transform.Field // WLED API Constants @Field Map WLED_ENDPOINTS = [ STATE: "/json/state", FULL: "/json", INFO: "/json/info", LIVE: "/json/live", CONFIG: "/json/cfg", PLAYLISTS: "/json/playlists" ] @Field Map WLED_LIMITS = [ MAX_BRIGHTNESS: 255, MAX_SEGMENTS: 32, MAX_EFFECTS: 255, MAX_PALETTES: 255, MIN_KELVIN: 2000, MAX_KELVIN: 6500 ] @Field Map NETWORK_CONFIG = [ DEFAULT_TIMEOUT: 5, MAX_RETRIES: 3, RETRY_DELAY: 2, CONNECTION_CHECK_INTERVAL: 30 ] metadata { definition (name: "WLED Universal", namespace: "obeisser", author: "obeisser") { capability "Actuator" capability "Sensor" capability "Color Control" capability "Color Temperature" capability "ColorMode" capability "Refresh" capability "Switch" capability "Switch Level" capability "Light" capability "Alarm" attribute "colorName", "string" attribute "effectName", "string" attribute "paletteName", "string" attribute "presetValue", "number" attribute "effectDirection", "string" attribute "reverse", "string" attribute "firmwareVersion", "string" attribute "connectionState", "string" attribute "lastUpdate", "string" attribute "effectId", "number" attribute "paletteId", "number" attribute "availableEffects", "string" attribute "availablePalettes", "string" attribute "playlistName", "string" attribute "playlistId", "number" attribute "availablePlaylists", "string" attribute "playlistState", "string" // Effect Control Commands command "setEffect", [ [name:"effectId", type: "NUMBER", description: "Effect ID (0-255)"], [name:"speed", type: "NUMBER", description: "Relative Effect Speed (0-255)"], [name:"intensity", type: "NUMBER", description: "Effect Intensity (0-255)"], [name:"paletteId", type: "NUMBER", description: "Color Palette ID (0-255)"] ] command "setEffectByName", [ [name:"effectName", type: "STRING", description: "Effect Name (e.g., 'Rainbow', 'Fire 2012')"], [name:"speed", type: "NUMBER", description: "Relative Effect Speed (0-255)"], [name:"intensity", type: "NUMBER", description: "Effect Intensity (0-255)"], [name:"paletteName", type: "STRING", description: "Color Palette Name (optional)"] ] command "setPaletteByName", [ [name:"paletteName", type: "STRING", description: "Palette Name (e.g., 'Rainbow', 'Fire')"] ] command "listEffects", [[name:"List all available effects with ID numbers"]] command "listPalettes", [[name:"List all available palettes with ID numbers"]] // Playlist Control Commands command "setPlaylist", [ [name:"playlistId", type: "NUMBER", description: "Playlist ID (1-250)"] ] command "setPlaylistByName", [ [name:"playlistName", type: "STRING", description: "Playlist Name"] ] command "stopPlaylist", [[name:"Stop currently running playlist"]] command "listPlaylists", [[name:"List all available playlists with ID numbers"]] // Preset Control Commands command "setPreset", [ [name:"presetId", type: "NUMBER", description: "Preset ID"] ] // Effect Direction Control Commands command "toggleEffectDirection", [[name:"Toggle effect animation direction (forward/reverse)"]] command "reverseOn", [[name:"Turn effect reverse direction ON"]] command "reverseOff", [[name:"Turn effect reverse direction OFF"]] // Alarm Control Commands command "siren", [[name:"Activate siren alarm (Chase Flash effect with red colors)"]] command "strobe", [[name:"Activate strobe alarm (white flashing strobe effect)"]] command "both", [[name:"Activate both siren and strobe alarm (Strobe Mega effect with red/blue colors)"]] // Diagnostics command "forceRefresh", [[name:"Force refresh device state, effects, and palettes"]] command "getDeviceInfo", [[name:"Get WLED firmware version and device information"]] command "testConnection", [[name:"Test network connection to WLED device"]] } preferences { input "uri", "text", title: "WLED URI", description: "Example: http://[wled_ip_address]", required: true, displayDuringSetup: true input name: "ledSegment", type: "number", title: "LED Segment ID", defaultValue: 0, required: true input name: "transitionTime", type: "enum", title: "Default Transition Time", options: [[0:"0ms"], [400:"400ms"], [700:"700ms"], [1000:"1s"], [2000:"2s"], [5000:"5s"]], defaultValue: 700 input name: "refreshInterval", type: "enum", title: "Polling Refresh Interval", options: [[0:"Disabled"], [30:"30 Seconds"], [60:"1 Minute"], [300:"5 Minutes"], [600:"10 Minutes"], [1800:"30 Minutes"], [3600:"1 Hour"]], defaultValue: 300 input name: "powerOffParent", type: "bool", title: "Power Off Main Controller with Segment", description: "If enabled, the main WLED power will be turned off when this segment is.", defaultValue: false input name: "enableRetry", type: "bool", title: "Enable Auto-Retry on Network Errors", description: "Automatically retry failed commands", defaultValue: true input name: "connectionMonitoring", type: "bool", title: "Enable Connection Monitoring", description: "Monitor and report connection status", defaultValue: true input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true } } //--- LIFECYCLE METHODS ---// def installed() { log.info "Installing WLED Optimized Driver v1.2..." initializeState() runIn(1, updated) } def updated() { log.info "Settings updated. Initializing..." state.initialized = false state.retryCount = 0 unschedule() if (settings.uri) { initializeState() runIn(1, forceRefresh) if (settings.connectionMonitoring) { runIn(30, checkConnection) } } } def initialize() { log.info "Initializing..." runIn(1, updated) } private initializeState() { state.connectionState = "unknown" state.lastSuccessfulContact = now() state.retryCount = 0 updateAttr("connectionState", "initializing") } def parse(hubitat.scheduling.AsyncResponse response, Map data = null) { try { if (response.hasError()) { handleNetworkError("HTTP Error: ${response.getErrorMessage()}", data) return } // Reset retry count on successful response state.retryCount = 0 state.lastSuccessfulContact = now() updateAttr("connectionState", "connected") updateAttr("lastUpdate", new Date().format("yyyy-MM-dd HH:mm:ss")) def msg = response.getJson() if (logEnable) log.debug "Response JSON: ${msg}" if (!msg) { log.warn "Received empty or invalid JSON response." return } // Handle device info response (check both root level and info section) if (msg.containsKey("ver") && msg.containsKey("vid")) { handleDeviceInfo(msg) } else if (msg.containsKey("info") && msg.info.containsKey("ver")) { handleDeviceInfo(msg.info) } // Handle playlists response if (msg.containsKey("playlists")) { handlePlaylistsInfo(msg.playlists) } // Handle full state response (initialization) if (msg.containsKey("state") && msg.containsKey("effects") && msg.containsKey("palettes")) { boolean wasInitialized = state.initialized state.effects = msg.effects state.palettes = msg.palettes state.segments = msg.state.seg ?: [] synchronizeState(msg.state) if (!wasInitialized) { log.info "Full device info received. Driver initialization complete." log.info "Loaded ${state.effects.size()} effects and ${state.palettes.size()} palettes" setSchedule() state.initialized = true // Get device info after initialization runIn(2, getDeviceInfo) // Get playlists info runIn(3, getPlaylists) // List available effects and palettes runIn(4, listEffects) runIn(5, listPalettes) } } // Handle state-only response else if (state.initialized) { if (msg.state) { synchronizeState(msg.state) } else { synchronizeState(msg) } } else { log.warn "Driver not fully initialized. Ignoring partial state update. Waiting for full refresh." } } catch (e) { log.error "Fatal error parsing response: ${e.message}" handleNetworkError("Parse error: ${e.message}", data) } } //--- CAPABILITY COMMANDS ---// def on() { sendWledCommand([on:true, seg: [[id: ledSegment.toInteger(), on:true]]]) } def off() { def payload = [seg: [[id: ledSegment.toInteger(), on:false]]] if (powerOffParent) { payload.on = false } sendWledCommand(payload) } def setLevel(value, rate = null) { if (value > 99) value = 100 if (value < 0) value = 0 if (value == 0) { off() } else { def brightness = (value * 2.55).toInteger() sendWledCommand([on: true, seg: [[id: ledSegment.toInteger(), on: true, bri: brightness]]], rate) } } def setColor(value) { if (value.level == 0) { off(); return } def rgb = hsvToRgb(value.hue, value.saturation, 100) def level = (value.level * 2.55).toInteger() def payload = [on: true, seg: [[id: ledSegment.toInteger(), on: true, bri: level, col: [rgb], fx: 0]]] sendWledCommand(payload) } def setColorTemperature(value) { if (logEnable) log.debug "Setting color temperature to ${value}K" def rgb = colorTempToRgb(value) def payload = [on: true, seg: [[id: ledSegment.toInteger(), on: true, col: [rgb], fx: 0]]] sendWledCommand(payload) sendEvent(name: "colorTemperature", value: value) sendEvent(name: "colorMode", value: "CT") setGenericNameFromTemp(value) } def refresh() { sendEthernetGet(WLED_ENDPOINTS.STATE) } //--- CUSTOM COMMANDS ---// def forceRefresh() { log.info "Forcing full refresh of state, effects, and palettes..." sendEthernetGet(WLED_ENDPOINTS.FULL) } def toggleEffectDirection() { def currentDirection = device.currentValue("effectDirection") boolean newReverseState = (currentDirection == "reverse") ? false : true if (logEnable) log.debug "Toggling effect direction. Current: ${currentDirection}, New reverse state: ${newReverseState}" setEffectReverse(newReverseState) } def reverseOn() { if (logEnable) log.debug "Turning reverse effect ON" setEffectReverse(true) } def reverseOff() { if (logEnable) log.debug "Turning reverse effect OFF" setEffectReverse(false) } def getDeviceInfo() { if (logEnable) log.debug "Requesting device information..." sendEthernetGet(WLED_ENDPOINTS.INFO) } def testConnection() { log.info "Testing connection to WLED device..." updateAttr("connectionState", "testing") sendEthernetGet(WLED_ENDPOINTS.STATE) } private setEffectReverse(boolean reverse) { if (!validateSegment(settings.ledSegment.toInteger())) return def payload = [seg: [[id: settings.ledSegment.toInteger(), rev: reverse]]] sendWledCommandWithRetry(payload) } def setEffect(Number effectId, Number speed = null, Number intensity = null, Number paletteId = null) { def seg = [id: ledSegment.toInteger(), fx: effectId.toInteger()] if (speed != null) seg.sx = speed.toInteger() if (intensity != null) seg.ix = intensity.toInteger() if (paletteId != null) seg.pal = paletteId.toInteger() sendWledCommand([on: true, seg: [seg]]) } def setEffectByName(String effectName, Number speed = null, Number intensity = null, String paletteName = null) { if (!state.effects) { log.error "Effects list not available. Please refresh the device first." return } def effectId = findEffectIdByName(effectName) if (effectId == null) { log.error "Effect '${effectName}' not found. Available effects: ${getEffectsList()}" return } def paletteId = null if (paletteName) { paletteId = findPaletteIdByName(paletteName) if (paletteId == null) { log.warn "Palette '${paletteName}' not found. Using current palette. Available palettes: ${getPalettesList()}" } } if (logEnable) log.debug "Setting effect '${effectName}' (ID: ${effectId}) with speed: ${speed}, intensity: ${intensity}, palette: ${paletteName} (ID: ${paletteId})" setEffect(effectId, speed, intensity, paletteId) } def setPaletteByName(String paletteName) { if (!state.palettes) { log.error "Palettes list not available. Please refresh the device first." return } def paletteId = findPaletteIdByName(paletteName) if (paletteId == null) { log.error "Palette '${paletteName}' not found. Available palettes: ${getPalettesList()}" return } if (logEnable) log.debug "Setting palette '${paletteName}' (ID: ${paletteId})" def payload = [seg: [[id: settings.ledSegment.toInteger(), pal: paletteId]]] sendWledCommand(payload) } def listEffects() { if (!state.effects) { log.warn "Effects list not available. Refreshing device..." forceRefresh() return } def effectsList = getEffectsList() log.info "Available Effects (${state.effects.size()}): ${effectsList}" updateAttr("availableEffects", effectsList) } def listPalettes() { if (!state.palettes) { log.warn "Palettes list not available. Refreshing device..." forceRefresh() return } def palettesList = getPalettesList() log.info "Available Palettes (${state.palettes.size()}): ${palettesList}" updateAttr("availablePalettes", palettesList) } def setPreset(Number presetId) { sendWledCommand([ps: presetId.toInteger()]) } def setPlaylist(Number playlistId) { if (playlistId < 1 || playlistId > 250) { log.error "Invalid playlist ID: ${playlistId}. Must be between 1-250." return } if (logEnable) log.debug "Starting playlist ID: ${playlistId}" sendWledCommand([playlist: [ps: playlistId, on: true]]) } def setPlaylistByName(String playlistName) { if (!state.playlists) { log.error "Playlists not available. Please refresh the device first." return } def playlistId = findPlaylistIdByName(playlistName) if (playlistId == null) { log.error "Playlist '${playlistName}' not found. Available playlists: ${getPlaylistsList()}" return } if (logEnable) log.debug "Starting playlist '${playlistName}' (ID: ${playlistId})" setPlaylist(playlistId) } def stopPlaylist() { if (logEnable) log.debug "Stopping current playlist" sendWledCommand([playlist: [on: false]]) } def listPlaylists() { if (!state.playlists) { log.warn "Playlists not available. Refreshing device..." getPlaylists() return } def playlistsList = getPlaylistsList() log.info "Available Playlists (${state.playlists.size()}): ${playlistsList}" updateAttr("availablePlaylists", playlistsList) } def getPlaylists() { if (logEnable) log.debug "Requesting playlists information..." sendEthernetGet(WLED_ENDPOINTS.PLAYLISTS) } //--- ALARM CAPABILITY ---// def siren() { // Use Chase Flash effect with Fire palette for intense red siren alarm setEffectByName("Chase Flash", 255, 255, "Fire") } def strobe() { // Use Strobe effect with white/default palette for visual alarm setEffectByName("Strobe", 255, 255, "Default") } def both() { // Use Lightning effect with Red & Blue palette for emergency alarm // Strobe Mega provides intense, attention-grabbing multicolor strobe effect suitable for "both" alarm setEffectByName("Strobe Mega", 255, 255, "Red & Blue") } //--- SYNCHRONIZATION ---// private synchronizeState(wledState) { try { if (logEnable) log.debug "Synchronizing state..." if (!wledState) { log.warn "Synchronize called with null state." return } def segmentId = settings.ledSegment?.toInteger() ?: 0 def seg = wledState.seg?.find { it.id == segmentId } if (!seg) { log.warn "Segment ID ${segmentId} not found in state. Cannot synchronize." return } // Update basic switch and level updateSwitchAndLevel(seg) // Update color information updateColorInformation(seg) // Update effect information updateEffectInformation(seg, wledState) } catch (Exception e) { log.error "Error synchronizing state: ${e.message}" } } private updateSwitchAndLevel(seg) { try { if (logEnable) log.debug "updateSwitchAndLevel called with seg: ${seg}" def isOn = seg?.on == true def switchValue = isOn ? "on" : "off" if (logEnable) log.debug "Setting switch to: ${switchValue} (seg.on = ${seg?.on})" updateAttr("switch", switchValue) def brightness = seg?.bri ?: 255 if (logEnable) log.debug "Brightness value: ${brightness}" def newLevel = 0 if (isOn && brightness != null) { newLevel = Math.round(brightness / 2.55) } if (logEnable) log.debug "Setting level to: ${newLevel} (brightness: ${brightness}, isOn: ${isOn})" updateAttr("level", newLevel, "%") if (logEnable) log.debug "updateSwitchAndLevel completed successfully" } catch (Exception e) { log.error "Error updating switch and level: ${e.message}" log.debug "Segment data: ${seg}" log.debug "Stack trace: ${e.getStackTrace()}" } } private updateColorInformation(seg) { try { def isEffect = (seg?.fx ?: 0) > 0 updateAttr("colorMode", isEffect ? "CT" : "RGB") if (!isEffect && seg?.col && seg.col[0]) { def rgb = seg.col[0] def estimatedKelvin = estimateColorTemperature(rgb[0], rgb[1], rgb[2]) if (estimatedKelvin) { updateAttr("colorTemperature", estimatedKelvin) setGenericNameFromTemp(estimatedKelvin) } else { def hsv = rgbToHsv(rgb[0], rgb[1], rgb[2]) updateAttr("hue", hsv?.H ?: 0) updateAttr("saturation", hsv?.S ?: 0) setGenericNameFromHue(hsv?.H ?: 0) } } else { updateAttr("colorName", "Effect") } } catch (Exception e) { log.error "Error updating color information: ${e.message}" updateAttr("colorName", "Unknown") } } private updateEffectInformation(seg, wledState) { try { // Update effect information with ID numbers updateAttr("effectId", seg?.fx ?: 0) updateAttr("paletteId", seg?.pal ?: 0) if (state.effects) { def effectId = seg?.fx ?: 0 updateAttr("effectName", state.effects[effectId] ?: "Unknown") } if (state.palettes) { def paletteId = seg?.pal ?: 0 updateAttr("paletteName", state.palettes[paletteId] ?: "Unknown") } updateAttr("presetValue", wledState?.ps ?: 0) // Update playlist information updatePlaylistInformation(wledState) def isReverse = seg?.rev ?: false updateAttr("effectDirection", isReverse ? "reverse" : "forward") updateAttr("reverse", isReverse ? "on" : "off") // Update available lists when effects/palettes are loaded if (state.effects && !device.currentValue("availableEffects")) { updateAttr("availableEffects", getEffectsList()) } if (state.palettes && !device.currentValue("availablePalettes")) { updateAttr("availablePalettes", getPalettesList()) } if (state.playlists && !device.currentValue("availablePlaylists")) { updateAttr("availablePlaylists", getPlaylistsList()) } } catch (Exception e) { log.error "Error updating effect information: ${e.message}" } } private handleDeviceInfo(deviceInfo) { if (logEnable) log.debug "Processing device info: ${deviceInfo}" if (deviceInfo.ver) { state.wledVersion = deviceInfo.ver updateAttr("firmwareVersion", deviceInfo.ver) } if (deviceInfo.vid) { state.wledBuild = deviceInfo.vid } if (deviceInfo.name) { state.deviceName = deviceInfo.name } log.info "WLED Device Info - Version: ${deviceInfo.ver}, Build: ${deviceInfo.vid}, Name: ${deviceInfo.name}" } //--- HTTP COMMUNICATIONS ---// private sendWledCommand(Map payload, Number transitionRate = null) { payload.v = true payload.tt = (transitionRate != null) ? (transitionRate * 100).toInteger() : settings.transitionTime.toInteger() sendEthernetPost(WLED_ENDPOINTS.STATE, JsonOutput.toJson(payload)) } private sendWledCommandWithRetry(Map payload, Number transitionRate = null, int retryCount = 0) { if (!settings.enableRetry || retryCount >= 3) { sendWledCommand(payload, transitionRate) return } def commandData = [payload: payload, transitionRate: transitionRate, retryCount: retryCount] payload.v = true payload.tt = (transitionRate != null) ? (transitionRate * 100).toInteger() : settings.transitionTime.toInteger() try { sendEthernetPost(WLED_ENDPOINTS.STATE, JsonOutput.toJson(payload), commandData) } catch (Exception e) { log.warn "Command failed, will retry if network error occurs. Error: ${e.message}" sendEthernetPost(WLED_ENDPOINTS.STATE, JsonOutput.toJson(payload), commandData) } } private sendEthernetGet(String path, Map data = null) { if (!settings.uri) { log.error "WLED URI is not set." updateAttr("connectionState", "error") return } try { asynchttpGet("parse", [ uri: settings.uri, path: path, timeout: 5 ], data) } catch (e) { log.error "asynchttpGet error: ${e.message}" handleNetworkError("GET request failed: ${e.message}", data) } } private sendEthernetPost(String path, String body, Map data = null) { if (!settings.uri) { log.error "WLED URI is not set." updateAttr("connectionState", "error") return } try { asynchttpPost("parse", [ uri: settings.uri, path: path, timeout: 5, contentType: 'application/json', body: body ], data) } catch (e) { log.error "asynchttpPost error: ${e.message}" handleNetworkError("POST request failed: ${e.message}", data) } } private handleNetworkError(String errorMessage, Map data = null) { log.warn "Network error: ${errorMessage}" updateAttr("connectionState", "error") if (!settings.enableRetry || !data || !data.retryCount) return def retryCount = data.retryCount + 1 if (retryCount <= 3) { log.info "Retrying command in 2 seconds (attempt ${retryCount}/3)" runIn(2, "retryCommand", [data: data]) } else { log.error "Max retry attempts (3) exceeded. Command failed permanently." } } def retryCommand(Map data) { if (data.payload) { log.info "Retrying WLED command (attempt ${data.retryCount + 1})" sendWledCommandWithRetry(data.payload, data.transitionRate, data.retryCount) } } def checkConnection() { if (!settings.connectionMonitoring) return def timeSinceLastContact = now() - (state.lastSuccessfulContact ?: 0) if (timeSinceLastContact > (30 * 2000)) { log.warn "No successful contact with WLED device for ${timeSinceLastContact/1000} seconds" updateAttr("connectionState", "disconnected") testConnection() } // Schedule next check runIn(30, checkConnection) } //--- HELPER METHODS ---// private updateAttr(String attrName, newValue, String unit = "") { try { def currentValue = device.currentValue(attrName) if ("${currentValue}" != "${newValue}") { sendEvent(name: attrName, value: newValue, unit: unit) } } catch (Exception e) { log.error "Error updating attribute ${attrName}: ${e.message}" // Try to send the event anyway try { sendEvent(name: attrName, value: newValue, unit: unit) } catch (Exception e2) { log.error "Failed to send event for ${attrName}: ${e2.message}" } } } private validateSegment(int segmentId) { if (!state.segments) { log.warn "Segment information not available. Proceeding with command." return true // Allow command to proceed, WLED will handle invalid segments } def segment = state.segments.find { it.id == segmentId } if (!segment) { log.warn "Segment ${segmentId} not found on device. Available segments: ${state.segments.collect{it.id}}" return false } return true } private setSchedule() { def interval = settings.refreshInterval.toInteger() if (interval == 0) { log.info "Polling disabled." return } String cron switch(interval) { case 30: cron = "0/30 * * * * ?"; break case 60: cron = "0 * * * * ?"; break case 300: cron = "0 0/5 * * * ?"; break case 600: cron = "0 0/10 * * * ?"; break case 1800: cron = "0 0/30 * * * ?"; break case 3600: cron = "0 0 * * * ?"; break default: log.warn "Unsupported refresh interval: ${interval}. Disabling polling." return } try { schedule(cron, refresh) log.info "Device polling scheduled with cron: '${cron}' (every ${interval} seconds)" } catch (e) { log.error "Failed to create schedule with cron '${cron}'. Polling will be disabled. Error: ${e.message}" } } //--- COLOR CONVERSION AND NAMING ---// private Integer estimateColorTemperature(int r, int g, int b) { if (r == null || g == null || b == null) return null if (Math.abs(r - g) > 30 && Math.abs(r - b) > 30 && Math.abs(g - b) > 30) return null float r_f = r / 255.0f float g_f = g / 255.0f float b_f = b / 255.0f float max = Math.max(r_f, Math.max(g_f, b_f)) float min = Math.min(r_f, Math.min(g_f, b_f)) if (max - min < 0.2) return 6600 float temperature if (r > g && g > b) { temperature = 40000.0 / Math.pow(r_f * 1.1 + 0.1, 1.2) } else { temperature = 6600 + (b_f - g_f) * 5000 } int kelvin = clamp(temperature.toInteger(), 2000, 6500) if (kelvin > 4000 && kelvin < 5000 && (r < 200 || g < 200 || b < 200)) return null return kelvin } private List hsvToRgb(float hue, float saturation, float value) { hue /= 100 saturation /= 100 value /= 100 int h = (int)(hue * 6) float f = hue * 6 - h float p = value * (1 - saturation) float q = value * (1 - f * saturation) float t = value * (1 - (1 - f) * saturation) switch (h) { case 0: return [(int)(value * 255), (int)(t * 255), (int)(p * 255)] case 1: return [(int)(q * 255), (int)(value * 255), (int)(p * 255)] case 2: return [(int)(p * 255), (int)(value * 255), (int)(t * 255)] case 3: return [(int)(p * 255), (int)(q * 255), (int)(value * 255)] case 4: return [(int)(t * 255), (int)(p * 255), (int)(value * 255)] case 5: return [(int)(value * 255), (int)(p * 255), (int)(q * 255)] default: return [0, 0, 0] } } private Map rgbToHsv(int r, int g, int b) { if (r == null || g == null || b == null) return [H:0, S:0] float R = r / 255f float G = g / 255f float B = b / 255f float cmax = [R, G, B].max() float cmin = [R, G, B].min() float delta = cmax - cmin float hue = 0 if (delta != 0) { if (cmax == R) { hue = 60 * (((G - B) / delta) % 6) } else if (cmax == G) { hue = 60 * (((B - R) / delta) + 2) } else if (cmax == B) { hue = 60 * (((R - G) / delta) + 4) } } if (hue < 0) hue += 360 float saturation = (cmax == 0) ? 0 : (delta / cmax) return [H: (hue/3.6).round(), S: (saturation * 100).round()] } private List colorTempToRgb(kelvin) { def temp = kelvin.toInteger() / 100 def red, green, blue if (temp <= 66) { red = 255 green = 99.4708025861 * Math.log(temp) - 161.1195681661 if (temp <= 19) { blue = 0 } else { blue = 138.5177312231 * Math.log(temp - 10) - 305.0447927307 } } else { red = 329.698727446 * Math.pow(temp - 60, -0.1332047592) green = 288.1221695283 * Math.pow(temp - 60, -0.0755148492) blue = 255 } return [ clamp(red, 0, 255), clamp(green, 0, 255), clamp(blue, 0, 255) ] } private int clamp(num, min, max) { return Math.max(min, Math.min(num, max)).toInteger() } private setGenericNameFromHue(hue) { def colorName hue = hue.toInteger() def hue360 = hue * 3.6 switch (hue360.toInteger()) { case 0..15: colorName = "Red"; break case 16..45: colorName = "Orange"; break case 46..75: colorName = "Yellow"; break case 76..105: colorName = "Chartreuse"; break case 106..135: colorName = "Green"; break case 136..165: colorName = "Spring"; break case 166..195: colorName = "Cyan"; break case 196..225: colorName = "Azure"; break case 226..255: colorName = "Blue"; break case 256..285: colorName = "Violet"; break case 286..315: colorName = "Magenta"; break case 316..345: colorName = "Rose"; break default: colorName = "Red"; break } updateAttr("colorName", colorName) } private setGenericNameFromTemp(temp) { def genericName def value = temp.toInteger() if (value <= 2000) { genericName = "Sodium" } else if (value < 2800) { genericName = "Incandescent" } else if (value < 3500) { genericName = "Warm White" } else if (value <= 5000) { genericName = "Daylight" } else if (value <= 6500) { genericName = "Skylight" } else { genericName = "Polar" } updateAttr("colorName", genericName) } //--- EFFECT AND PALETTE HELPER METHODS ---// private Integer findEffectIdByName(String effectName) { if (!state.effects) return null // Try exact match first def exactMatch = state.effects.findIndexOf { it.toLowerCase() == effectName.toLowerCase() } if (exactMatch >= 0) return exactMatch // Try partial match def partialMatch = state.effects.findIndexOf { it.toLowerCase().contains(effectName.toLowerCase()) } if (partialMatch >= 0) { if (logEnable) log.debug "Found partial match for '${effectName}': '${state.effects[partialMatch]}' (ID: ${partialMatch})" return partialMatch } return null } private Integer findPaletteIdByName(String paletteName) { if (!state.palettes) return null // Try exact match first def exactMatch = state.palettes.findIndexOf { it.toLowerCase() == paletteName.toLowerCase() } if (exactMatch >= 0) return exactMatch // Try partial match def partialMatch = state.palettes.findIndexOf { it.toLowerCase().contains(paletteName.toLowerCase()) } if (partialMatch >= 0) { if (logEnable) log.debug "Found partial match for '${paletteName}': '${state.palettes[partialMatch]}' (ID: ${partialMatch})" return partialMatch } return null } private String getEffectsList() { try { if (!state.effects) return "Effects not loaded" def effectsWithIds = [] state.effects.eachWithIndex { effect, index -> effectsWithIds.add("${index}: ${effect ?: 'Unknown'}") } return effectsWithIds.join(", ") } catch (Exception e) { log.error "Error getting effects list: ${e.message}" return "Error loading effects" } } private String getPalettesList() { try { if (!state.palettes) return "Palettes not loaded" def palettesWithIds = [] state.palettes.eachWithIndex { palette, index -> palettesWithIds.add("${index}: ${palette ?: 'Unknown'}") } return palettesWithIds.join(", ") } catch (Exception e) { log.error "Error getting palettes list: ${e.message}" return "Error loading palettes" } } //--- PLAYLIST HELPER METHODS ---// private Integer findPlaylistIdByName(String playlistName) { if (!state.playlists) return null // Try exact match first def exactMatch = state.playlists.find { it.value.name?.toLowerCase() == playlistName.toLowerCase() } if (exactMatch) return exactMatch.key.toInteger() // Try partial match def partialMatch = state.playlists.find { it.value.name?.toLowerCase()?.contains(playlistName.toLowerCase()) } if (partialMatch) { if (logEnable) log.debug "Found partial match for '${playlistName}': '${partialMatch.value.name}' (ID: ${partialMatch.key})" return partialMatch.key.toInteger() } return null } private String getPlaylistsList() { try { if (!state.playlists) return "Playlists not loaded" def playlistsWithIds = [] state.playlists.each { id, playlist -> def name = playlist?.name ?: "Unnamed Playlist" playlistsWithIds.add("${id}: ${name}") } return playlistsWithIds.join(", ") } catch (Exception e) { log.error "Error getting playlists list: ${e.message}" return "Error loading playlists" } } private updatePlaylistInformation(wledState) { try { // Check if a playlist is currently running if (wledState?.playlist && wledState.playlist.ps) { def currentPlaylistId = wledState.playlist.ps updateAttr("playlistId", currentPlaylistId) updateAttr("playlistState", wledState.playlist.on ? "running" : "stopped") if (state.playlists && state.playlists[currentPlaylistId.toString()]) { def playlistName = state.playlists[currentPlaylistId.toString()].name ?: "Unnamed Playlist" updateAttr("playlistName", playlistName) } else { updateAttr("playlistName", "Unknown Playlist") } } else { updateAttr("playlistId", 0) updateAttr("playlistState", "none") updateAttr("playlistName", "None") } } catch (Exception e) { if (logEnable) log.debug "Error updating playlist information: ${e.message}" updateAttr("playlistId", 0) updateAttr("playlistState", "none") updateAttr("playlistName", "None") } } private handlePlaylistsInfo(playlistsData) { try { if (logEnable) log.debug "Processing playlists info: ${playlistsData}" if (playlistsData) { state.playlists = playlistsData def playlistCount = playlistsData.size() log.info "Loaded ${playlistCount} playlists" // Update available playlists attribute updateAttr("availablePlaylists", getPlaylistsList()) } else { state.playlists = [:] updateAttr("availablePlaylists", "No playlists available") log.info "No playlists found on device" } } catch (Exception e) { log.warn "Error processing playlists info: ${e.message}" state.playlists = [:] updateAttr("availablePlaylists", "Error loading playlists") } }