/** * Advanced Motion Lighting Child * * Author: ShaneAllen */ definition( name: "Advanced Motion Lighting Child", namespace: "ShaneAllen", author: "ShaneAllen", description: "Commercial-grade Lighting Engine: Multiple Disable Switches, Triple-Check Offs, Boot Recovery, Window Shade (Contact) logic, ROI Telemetry, and Dynamic Variable Tracking.", category: "Convenience", parent: "ShaneAllen:Advanced Motion Lighting", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") } def mainPage() { dynamicPage(name: "mainPage", title: "Advanced Motion Lighting Rule", install: true, uninstall: true) { section("Live System Dashboard") { if (pauseApp || parent?.isSystemPaused()) { def source = parent?.isSystemPaused() ? "System-Wide" : "Rule level" paragraph "
⚠️ This rule is currently PAUSED (${source}). Automations will not execute.
" } input "btnRefresh", "button", title: "Refresh Data" if (motionSensors || triggerSwitches) { def mState = isPrimaryActive() ? "ACTIVE" : "INACTIVE" def lState = (switches?.any { it.currentValue("switch") == "on" } || dimmers?.any { it.currentValue("switch") == "on" } || colorBulbs?.any { it.currentValue("switch") == "on" }) ? "ON" : "OFF" def wState = atomicState.warningPhase ? " (WARNING PHASE)" : "" def manualLock = atomicState.manuallyTurnedOn ? " (MANUAL OVERRIDE)" : "" def arrivalLock = atomicState.arrivalActive ? " (ARRIVAL OVERRIDE)" : "" def modeInfo = location.mode def overrides = [] if (atomicState.partyLock) overrides << "PARTY LOCK ON" if (disableOnSwitches?.any { it.currentValue("switch") == "on" }) overrides << "ON Disabled" if (disableOffSwitches?.any { it.currentValue("switch") == "on" }) overrides << "OFF Disabled" if (goodNightSwitch && goodNightSwitch.currentValue("switch") == "on") overrides << "Nap Lock Active" if (enableOccupancyLock && occupancyLockContact?.currentValue("contact") == "closed") overrides << "Occupancy Locked ON" if (isKeepAliveActive()) { overrides << "Keep-Alive Active" } def timeBlocked = false if (restrictByTime && startTimeType && endTimeType) { def sTime = resolveTime(startTimeType, startTime, startOffset) def eTime = resolveTime(endTimeType, endTime, endOffset) if (sTime && eTime) { def isInside = isTimeInWindow(sTime, eTime) timeBlocked = (timeLogic == "Block execution DURING this window") ? isInside : !isInside } } if (timeBlocked) overrides << "Blocked by Time Window" def isOpen = (contactSensors?.any { it.currentValue("contact") == "open" } || shadeSensors?.any { it.currentValue("contact") == "open" }) if (isOpen) { def luxOverrideActive = false if (useLuxContactOverride && luxSensor) { def curLux = luxSensor.currentValue("illuminance") ?: 0 def targetLux = luxContactThreshold ?: 0 if (luxContactVar) { def hVar = getGlobalVar(luxContactVar) if (hVar != null) targetLux = hVar.value.toInteger() } if (curLux < targetLux) luxOverrideActive = true } if ((overcastSwitch && overcastSwitch.currentValue("switch") == "on") || luxOverrideActive) { overrides << "Window/Shade Override Active (Overcast/Lux)" } else { overrides << "Blocked by Open Window/Shade" } } def oText = overrides ? overrides.join(" | ") : "Clear" def statusText = "" statusText += "" statusText += "" statusText += "
Primary TriggerLight StateCurrent ModeActive Blocks/Overrides
${mState}${lState}${wState}${manualLock}${arrivalLock}${modeInfo}${oText}
" if (enableTriggerTracking && atomicState.lastTriggerSource) { statusText += "
Last Activated By: ${atomicState.lastTriggerSource}
" } // --- TIMER DASHBOARD --- def timerText = "" def tz = location.timeZone ?: TimeZone.getDefault() def currentTime = now() if (atomicState.stdTaskTime && atomicState.stdTaskTime > currentTime) { def diff = atomicState.stdTaskTime - currentTime timerText += "Standard Timeout: ${(diff / 60000).toInteger()}m ${((diff % 60000) / 1000).toInteger()}s" } if (timerText) { statusText += "
Active Timers: ${timerText}
" } if (luxSensor) { def curLux = luxSensor.currentValue("illuminance") ?: 0 statusText += "
Current Light Level: ${curLux} lx
" } if (enableTelemetry) { def liveCalc = calculateLiveSavings() statusText += "
Predictive ROI: Today: $${liveCalc.today} | Total: $${liveCalc.lifetime}
" } paragraph statusText } else { paragraph "Configure sensors below to see the live dashboard." } } section("
1. Core Configuration
", hideable: true, hidden: false) { label title: "Rule Name", required: true input "pauseApp", "bool", title: "⏸️ Pause this Lighting Rule?", defaultValue: false, submitOnChange: true input "activeModes", "mode", title: "Active Modes (Allowed to turn ON)", multiple: true, required: false input "adjustOnModeChange", "bool", title: "Adjust lights dynamically on Mode change?", defaultValue: true // FULLY RESTORED DYNAMIC SETTINGS input "dynamicAdjust", "bool", title: "Adjust lights dynamically if Hub Variable / Virtual Dimmer changes while ON?", defaultValue: true, submitOnChange: true input "lightType", "enum", title: "What type of lights are we controlling?", options: ["Simple On/Off", "Adjustable Bulb / Dimmer", "Color / CT Bulb"], required: true, submitOnChange: true if (dynamicAdjust && lightType == "Color / CT Bulb") { input "dynamicTransition", "number", title: "Dynamic Shift Fade Time (Seconds)", defaultValue: 5, description: "How slowly the bulbs will fade to the new setpoint." } if (lightType == "Simple On/Off") { input "switches", "capability.switch", title: "Switches to control", multiple: true, required: true } else if (lightType == "Adjustable Bulb / Dimmer") { input "dimmers", "capability.switchLevel", title: "Dimmers to control", multiple: true, required: true } else if (lightType == "Color / CT Bulb") { input "colorBulbs", "capability.colorTemperature", title: "Color/CT Bulbs to control", multiple: true, required: true } if (lightType == "Adjustable Bulb / Dimmer" || lightType == "Color / CT Bulb") { input "isSmartBulbOnRelay", "bool", title: "Are these smart bulbs on a power-cutting Smart Switch?", defaultValue: false, submitOnChange: true if (isSmartBulbOnRelay) { input "relaySwitch", "capability.switch", title: "The Relay providing power", required: true input "relayDelay", "number", title: "Boot Delay (ms)", defaultValue: 1500 } } paragraph "
" paragraph "Optional Button / Override Control" input "optButton", "capability.pushableButton", title: "Select Button Device (Overrides Lights)", required: false, submitOnChange: true if (optButton) { input "optButtonNum", "number", title: "Button Number", required: true, defaultValue: 1 input "optButtonAction", "enum", title: "Button Action", options: ["pushed", "held", "doubleTapped", "released"], required: true, defaultValue: "pushed" input "optButtonModes", "mode", title: "Allowed Modes for Button", multiple: true, required: false input "toggleSyncLights", "capability.switch", title: "Group Toggle Sync (Check these lights before toggling)", multiple: true, required: false, description: "Select the other lights controlled by this button to prevent flip-flopping." } } section("
2. Triggers & Intelligence
", hideable: true, hidden: false) { input "motionSensors", "capability.motionSensor", title: "Primary Motion Sensors", multiple: true, required: false input "triggerSwitches", "capability.switch", title: "Primary Trigger Switches (Virtual Occupancy)", multiple: true, required: false, description: "WARNING: DO NOT select the lights you are controlling here!" input "enableTriggerTracking", "bool", title: "Enable 'Last Triggered By' Intelligence?", defaultValue: false input "motionDebounceSeconds", "number", title: "Debounce (Seconds of continuous motion required)", defaultValue: 0 input "luxSensor", "capability.illuminanceMeasurement", title: "Illuminance (Lux) Sensor", required: false input "enableActiveLuxPolling", "bool", title: "Enable Active Lux Polling (Sunset Fade-In)?", defaultValue: false, description: "If the room is occupied but lights are off due to Lux, this will continually check and turn them on when the sun goes down." } section("
3. Turn ON & Transitions
", hideable: true, hidden: true) { input "useModeSettings", "bool", title: "Use specific settings per mode?", defaultValue: false, submitOnChange: true input "enableSoftStart", "bool", title: "Enable Soft-Start (Transition Ramping)?", defaultValue: false, submitOnChange: true if (enableSoftStart) { input "softStartTime", "number", title: "Ramp Duration (Seconds)", defaultValue: 3 } if (useModeSettings) { location.modes.each { m -> paragraph "${m.name} Settings" // FULLY RESTORED HUB VARIABLE DIMMER/LEVEL LOGIC if (lightType == "Adjustable Bulb / Dimmer" || lightType == "Color / CT Bulb") { input "useLevelDimmer_${m.id}", "bool", title: "Follow a Virtual Dimmer for Level?", defaultValue: false, submitOnChange: true if (settings["useLevelDimmer_${m.id}"]) { input "levelDimmer_${m.id}", "capability.switchLevel", title: "Select Virtual Dimmer to follow", required: true } else { input "useLevelVar_${m.id}", "bool", title: "Use a Hub Variable for Level?", defaultValue: false, submitOnChange: true if (settings["useLevelVar_${m.id}"]) { input "levelVarName_${m.id}", "string", title: "Hub Variable Name (Exact text)" } else { input "level_${m.id}", "number", title: "Dim Level (%)", range: "1..100" } } } if (lightType == "Color / CT Bulb") { input "useCTVar_${m.id}", "bool", title: "Use a Hub Variable for Color Temp?", defaultValue: false, submitOnChange: true if (settings["useCTVar_${m.id}"]) { input "ctVarName_${m.id}", "string", title: "Hub Variable Name (Exact text)" } else { input "ct_${m.id}", "number", title: "Color Temp (K)", range: "2000..7000" } } input "delay_${m.id}", "number", title: "Turn Off Delay (Minutes)" input "lux_${m.id}", "number", title: "Lux Threshold (Only ON if below this)" } } else { if (lightType == "Adjustable Bulb / Dimmer" || lightType == "Color / CT Bulb") { input "useLevelDimmer", "bool", title: "Follow a Virtual Dimmer for Level?", defaultValue: false, submitOnChange: true if (useLevelDimmer) { input "levelDimmer", "capability.switchLevel", title: "Select Virtual Dimmer to follow", required: true } else { input "useLevelVar", "bool", title: "Use a Hub Variable for Level?", defaultValue: false, submitOnChange: true if (useLevelVar) { input "levelVarName", "string", title: "Hub Variable Name (Exact text)" } else { input "defaultLevel", "number", title: "Default Dim Level (%)", defaultValue: 100 } } } if (lightType == "Color / CT Bulb") { input "useCTVar", "bool", title: "Use a Hub Variable for Color Temp?", defaultValue: false, submitOnChange: true if (useCTVar) { input "ctVarName", "string", title: "Hub Variable Name (Exact text)" } else { input "defaultCT", "number", title: "Default Color Temp (K)", defaultValue: 2700 } } input "defaultDelay", "number", title: "Standard Turn Off Delay (Minutes)", defaultValue: 5, required: true } } section("
4. Turn OFF Behaviors
", hideable: true, hidden: true) { input "gracePeriod", "number", title: "Grace Period after Manual Off (Seconds)", defaultValue: 15 input "manualTimeoutMinutes", "number", title: "Manual Override Motion Shutoff Timer (Minutes)", defaultValue: 30, description: "If a light is manually turned on, wait this long after motion stops before turning off." input "enableWarningDim", "bool", title: "Enable Warning Dim Phase?", defaultValue: false, submitOnChange: true if (enableWarningDim && lightType != "Simple On/Off") { input "warningDimLevel", "number", title: "Warning Dim Level (%)", defaultValue: 10 input "warningDimSeconds", "number", title: "Warning Duration (Seconds)", defaultValue: 30 } input "dimBeforeOff", "bool", title: "Dim to 1% before turning off?", defaultValue: true, description: "Disable this for bulbs that don't need a forced soft-fade." input "turnOffOnModes", "mode", title: "Force OFF when Hub enters these Modes", multiple: true if (isSmartBulbOnRelay) { input "turnOffRelay", "bool", title: "Turn off Relay Power when lights turn off?", defaultValue: true } paragraph "Absolute Force-Off (Sweep)" input "enableForceOff", "bool", title: "Enable Absolute Force-Off?", defaultValue: false, submitOnChange: true if (enableForceOff) input "forceOffMinutes", "number", title: "Sweep Delay (Minutes)", defaultValue: 60 } section("
5. Advanced Restrictions & True Occupancy
", hideable: true, hidden: true) { input "disableOnSwitches", "capability.switch", title: "Switches to Disable Turning ON", multiple: true, required: false input "disableOffSwitches", "capability.switch", title: "Switches to Disable Turning OFF", multiple: true, required: false input "goodNightSwitch", "capability.switch", title: "Nap Time / Hard-Lock Switch", required: false input "enableOccupancyLock", "bool", title: "Enable True Occupancy (Door Lock)?", defaultValue: false, submitOnChange: true if (enableOccupancyLock) { input "occupancyLockContact", "capability.contactSensor", title: "Lock Lights ON when this Contact is CLOSED" } paragraph "Operational Time Window" input "restrictByTime", "bool", title: "Enable Time-Based Gate?", defaultValue: false, submitOnChange: true if (restrictByTime) { input "timeLogic", "enum", title: "Window Logic", options: ["Only run DURING this window", "Block execution DURING this window"], defaultValue: "Only run DURING this window", submitOnChange: true input "startTimeType", "enum", title: "Start Time", options: ["Specific Time", "Sunrise", "Sunset"], required: true, submitOnChange: true if (startTimeType == "Specific Time") { input "startTime", "time", title: "Select Start Time", required: true } else { input "startOffset", "number", title: "Offset (Minutes)", defaultValue: 0 } input "endTimeType", "enum", title: "End Time", options: ["Specific Time", "Sunrise", "Sunset"], required: true, submitOnChange: true if (endTimeType == "Specific Time") { input "endTime", "time", title: "Select End Time", required: true } else { input "endOffset", "number", title: "Offset (Minutes)", defaultValue: 0 } } paragraph "Window & Shade Logic" input "contactSensors", "capability.contactSensor", title: "Window Contacts (Do NOT turn on if OPEN)", multiple: true, required: false input "shadeSensors", "capability.contactSensor", title: "Shade Contacts (Do NOT turn on if OPEN)", multiple: true, required: false input "turnOffOnContactOpen", "bool", title: "Force Lights OFF if Window/Shade Contacts OPEN?", defaultValue: false, description: "If enabled, opening a window or shade while lights are ON (and Overcast/Lux is OFF) will instantly turn them off." input "overcastSwitch", "capability.switch", title: "Virtual Overcast Switch (Ignores open windows/shades)", required: false input "useLuxContactOverride", "bool", title: "Enable Lux Override for Open Windows/Shades?", defaultValue: false, submitOnChange: true if (useLuxContactOverride) { input "luxContactVar", "string", title: "Hub Variable for Lux Threshold (Optional)", required: false input "luxContactThreshold", "number", title: "Static Lux Threshold", required: false } paragraph "Keep-Alive / Overrides (Keeps lights ON, won't trigger ON)" input "keepAliveMotionSensors", "capability.motionSensor", title: "Secondary Motion Sensors", multiple: true, required: false input "keepAliveSwitches", "capability.switch", title: "Keep-Alive Switches (e.g., TV is ON)", multiple: true, required: false input "keepAlivePower", "capability.powerMeter", title: "Keep-Alive Power Meters", multiple: true, required: false input "keepAlivePowerThreshold", "number", title: "Power Threshold for Keep-Alive (Watts)", defaultValue: 10 // THE BRIDGE FIX paragraph "Override Exceptions" input "ignoreOverrideSwitches", "capability.switch", title: "Ignore Manual Overrides when these are ON", multiple: true, required: false, description: "e.g., A virtual switch turned on by a Shower Monitor. Prevents manual holds." } section("
6. Telemetry & Health
", hideable: true, hidden: true) { input "logEnable", "bool", title: "Enable Debug Logging (Auto-disables after 30 min)", defaultValue: false input "enableHealthWatchdog", "bool", title: "Report Battery & Health to Parent?", defaultValue: false input "enableTelemetry", "bool", title: "Enable Energy Tracking?", defaultValue: false, submitOnChange: true if (enableTelemetry) { input "totalWattage", "decimal", title: 'Total Wattage (e.g. 60.5)', required: true input "kwhRate", "decimal", title: 'Electricity Rate ($ per kWh)', defaultValue: 0.14, required: true input "baselineHours", "decimal", title: "Baseline ON Hours (Assumed daily without automation)", defaultValue: 8.0 } } section("
7. Arrival Lighting Strategy
", hideable: true, hidden: false) { input "enableArrivalLighting", "bool", title: "Enable Full Arrival Lighting?", defaultValue: false, submitOnChange: true, description: "If enabled, the parent app will force these lights ON when the Arrival scenario is triggered." if (enableArrivalLighting && lightType == "Color / CT Bulb") { input "arrivalColorOverride", "bool", title: "Override color to 6500K during Arrival?", defaultValue: true, submitOnChange: true, description: "Lights will revert to their assigned color temp when the arrival timer ends." if (arrivalColorOverride) { input "arrivalTransitionTime", "number", title: "Revert Transition Time (Seconds)", defaultValue: 3, description: "How gradually the lights shift back from 6500K to standard color." } } } } } def installed() { initialize() } def updated() { unsubscribe() unschedule() if (isPaused()) { log.warn "Rule is currently PAUSED. Schedules cleared." } if (logEnable) { log.debug "Debug logging enabled for 30 minutes." runIn(1800, "logsOff") } initialize() } def initialize() { atomicState.historyLog = atomicState.historyLog ?: [] atomicState.appTurnedOn = atomicState.appTurnedOn ?: false atomicState.manuallyTurnedOn = atomicState.manuallyTurnedOn ?: false atomicState.arrivalActive = atomicState.arrivalActive ?: false atomicState.offRetryCount = 0 atomicState.warningPhase = false // Primary Triggers subscribe(motionSensors, "motion", triggerHandler) subscribe(triggerSwitches, "switch", triggerHandler) // Keep-Alives subscribe(keepAliveMotionSensors, "motion", keepAliveHandler) subscribe(keepAliveSwitches, "switch", keepAliveHandler) subscribe(keepAlivePower, "power", keepAliveHandler) // Restrictions & System subscribe(disableOnSwitches, "switch", restrictionHandler) subscribe(disableOffSwitches, "switch", restrictionHandler) subscribe(contactSensors, "contact", restrictionHandler) subscribe(shadeSensors, "contact", restrictionHandler) subscribe(overcastSwitch, "switch", restrictionHandler) subscribe(goodNightSwitch, "switch", restrictionHandler) // Optional Button Setup if (optButton && optButtonAction) { subscribe(optButton, optButtonAction, buttonActionHandler) } if (enableOccupancyLock && occupancyLockContact) { subscribe(occupancyLockContact, "contact", restrictionHandler) } subscribe(location, "mode", modeChangeHandler) subscribe(location, "systemStart", bootHandler) if (switches) { subscribe(switches, "switch.off", physicalOffHandler); subscribe(switches, "switch.on", physicalOnHandler) } if (dimmers) { subscribe(dimmers, "switch.off", physicalOffHandler); subscribe(dimmers, "switch.on", physicalOnHandler) } if (colorBulbs) { subscribe(colorBulbs, "switch.off", physicalOffHandler); subscribe(colorBulbs, "switch.on", physicalOnHandler) } // FULLY RESTORED: Subscribe to Dynamic Variables and Dimmers if (dynamicAdjust) { def dimmersToSub = [] def varsToSub = [] if (useModeSettings) { location.modes.each { m -> if (settings["useLevelDimmer_${m.id}"] && settings["levelDimmer_${m.id}"]) dimmersToSub << settings["levelDimmer_${m.id}"] if (settings["useLevelVar_${m.id}"] && settings["levelVarName_${m.id}"]) varsToSub << settings["levelVarName_${m.id}"] if (settings["useCTVar_${m.id}"] && settings["ctVarName_${m.id}"]) varsToSub << settings["ctVarName_${m.id}"] } } else { if (useLevelDimmer && levelDimmer) dimmersToSub << levelDimmer if (useLevelVar && levelVarName) varsToSub << levelVarName if (useCTVar && ctVarName) varsToSub << ctVarName } dimmersToSub.findAll { it }.unique { it.id }.each { dev -> subscribe(dev, "level", dynamicAdjustmentHandler) } varsToSub.findAll { it }.unique().each { vName -> subscribe(location, "variable:${vName}", dynamicAdjustmentHandler) } } schedule("0 0 0 * * ?", "midnightReset") runIn(10, "bootSync") } def debugLog(msg) { if (logEnable) log.debug msg } def logsOff() { log.warn "Debug logging automatically disabled." app.updateSetting("logEnable", [value: "false", type: "bool"]) } def isPaused() { return (pauseApp == true || parent?.isSystemPaused() == true) } // FULLY RESTORED: DYNAMIC ADJUSTMENT HANDLER def dynamicAdjustmentHandler(evt) { if (isPaused()) return if (atomicState.appTurnedOn) { def randomStagger = new Random().nextInt(2000) + 100 runInMillis(randomStagger, "applyDynamicSettings") } } def applyDynamicSettings() { if (isPaused()) return def trans = dynamicTransition != null ? dynamicTransition : (enableSoftStart ? (softStartTime ?: 3) : 5) applyLightingSettings(trans) } // --- BUTTON LOGIC --- def buttonActionHandler(evt) { if (isPaused()) return if (optButtonModes && !optButtonModes.contains(location.mode)) return if (evt.value == optButtonNum?.toString()) { def anyOn = false if (lightType == "Simple On/Off") anyOn = switches?.any { it.currentValue("switch") == "on" } else if (lightType == "Adjustable Bulb / Dimmer") anyOn = dimmers?.any { it.currentValue("switch") == "on" } else if (lightType == "Color / CT Bulb") anyOn = colorBulbs?.any { it.currentValue("switch") == "on" } // Check if the opposite side of the bed is ON before toggling if (toggleSyncLights?.any { it.currentValue("switch") == "on" }) { anyOn = true } if (anyOn) { // Force Lights OFF (Manual Override) def gp = (gracePeriod ?: 15) atomicState.gracePeriodEnd = now() + (gp * 1000) atomicState.appTurnedOn = false atomicState.manuallyTurnedOn = false atomicState.arrivalActive = false atomicState.warningPhase = false cancelAllTurnOffTimers() recordTurnOffTime() sendOffCommands() } else { // Force Lights ON (Manual Override) atomicState.manuallyTurnedOn = true atomicState.appTurnedOn = true atomicState.warningPhase = false recordTurnOnTime() startTurnOffTimer() if (lightType == "Color / CT Bulb" || lightType == "Adjustable Bulb / Dimmer") { applyLightingSettings() } else if (lightType == "Simple On/Off") { switches?.each { it.on() } } } } } // --- STATE HELPERS --- def isPrimaryActive() { return (motionSensors?.any { it.currentValue("motion") == "active" } || triggerSwitches?.any { it.currentValue("switch") == "on" }) } def isKeepAliveActive() { def mActive = keepAliveMotionSensors?.any { it.currentValue("motion") == "active" } def sActive = keepAliveSwitches?.any { it.currentValue("switch") == "on" } def pActive = keepAlivePower?.any { it.currentValue("power")?.toBigDecimal() >= (keepAlivePowerThreshold ?: 10) } return (mActive || sActive || pActive) } def bootHandler(evt) { runIn(30, "bootSync", [overwrite: true]) } // INTELLIGENT BOOT RECOVERY def bootSync() { if (isPaused()) return def anyOn = (switches?.any { it.currentValue("switch") == "on" } || dimmers?.any { it.currentValue("switch") == "on" } || colorBulbs?.any { it.currentValue("switch") == "on" }) def isOccupied = isPrimaryActive() || isKeepAliveActive() def isGoodNight = goodNightSwitch && goodNightSwitch.currentValue("switch") == "on" if (anyOn) { if (isGoodNight || !isOccupied) { // Room is unoccupied or Good Night is active, force lights back OFF def randomStagger = new Random().nextInt(5000) + 500 runInMillis(randomStagger, "sendOffCommands") } else { // Room is occupied, ensure proper settings evaluateTurnOn() } } else { if (isOccupied && !isGoodNight) { // Lights are off but room is occupied, turn them back on evaluateTurnOn() } } } def recordTurnOnTime() { if (!atomicState.onTimeStart) atomicState.onTimeStart = now() } def recordTurnOffTime() { if (atomicState.onTimeStart) { atomicState.todayOnMillis = (atomicState.todayOnMillis ?: 0) + (now() - atomicState.onTimeStart); atomicState.onTimeStart = null } } def calculateLiveSavings() { def currentMillis = (atomicState.todayOnMillis ?: 0) + (atomicState.onTimeStart ? (now() - atomicState.onTimeStart) : 0) def hoursOn = currentMillis / 3600000.0 def savedKwh = ((totalWattage ?: 0) * ((baselineHours ?: 8.0) - hoursOn)) / 1000.0 def todaySaved = (savedKwh > 0 ? savedKwh : 0.0) * (kwhRate ?: 0.14) return [today: todaySaved.toBigDecimal().setScale(2, BigDecimal.ROUND_HALF_UP), lifetime: (atomicState.lifetimeSavings ?: 0.0).toBigDecimal().setScale(2, BigDecimal.ROUND_HALF_UP)] } def midnightReset() { atomicState.lifetimeSavings = (atomicState.lifetimeSavings ?: 0.0) + calculateLiveSavings().today.toBigDecimal() atomicState.todayOnMillis = 0 if (atomicState.onTimeStart) { atomicState.onTimeStart = now() } } def triggerHandler(evt) { if (isPaused()) return if (evt.value == "active" || evt.value == "on") { if (atomicState.gracePeriodEnd && now() < atomicState.gracePeriodEnd) return if (enableTriggerTracking) { atomicState.lastTriggerSource = evt.displayName } // Interrupt the Warning Dim Phase if motion is detected if (atomicState.warningPhase) { atomicState.warningPhase = false cancelAllTurnOffTimers() applyLightingSettings() startTurnOffTimer() return } if (atomicState.manuallyTurnedOn) { startTurnOffTimer() } else { cancelAllTurnOffTimers() if (motionDebounceSeconds && !atomicState.appTurnedOn) runIn(motionDebounceSeconds, "evaluateTurnOn") else evaluateTurnOn() } } else { runIn(1, "evaluatePrimaryOff") } } def evaluatePrimaryOff() { if (isPaused()) return if (!isPrimaryActive() && !isKeepAliveActive()) { startTurnOffTimer() } } def keepAliveHandler(evt) { if (isPaused()) return def isActiveEvent = (evt.value == "active" || evt.value == "on" || (evt.name == "power" && evt.value.toFloat() >= (keepAlivePowerThreshold ?: 10))) if (isActiveEvent) { if (atomicState.manuallyTurnedOn) { startTurnOffTimer() } else if (atomicState.appTurnedOn) { cancelAllTurnOffTimers() } } else { runIn(1, "evaluateKeepAliveOff") } } def evaluateKeepAliveOff() { if (isPaused()) return if (atomicState.appTurnedOn && !isPrimaryActive() && !isKeepAliveActive()) { startTurnOffTimer() } } def restrictionHandler(evt) { if (isPaused()) return // NEW LOGIC: FORCE OFF ON CONTACT OPEN if (turnOffOnContactOpen && evt.value == "open") { def isContactEvent = contactSensors?.any { it.id == evt.device.id } || shadeSensors?.any { it.id == evt.device.id } if (isContactEvent) { def overcastActive = (overcastSwitch?.currentValue("switch") == "on") def luxOverrideActive = false // Respect the Lux Bypass if enabled if (useLuxContactOverride && luxSensor) { def curLux = luxSensor.currentValue("illuminance") ?: 0 def targetLux = luxContactThreshold ?: 0 if (luxContactVar) { def hVar = getGlobalVar(luxContactVar) if (hVar != null) targetLux = hVar.value.toInteger() } if (curLux < targetLux) luxOverrideActive = true } // Only proceed if overrides are NOT active if (!overcastActive && !luxOverrideActive) { def isLightOn = (switches?.any { it.currentValue("switch") == "on" } || dimmers?.any { it.currentValue("switch") == "on" } || colorBulbs?.any { it.currentValue("switch") == "on" }) if (isLightOn) { debugLog("Contact opened and overcast is off. Forcing lights OFF.") atomicState.appTurnedOn = false atomicState.manuallyTurnedOn = false atomicState.arrivalActive = false atomicState.warningPhase = false cancelAllTurnOffTimers() recordTurnOffTime() sendOffCommands() return // Abort further evaluation so they stay off } } } } // Existing Logic: If a shade closes, overcast turns on, or a contact closes while motion is active, evaluate turning the lights on immediately if (isPrimaryActive()) evaluateTurnOn() } def startTurnOffTimer() { if (atomicState.arrivalActive) return def delay = atomicState.manuallyTurnedOn ? (manualTimeoutMinutes ?: 30) : (getModeSetting("delay") ?: (defaultDelay ?: 5)) atomicState.stdTaskTime = now() + (delay * 60000) runIn(delay * 60, "processTurnOff") debugLog("Turn off timer started for ${delay} minutes.") } def cancelAllTurnOffTimers() { unschedule("processTurnOff"); atomicState.stdTaskTime = null } // Background poller for when room is active but lights are off due to Lux def pollLuxWhileActive() { if (isPaused()) return if (!isPrimaryActive() && !isKeepAliveActive()) return if (atomicState.appTurnedOn) return def shouldTurnOn = false if (luxSensor) { def curLux = luxSensor.currentValue("illuminance") ?: 0 def targetLux = getModeSetting("lux") if (targetLux != null && curLux < targetLux) shouldTurnOn = true def isOpen = (contactSensors?.any { it.currentValue("contact") == "open" } || shadeSensors?.any { it.currentValue("contact") == "open" }) if (useLuxContactOverride && isOpen) { def overrideLux = luxContactVar ? getGlobalVar(luxContactVar)?.value?.toInteger() : (luxContactThreshold ?: 0) if (curLux < overrideLux) shouldTurnOn = true } } if (shouldTurnOn) evaluateTurnOn() else runIn(60, "pollLuxWhileActive") } // --- TIME RESOLUTION HELPER --- def resolveTime(type, timeVal, offset) { if (type == "Specific Time" && timeVal) return toDateTime(timeVal) def astro = getSunriseAndSunset() if (type == "Sunrise") { def t = astro.sunrise if (offset) t = new Date(t.time + (offset.toInteger() * 60000)) return t } if (type == "Sunset") { def t = astro.sunset if (offset) t = new Date(t.time + (offset.toInteger() * 60000)) return t } return null } // --- MIDNIGHT WRAP HELPER --- def isTimeInWindow(sTime, eTime) { def n = new Date() if (sTime < eTime) { return timeOfDayIsBetween(sTime, eTime, n, location.timeZone) } else { return (n.after(sTime) || n.before(eTime)) } } def evaluateTurnOn() { if (isPaused()) return atomicState.offRetryCount = 0 if (activeModes && !activeModes.contains(location.mode)) return if (luxSensor) { def currentLux = luxSensor.currentValue("illuminance") ?: 0 def targetLux = getModeSetting("lux") if (targetLux != null && currentLux >= targetLux) { if (enableActiveLuxPolling) runIn(60, "pollLuxWhileActive") return } } // Disable ON Switches & Goodnight switch if (disableOnSwitches?.any { it.currentValue("switch") == "on" }) return if (goodNightSwitch && goodNightSwitch.currentValue("switch") == "on") return // Midnight Wrap Time Evaluation if (restrictByTime && startTimeType && endTimeType) { def sTime = resolveTime(startTimeType, startTime, startOffset) def eTime = resolveTime(endTimeType, endTime, endOffset) if (sTime && eTime) { def isInside = isTimeInWindow(sTime, eTime) def shouldBlock = (timeLogic == "Block execution DURING this window") ? isInside : !isInside if (shouldBlock) return } } // True Overcast, Window Shades, and Lux Override Logic def luxOverrideActive = false def isOpen = (contactSensors?.any { it.currentValue("contact") == "open" } || shadeSensors?.any { it.currentValue("contact") == "open" }) if (useLuxContactOverride && luxSensor && isOpen) { def curLux = luxSensor.currentValue("illuminance") ?: 0 def targetLux = luxContactThreshold ?: 0 if (luxContactVar) { def hVar = getGlobalVar(luxContactVar) if (hVar != null) targetLux = hVar.value.toInteger() } if (curLux < targetLux) { luxOverrideActive = true } } // THE BUILT-IN "DON'T TURN ON" LOGIC if (isOpen && overcastSwitch?.currentValue("switch") != "on" && !luxOverrideActive) { if (enableActiveLuxPolling && luxSensor) runIn(60, "pollLuxWhileActive") return } atomicState.appTurnedOn = true atomicState.warningPhase = false atomicState.lastAutoCommand = now() recordTurnOnTime() applyLightingSettings() } // FULLY RESTORED: Level Variable & Virtual Dimmer Grabber with Hub Variable Tracking def getTargetLevel() { def isMode = useModeSettings def m = location.modes.find { it.name == location.mode } def useDimmer = isMode ? settings["useLevelDimmer_${m?.id}"] : useLevelDimmer def dimmerDev = isMode ? settings["levelDimmer_${m?.id}"] : levelDimmer def useVar = isMode ? settings["useLevelVar_${m?.id}"] : useLevelVar def varName = isMode ? settings["levelVarName_${m?.id}"] : levelVarName def staticLvl = isMode ? settings["level_${m?.id}"] : defaultLevel if (useDimmer && dimmerDev) { def curLvl = dimmerDev.currentValue("level") if (curLvl != null) return curLvl.toInteger() } else if (useVar && varName) { def hubVar = getGlobalVar(varName) if (hubVar != null && hubVar.value != null) return hubVar.value.toInteger() } return staticLvl != null ? staticLvl : 100 } // FULLY RESTORED: Color Temp Variable Grabber def getTargetColorTemp() { def isMode = useModeSettings def m = location.modes.find { it.name == location.mode } def useVar = isMode ? settings["useCTVar_${m?.id}"] : useCTVar def varName = isMode ? settings["ctVarName_${m?.id}"] : ctVarName def staticCT = isMode ? settings["ct_${m?.id}"] : defaultCT if (useVar && varName) { def hubVar = getGlobalVar(varName) if (hubVar != null && hubVar.value != null) return hubVar.value.toInteger() } return staticCT ?: 2700 } // The transition parameter is optional. If passed, the bulbs will fade over that duration. def applyLightingSettings(transition = null) { if (isPaused()) return atomicState.lastAutoCommand = now() def refreshNeeded = false def t = (transition != null) ? transition : (enableSoftStart ? (softStartTime ?: 3) : null) if (lightType == "Simple On/Off") { switches?.each { if (it.currentValue("switch") != "on") { it.on() refreshNeeded = true } } } else if (lightType == "Adjustable Bulb / Dimmer") { if (isSmartBulbOnRelay && relaySwitch?.currentValue("switch") != "on") { relaySwitch.on() pauseExecution(relayDelay ?: 1500) atomicState.lastAutoCommand = now() // Reset debounce timer post-boot wait } def lvl = getTargetLevel() dimmers?.each { if (it.currentValue("switch") != "on" || it.currentValue("level") != lvl) { if (t != null) { it.setLevel(lvl, t) } else { it.setLevel(lvl) } refreshNeeded = true } } } else if (lightType == "Color / CT Bulb") { if (isSmartBulbOnRelay && relaySwitch?.currentValue("switch") != "on") { relaySwitch.on() pauseExecution(relayDelay ?: 1500) atomicState.lastAutoCommand = now() // Reset debounce timer post-boot wait } def lvl = getTargetLevel() def ct = getTargetColorTemp() colorBulbs?.each { if (it.currentValue("switch") != "on" || it.currentValue("level") != lvl || it.currentValue("colorTemperature") != ct) { if (t != null) { it.setLevel(lvl, t) pauseExecution(100) // Staggered to prevent popcorn effect it.setColorTemperature(ct, lvl, t) } else { it.setLevel(lvl) pauseExecution(100) // Staggered to prevent popcorn effect it.setColorTemperature(ct, lvl) } refreshNeeded = true } } } if (refreshNeeded) { runIn(2, "executeRefresh") } } def processTurnOff() { if (isPaused()) return // Disable OFF Switches if (disableOffSwitches?.any { it.currentValue("switch") == "on" }) return // OCCUPANCY LOCK logic if (enableOccupancyLock && occupancyLockContact?.currentValue("contact") == "closed") { startTurnOffTimer() return } if (isPrimaryActive() || isKeepAliveActive()) { if (atomicState.manuallyTurnedOn) startTurnOffTimer() return } // Process Warning Dim Phase if (enableWarningDim && !atomicState.warningPhase && lightType != "Simple On/Off") { atomicState.warningPhase = true def wLvl = warningDimLevel ?: 10 dimmers?.each { it.setLevel(wLvl) } colorBulbs?.each { it.setLevel(wLvl) } runIn(warningDimSeconds ?: 30, "processTurnOff") return } atomicState.appTurnedOn = false atomicState.manuallyTurnedOn = false atomicState.arrivalActive = false atomicState.warningPhase = false recordTurnOffTime() sendOffCommands() runIn(10, "verifyTurnOff") } def sendOffCommands() { if (isPaused()) return atomicState.lastAutoOffCommand = now() def refreshNeeded = false switches?.each { if (it.currentValue("switch") != "off") { it.off() refreshNeeded = true } } dimmers?.each { if (it.currentValue("switch") != "off") { if (dimBeforeOff != false && !atomicState.warningPhase) { it.setLevel(1) pauseExecution(1000) } it.off() refreshNeeded = true } } colorBulbs?.each { if (it.currentValue("switch") != "off") { if (dimBeforeOff != false && !atomicState.warningPhase) { it.setLevel(1) pauseExecution(1000) } it.off() refreshNeeded = true } } if (isSmartBulbOnRelay && turnOffRelay && relaySwitch?.currentValue("switch") != "off") { relaySwitch?.off() atomicState.lastAutoOffCommand = now() // Update debounce after relay off command } if (refreshNeeded) { runIn(2, "executeRefresh") } } // Execute Refresh method def executeRefresh() { atomicState.lastAutoCommand = now() atomicState.lastAutoOffCommand = now() if (lightType == "Simple On/Off") { switches?.each { if (it.hasCommand("refresh")) it.refresh() } } else if (lightType == "Adjustable Bulb / Dimmer") { dimmers?.each { if (it.hasCommand("refresh")) it.refresh() } } else if (lightType == "Color / CT Bulb") { colorBulbs?.each { if (it.hasCommand("refresh")) it.refresh() } } } def verifyTurnOff() { if (isPaused()) return def anyOn = false if (isSmartBulbOnRelay && turnOffRelay) { anyOn = (relaySwitch?.currentValue("switch") == "on") } else { anyOn = (switches?.any { it.currentValue("switch") == "on" } || dimmers?.any { it.currentValue("switch") == "on" } || colorBulbs?.any { it.currentValue("switch") == "on" }) } if (isPrimaryActive() || isKeepAliveActive()) { atomicState.offRetryCount = 0 return } if (anyOn && atomicState.offRetryCount < 3) { atomicState.offRetryCount++ sendOffCommands() runIn(10, "verifyTurnOff") } else { atomicState.offRetryCount = 0 } } def physicalOnHandler(evt) { if (isPaused()) return // THE BRIDGE BYPASS if (ignoreOverrideSwitches?.any { it.currentValue("switch") == "on" }) return def debounceTime = isSmartBulbOnRelay ? 10000 : 5000 // Extended debounce for relay-controlled smart bulbs def isOutsideAppDebounce = (now() - (atomicState.lastAutoCommand ?: 0) > debounceTime) // Ignore events immediately following an app command to prevent boot reports from triggering manual mode if (!isOutsideAppDebounce) return // BUG FIX: Prevent Hue Bridge / LAN polling from triggering a manual override. // If the app already turned the light ON, ignore redundant digital 'on' events // unless they are explicitly flagged as a physical switch press. if (atomicState.appTurnedOn && !evt.isPhysical()) { return } // Ignore physical events if a lock switch is active if (disableOnSwitches?.any { it.currentValue("switch") == "on" }) return if (goodNightSwitch && goodNightSwitch.currentValue("switch") == "on") return atomicState.manuallyTurnedOn = true atomicState.appTurnedOn = true atomicState.warningPhase = false recordTurnOnTime() startTurnOffTimer() // Sync color and level when physically turned on if (lightType == "Color / CT Bulb" || lightType == "Adjustable Bulb / Dimmer") { // Wait 1 second to ensure the bulb is fully on the mesh network after relay boot runIn(1, "syncManualBulbs") } } def physicalOffHandler(evt) { if (isPaused()) return // THE BRIDGE BYPASS if (ignoreOverrideSwitches?.any { it.currentValue("switch") == "on" }) return def debounceTime = isSmartBulbOnRelay ? 10000 : 5000 // Extended debounce for relay-controlled smart bulbs def isOutsideAppDebounce = (now() - (atomicState.lastAutoOffCommand ?: 0) > debounceTime) // Ignore events immediately following an app command to prevent hub-level physical flags from overriding automation if (!isOutsideAppDebounce) return atomicState.appTurnedOn = false atomicState.manuallyTurnedOn = false atomicState.arrivalActive = false atomicState.warningPhase = false def gp = (gracePeriod ?: 15) atomicState.gracePeriodEnd = now() + (gp * 1000) cancelAllTurnOffTimers() recordTurnOffTime() } def syncManualBulbs() { if (isPaused()) return atomicState.lastAutoCommand = now() // Update debounce so the commands below don't re-trigger manual overrides def t = enableSoftStart ? (softStartTime ?: 3) : null def lvl = getTargetLevel() if (lightType == "Adjustable Bulb / Dimmer") { dimmers?.each { if (it.currentValue("switch") == "on") { if (t != null) { it.setLevel(lvl, t) } else { it.setLevel(lvl) } } } } else if (lightType == "Color / CT Bulb") { def ct = getTargetColorTemp() colorBulbs?.each { if (it.currentValue("switch") == "on") { if (t != null) { it.setLevel(lvl, t) pauseExecution(100) // Staggered to prevent popcorn effect it.setColorTemperature(ct, lvl, t) } else { it.setLevel(lvl) pauseExecution(100) // Staggered to prevent popcorn effect it.setColorTemperature(ct, lvl) } } } } } def modeChangeHandler(evt) { if (isPaused()) return def forceOffList = [turnOffOnModes].flatten().findAll { it } if (forceOffList.contains(evt.value)) { cancelAllTurnOffTimers() def randomStagger = new Random().nextInt(4000) + 100 runInMillis(randomStagger, "processTurnOff") } else if (adjustOnModeChange && atomicState.appTurnedOn) { def randomStagger = new Random().nextInt(4000) + 100 runInMillis(randomStagger, "applyLightingSettings") } } def appButtonHandler(btn) { if (btn == "btnRefresh") { debugLog("Live Dashboard Refreshed") } } def getModeSetting(type) { if (!useModeSettings) return null def m = location.modes.find { it.name == location.mode } return settings["${type}_${m.id}"] } // --- PARENT CALLED METHODS --- def isArrivalEnabled() { return enableArrivalLighting == true } def turnOnArrival() { if (isPaused()) return atomicState.arrivalActive = true atomicState.appTurnedOn = true recordTurnOnTime() atomicState.lastAutoCommand = now() if (lightType == "Color / CT Bulb" && arrivalColorOverride) { def lvl = getTargetLevel() colorBulbs?.each { it.setColorTemperature(6500, lvl, 0) } } else { applyLightingSettings() } } def revertFromArrival() { if (isPaused()) return atomicState.arrivalActive = false if (isPrimaryActive() || isKeepAliveActive()) { startTurnOffTimer() if (lightType == "Color / CT Bulb" && arrivalColorOverride) { applyLightingSettings(arrivalTransitionTime != null ? arrivalTransitionTime : 3) } } else { processTurnOff() } } def executeParentSweep(delayMs = 0) { if (isPaused()) return if (!isPrimaryActive() && !isKeepAliveActive()) { if (delayMs > 0) { runInMillis(delayMs, "processTurnOff") } else { processTurnOff() } } } def clearManualOverride() { if (isPaused()) return if (atomicState.manuallyTurnedOn) { atomicState.manuallyTurnedOn = false if (!isPrimaryActive() && !isKeepAliveActive()) { startTurnOffTimer() } else { cancelAllTurnOffTimers() } return true } return false } // --- DASHBOARD EXPORT FOR PARENT --- def getZoneStatus() { def isLightOn = (switches?.any { it.currentValue("switch") == "on" } || dimmers?.any { it.currentValue("switch") == "on" } || colorBulbs?.any { it.currentValue("switch") == "on" }) def primaryActive = isPrimaryActive() def keepAliveActive = isKeepAliveActive() def healthData = [] if (enableHealthWatchdog) { def checkList = [] if (motionSensors) checkList.addAll(motionSensors) if (keepAliveMotionSensors) checkList.addAll(keepAliveMotionSensors) if (contactSensors) checkList.addAll(contactSensors) if (shadeSensors) checkList.addAll(shadeSensors) if (occupancyLockContact) checkList.add(occupancyLockContact) checkList.unique().findAll { it }.each { d -> healthData << [name: d.displayName, battery: d.currentValue("battery"), lastActivity: d.getLastActivity()?.format("MM-dd HH:mm")] } } def timeBlocked = false if (restrictByTime && startTimeType && endTimeType) { def sTime = resolveTime(startTimeType, startTime, startOffset) def eTime = resolveTime(endTimeType, endTime, endOffset) if (sTime && eTime) { def isInside = isTimeInWindow(sTime, eTime) timeBlocked = (timeLogic == "Block execution DURING this window") ? isInside : !isInside } } def statusText = "Standby" if (parent?.isSystemPaused()) statusText = "PAUSED (Global)" else if (pauseApp) statusText = "PAUSED (Rule)" else if (atomicState.arrivalActive) statusText = "Arrival Override" else if (enableOccupancyLock && occupancyLockContact?.currentValue("contact") == "closed") statusText = "Locked (Occupied)" else if (isLightOn && atomicState.manuallyTurnedOn) statusText = "Manual Override" else if (activeModes && !activeModes.contains(location.mode)) statusText = "Blocked (Mode: ${location.mode})" else if (timeBlocked) statusText = "Blocked (Time Window)" else if (disableOnSwitches?.any { it.currentValue("switch") == "on" }) statusText = "Disabled (ON Block)" else if (disableOffSwitches?.any { it.currentValue("switch") == "on" }) statusText = "Disabled (OFF Block)" else if (goodNightSwitch && goodNightSwitch.currentValue("switch") == "on") statusText = "Nap Lock Active" else { def isOpen = (contactSensors?.any { it.currentValue("contact") == "open" } || shadeSensors?.any { it.currentValue("contact") == "open" }) if (isOpen) { def overcastActive = (overcastSwitch?.currentValue("switch") == "on") def luxOverrideActive = false if (useLuxContactOverride && luxSensor) { def curLux = luxSensor.currentValue("illuminance") ?: 0 def targetLux = luxContactThreshold ?: 0 if (luxContactVar) { def hVar = getGlobalVar(luxContactVar) if (hVar != null) targetLux = hVar.value.toInteger() } if (curLux < targetLux) luxOverrideActive = true } if (overcastActive || luxOverrideActive) { statusText = "Occupied (Window/Shade Bypass Active)" } else { statusText = "Blocked (Open Window/Shade)" } } else if (atomicState.warningPhase) statusText = "Warning Dim Phase" else if (isLightOn && (primaryActive || keepAliveActive)) statusText = "Occupied" else if (isLightOn && !primaryActive && !keepAliveActive) statusText = "Counting Down" else if (!isLightOn && primaryActive) statusText = "Motion Ignored" } def timerText = "--" if (isLightOn && atomicState.stdTaskTime && atomicState.stdTaskTime > now()) { def diff = atomicState.stdTaskTime - now() timerText = "${(diff / 60000).toInteger()}m ${((diff % 60000) / 1000).toInteger()}s" } def lightDetails = isLightOn ? "ON" : "OFF" if (isLightOn) { if (lightType == "Adjustable Bulb / Dimmer") { def activeDev = dimmers?.find { it.currentValue("switch") == "on" } if (activeDev) { def lvl = activeDev.currentValue("level") if (lvl != null) lightDetails += "
(${lvl}%)
" } } else if (lightType == "Color / CT Bulb") { def activeDev = colorBulbs?.find { it.currentValue("switch") == "on" } if (activeDev) { def lvl = activeDev.currentValue("level") def ct = activeDev.currentValue("colorTemperature") def extras = [] if (lvl != null) extras << "${lvl}%" if (ct != null) extras << "${ct}K" if (extras) lightDetails += "
(" + extras.join(" @ ") + ")
" } } } return [ name: app.label ?: "Unnamed Zone", light: lightDetails, motion: primaryActive ? "ACTIVE" : (keepAliveActive ? "KEEP-ALIVE" : "INACTIVE"), status: statusText, lastTrigger: enableTriggerTracking ? atomicState.lastTriggerSource : null, timer: timerText, health: healthData, roi: enableTelemetry ? calculateLiveSavings() : null ] } def dynamicCTUpdate(newCT) { if (isPaused()) return if (lightType == "Color / CT Bulb") { def lvl = getTargetLevel() def refreshNeeded = false colorBulbs?.each { bulb -> if (bulb.currentValue("switch") == "on") { bulb.setColorTemperature(newCT, lvl) refreshNeeded = true } } if (refreshNeeded) { runIn(2, "executeRefresh") } } } def resetROI() { atomicState.lifetimeSavings = 0.0 atomicState.todayOnMillis = 0 if (atomicState.onTimeStart) { atomicState.onTimeStart = now() } }