/** * MCP Rule - Child App * * Individual automation rule with isolated settings. * Each rule is a separate child app instance. * * Version: 0.10.1 */ definition( name: "MCP Rule", namespace: "mcp", author: "kingpanther13", description: "Individual automation rule for MCP Rule Server", category: "Automation", parent: "mcp:MCP Rule Server", singleThreaded: true, iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") page(name: "editTriggersPage") page(name: "addTriggerPage") page(name: "editTriggerPage") page(name: "editConditionsPage") page(name: "addConditionPage") page(name: "editConditionPage") page(name: "editActionsPage") page(name: "addActionPage") page(name: "editActionPage") page(name: "confirmDeletePage") } def installed() { log.info "MCP Rule '${settings.ruleName}' installed" state.createdAt = now() state.updatedAt = now() state.executionCount = 0 // IMPORTANT: Only initialize arrays if they don't exist yet // This prevents overwriting data that may have been set by updateRuleFromParent // during rule creation via MCP (race condition fix) // Using atomicState for immediate persistence - prevents race condition with enabled=true if (atomicState.triggers == null) atomicState.triggers = [] if (atomicState.conditions == null) atomicState.conditions = [] if (atomicState.actions == null) atomicState.actions = [] // Set the app label to match the rule name (for display in Apps list) if (settings.ruleName) { app.updateLabel(settings.ruleName) } initialize() } def updated() { log.info "MCP Rule '${settings.ruleName}' updated" state.updatedAt = now() // Update the app label to match the rule name (for display in Apps list) if (settings.ruleName) { app.updateLabel(settings.ruleName) } unsubscribe() unschedule() initialize() } def uninstalled() { log.info "MCP Rule '${settings.ruleName}' uninstalled" unsubscribe() unschedule() } def initialize() { // Clear any stale duration timer state (timers were canceled by unschedule() in updated()) clearDurationState() // Clear stale cancelled delay IDs (scheduled callbacks were cancelled by unschedule()) atomicState.cancelledDelayIds = [:] // Initialize previousMode so mode_change triggers with fromMode work on first event state.previousMode = location.mode if (settings.ruleEnabled) { subscribeToTriggers() } } /** * Clears all duration-related state to prevent accumulation and stale data. * Should be called during initialization and when rule is disabled. */ def clearDurationState() { if (atomicState.durationTimers) { log.debug "Clearing ${atomicState.durationTimers.size()} stale duration timer entries" atomicState.remove("durationTimers") } if (atomicState.durationFired) { log.debug "Clearing ${atomicState.durationFired.size()} stale durationFired entries" atomicState.remove("durationFired") } } // ==================== MAIN PAGE ==================== def mainPage() { clearAllSubPageSettings() // Clean up orphaned sub-page settings on return to main page dynamicPage(name: "mainPage", title: "Configure Rule", install: true, uninstall: true) { section("Rule Settings") { input "ruleName", "text", title: "Rule Name", required: true, submitOnChange: true input "ruleDescription", "text", title: "Description (optional)", required: false input "ruleEnabled", "bool", title: "Rule Enabled", defaultValue: false, submitOnChange: true } section("Status") { def lastRun = state.lastTriggered ? formatTimestamp(state.lastTriggered) : "Never" paragraph "Status: ${settings.ruleEnabled ? '✓ Enabled' : '○ Disabled'}" paragraph "Last Triggered: ${lastRun}" paragraph "Execution Count: ${state.executionCount ?: 0}" } section("Triggers (${atomicState.triggers?.size() ?: 0})") { if (atomicState.triggers && atomicState.triggers.size() > 0) { atomicState.triggers.eachWithIndex { trigger, idx -> paragraph "${idx + 1}. ${describeTrigger(trigger)}" } } else { paragraph "No triggers defined" } href name: "editTriggers", page: "editTriggersPage", title: "Edit Triggers" } section("Conditions (${atomicState.conditions?.size() ?: 0})") { if (atomicState.conditions && atomicState.conditions.size() > 0) { def logic = settings.conditionLogic == "any" ? "ANY" : "ALL" paragraph "Logic: ${logic} conditions must be true" atomicState.conditions.eachWithIndex { condition, idx -> paragraph "${idx + 1}. ${describeCondition(condition)}" } } else { paragraph "No conditions (rule always executes when triggered)" } href name: "editConditions", page: "editConditionsPage", title: "Edit Conditions" } section("Actions (${atomicState.actions?.size() ?: 0})") { if (atomicState.actions && atomicState.actions.size() > 0) { atomicState.actions.eachWithIndex { action, idx -> paragraph "${idx + 1}. ${describeAction(action)}" } } else { paragraph "No actions defined" } href name: "editActions", page: "editActionsPage", title: "Edit Actions" } section("Rule Actions") { input "testRuleBtn", "button", title: "Test Rule (Dry Run)" } } } // ==================== TRIGGER PAGES ==================== def editTriggersPage() { // Auto-save pending trigger if (settings.triggerType) { savePendingTrigger() } dynamicPage(name: "editTriggersPage", title: "Edit Triggers") { section("Current Triggers") { if (atomicState.triggers && atomicState.triggers.size() > 0) { atomicState.triggers.eachWithIndex { trigger, idx -> href name: "editTrigger_${idx}", page: "editTriggerPage", title: "${idx + 1}. ${describeTrigger(trigger)}", description: "Tap to edit", params: [triggerIndex: idx] } } else { paragraph "No triggers defined. Add a trigger to make this rule responsive." } } section { href name: "addTrigger", page: "addTriggerPage", title: "+ Add Trigger" } section { href name: "backToMain", page: "mainPage", title: "← Done" } } } def addTriggerPage() { // Only clear settings on fresh entry, not on submitOnChange re-renders if (!settings.triggerType) { state.editingTriggerIndex = null clearTriggerSettings() } dynamicPage(name: "addTriggerPage", title: "Add Trigger") { section("Trigger Type") { input "triggerType", "enum", title: "When should this rule trigger?", options: [ "device_event": "Device Event (attribute changes)", "button_event": "Button Press", "time": "Specific Time", "periodic": "Periodic Schedule", "mode_change": "Mode Change", "hsm_change": "HSM Status Change" ], required: false, submitOnChange: true } renderTriggerFields() section { if (settings.triggerType) { href name: "saveTrigger", page: "editTriggersPage", title: "Save Trigger", description: "Save and return to triggers list" } href name: "cancelTrigger", page: "editTriggersPage", title: "Cancel" } } } def editTriggerPage(params) { def triggerIndex = params?.triggerIndex != null ? params.triggerIndex.toInteger() : state.editingTriggerIndex if (triggerIndex == null || triggerIndex < 0 || triggerIndex >= (atomicState.triggers?.size() ?: 0)) { return dynamicPage(name: "editTriggerPage", title: "Trigger Not Found") { section { paragraph "The requested trigger could not be found." href name: "backToTriggers", page: "editTriggersPage", title: "Back to Triggers" } } } state.editingTriggerIndex = triggerIndex def trigger = atomicState.triggers[triggerIndex] // Load trigger into settings if not already loaded if (state.loadedTriggerIndex != triggerIndex) { loadTriggerSettings(trigger) state.loadedTriggerIndex = triggerIndex } dynamicPage(name: "editTriggerPage", title: "Edit Trigger ${triggerIndex + 1}") { section("Trigger Type") { input "triggerType", "enum", title: "When should this rule trigger?", options: [ "device_event": "Device Event (attribute changes)", "button_event": "Button Press", "time": "Specific Time", "periodic": "Periodic Schedule", "mode_change": "Mode Change", "hsm_change": "HSM Status Change" ], required: false, submitOnChange: true } renderTriggerFields() section { href name: "saveTriggerEdit", page: "editTriggersPage", title: "Save Changes", description: "Save and return to triggers list" input "deleteTriggerBtn", "button", title: "Delete Trigger" href name: "cancelTriggerEdit", page: "editTriggersPage", title: "Cancel" } } } def renderTriggerFields() { switch (settings.triggerType) { case "device_event": section("Device Event Settings") { input "triggerDevice", "capability.*", title: "Device", required: false, submitOnChange: true if (settings.triggerDevice) { def attrs = settings.triggerDevice.supportedAttributes?.collect { it.name }?.unique()?.sort() input "triggerAttribute", "enum", title: "Attribute", options: attrs, required: false } input "triggerOperator", "enum", title: "Comparison (optional)", options: ["any": "Any Change", "equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than", ">=": "Greater/Equal", "<=": "Less/Equal"], required: false, defaultValue: "any" input "triggerValue", "text", title: "Value (if comparing)", required: false input "triggerDuration", "number", title: "For duration (optional)", description: "Debounce - only trigger if condition persists", required: false, range: "1..999" input "triggerDurationUnit", "enum", title: "Duration Unit", options: ["seconds": "Seconds", "minutes": "Minutes", "hours": "Hours"], required: false, defaultValue: "seconds" paragraph "Note: Duration is limited to 2 hours (7200 seconds) max. Hubitat's runIn() scheduler uses seconds internally and longer durations may be unreliable due to hub restarts." } break case "button_event": section("Button Event Settings") { input "triggerDevice", "capability.pushableButton", title: "Button Device", required: false input "triggerButtonNumber", "number", title: "Button Number (leave empty for any)", required: false, range: "1..20" input "triggerButtonAction", "enum", title: "Button Action", options: ["pushed": "Pushed", "held": "Held", "doubleTapped": "Double Tapped", "released": "Released"], required: false, defaultValue: "pushed" } break case "time": section("Time Settings") { input "triggerTimeType", "enum", title: "Time Type", options: ["specific": "Specific Time", "sunrise": "Sunrise", "sunset": "Sunset"], required: false, submitOnChange: true, defaultValue: "specific" if (settings.triggerTimeType == "specific") { input "triggerTime", "time", title: "Time", required: false } else if (settings.triggerTimeType in ["sunrise", "sunset"]) { input "triggerOffset", "number", title: "Offset (minutes)", description: "Negative = before, Positive = after", required: false, range: "-180..180", defaultValue: 0 } } break case "periodic": section("Periodic Schedule") { input "triggerUnit", "enum", title: "Unit", options: ["minutes": "Minutes", "hours": "Hours", "days": "Days"], required: false, defaultValue: "minutes", submitOnChange: true def maxInterval = settings.triggerUnit == "hours" ? 23 : (settings.triggerUnit == "days" ? 31 : 59) input "triggerInterval", "number", title: "Every (1-${maxInterval})", required: false, range: "1..${maxInterval}" } break case "mode_change": section("Mode Change Settings") { def modes = location.modes?.collect { it.name } input "triggerFromMode", "enum", title: "From Mode (optional)", options: modes, required: false input "triggerToMode", "enum", title: "To Mode (optional)", options: modes, required: false paragraph "Leave both empty to trigger on any mode change" } break case "hsm_change": section("HSM Change Settings") { input "triggerHsmStatus", "enum", title: "HSM Status (optional)", options: ["armedAway": "Armed Away", "armedHome": "Armed Home", "armedNight": "Armed Night", "disarmed": "Disarmed", "intrusion": "Intrusion Alert"], required: false paragraph "Leave empty to trigger on any HSM change" } break } } def savePendingTrigger() { def trigger = buildTriggerFromSettings() if (trigger) { def list = atomicState.triggers ?: [] if (state.editingTriggerIndex != null && state.editingTriggerIndex >= 0 && state.editingTriggerIndex < list.size()) { list[state.editingTriggerIndex] = trigger atomicState.triggers = list log.info "Updated trigger ${state.editingTriggerIndex + 1}" } else { list.add(trigger) atomicState.triggers = list log.info "Added new trigger" } state.updatedAt = now() } clearTriggerSettings() state.remove("editingTriggerIndex") state.remove("loadedTriggerIndex") } def buildTriggerFromSettings() { if (!settings.triggerType) return null def trigger = [type: settings.triggerType] switch (settings.triggerType) { case "device_event": if (!settings.triggerDevice || !settings.triggerAttribute) return null trigger.deviceId = settings.triggerDevice.id.toString() trigger.attribute = settings.triggerAttribute if (settings.triggerOperator && settings.triggerOperator != "any") { trigger.operator = settings.triggerOperator } if (settings.triggerValue) trigger.value = settings.triggerValue if (settings.triggerDuration) { // Convert duration to seconds based on unit def durationSeconds = settings.triggerDuration def unit = settings.triggerDurationUnit ?: "seconds" switch (unit) { case "minutes": durationSeconds = settings.triggerDuration * 60 break case "hours": durationSeconds = settings.triggerDuration * 3600 break } // Cap at 7200 seconds (2 hours) - runIn() is unreliable for longer durations def maxDuration = 7200 if (durationSeconds > maxDuration) { log.warn "Duration ${durationSeconds}s exceeds max of ${maxDuration}s, capping to ${maxDuration}s" durationSeconds = maxDuration } trigger.duration = durationSeconds trigger.durationUnit = unit // Store original unit for display trigger.durationValue = settings.triggerDuration // Store original value for editing } break case "button_event": if (!settings.triggerDevice) return null trigger.deviceId = settings.triggerDevice.id.toString() trigger.action = settings.triggerButtonAction ?: "pushed" if (settings.triggerButtonNumber) trigger.buttonNumber = settings.triggerButtonNumber break case "time": if (settings.triggerTimeType == "specific") { if (!settings.triggerTime) return null trigger.time = formatTimeInput(settings.triggerTime) } else if (settings.triggerTimeType == "sunrise") { trigger.sunrise = true if (settings.triggerOffset) trigger.offset = settings.triggerOffset } else if (settings.triggerTimeType == "sunset") { trigger.sunset = true if (settings.triggerOffset) trigger.offset = settings.triggerOffset } break case "periodic": if (!settings.triggerInterval) return null trigger.interval = settings.triggerInterval trigger.unit = settings.triggerUnit ?: "minutes" break case "mode_change": if (settings.triggerFromMode) trigger.fromMode = settings.triggerFromMode if (settings.triggerToMode) trigger.toMode = settings.triggerToMode break case "hsm_change": if (settings.triggerHsmStatus) trigger.status = settings.triggerHsmStatus break } return trigger } def loadTriggerSettings(trigger) { clearTriggerSettings() app.updateSetting("triggerType", trigger.type) switch (trigger.type) { case "device_event": if (trigger.deviceId) { def device = parent.findDevice(trigger.deviceId) if (device) app.updateSetting("triggerDevice", [type: "capability.*", value: device.id]) } if (trigger.attribute) app.updateSetting("triggerAttribute", [type: "text", value: trigger.attribute]) if (trigger.operator) app.updateSetting("triggerOperator", [type: "enum", value: trigger.operator]) if (trigger.value != null) app.updateSetting("triggerValue", [type: "text", value: trigger.value]) if (trigger.duration) { // Load original value and unit if available, otherwise convert from seconds if (trigger.durationValue && trigger.durationUnit) { app.updateSetting("triggerDuration", trigger.durationValue) app.updateSetting("triggerDurationUnit", trigger.durationUnit) } else { // Legacy: duration was stored in seconds only app.updateSetting("triggerDuration", trigger.duration) app.updateSetting("triggerDurationUnit", "seconds") } } break case "button_event": if (trigger.deviceId) { def device = parent.findDevice(trigger.deviceId) if (device) app.updateSetting("triggerDevice", [type: "capability.pushableButton", value: device.id]) } if (trigger.buttonNumber != null) app.updateSetting("triggerButtonNumber", [type: "number", value: trigger.buttonNumber]) if (trigger.action) app.updateSetting("triggerButtonAction", [type: "enum", value: trigger.action]) break case "time": if (trigger.time) { app.updateSetting("triggerTimeType", [type: "enum", value: "specific"]) app.updateSetting("triggerTime", [type: "time", value: trigger.time]) } else if (trigger.sunrise) { app.updateSetting("triggerTimeType", [type: "enum", value: "sunrise"]) app.updateSetting("triggerOffset", [type: "number", value: trigger.offset != null ? trigger.offset : 0]) } else if (trigger.sunset) { app.updateSetting("triggerTimeType", [type: "enum", value: "sunset"]) app.updateSetting("triggerOffset", [type: "number", value: trigger.offset != null ? trigger.offset : 0]) } break case "periodic": if (trigger.interval != null) app.updateSetting("triggerInterval", [type: "number", value: trigger.interval]) if (trigger.unit) app.updateSetting("triggerUnit", [type: "enum", value: trigger.unit]) break case "mode_change": if (trigger.fromMode) app.updateSetting("triggerFromMode", [type: "enum", value: trigger.fromMode]) if (trigger.toMode) app.updateSetting("triggerToMode", [type: "enum", value: trigger.toMode]) break case "hsm_change": if (trigger.status) app.updateSetting("triggerHsmStatus", [type: "enum", value: trigger.status]) break } } def clearTriggerSettings() { ["triggerType", "triggerDevice", "triggerAttribute", "triggerOperator", "triggerValue", "triggerDuration", "triggerDurationUnit", "triggerButtonNumber", "triggerButtonAction", "triggerTimeType", "triggerTime", "triggerOffset", "triggerInterval", "triggerUnit", "triggerFromMode", "triggerToMode", "triggerHsmStatus"].each { app.removeSetting(it) } } /** * Clears all sub-page settings (triggers, conditions, actions) to prevent * "required fields" validation errors when orphaned settings exist from * partially-completed forms on sub-pages. */ def clearAllSubPageSettings() { clearTriggerSettings() clearConditionSettings() clearActionSettings() // Clear editing state flags state.remove("editingTriggerIndex") state.remove("loadedTriggerIndex") state.remove("editingConditionIndex") state.remove("loadedConditionIndex") state.remove("editingActionIndex") state.remove("loadedActionIndex") } def formatTimeInput(timeInput) { try { def result if (timeInput instanceof Date) { result = timeInput.format("HH:mm") } else if (timeInput instanceof String) { if (timeInput.contains("T")) { def date = Date.parse("yyyy-MM-dd'T'HH:mm:ss.SSSZ", timeInput) result = date.format("HH:mm") } else { result = timeInput } } else { result = timeInput.toString() } // Validate HH:mm format to prevent malformed cron expressions if (result && result =~ /^\d{1,2}:\d{2}$/) { def parts = result.split(":") def hour = parts[0] as Integer def minute = parts[1] as Integer if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) { return result } } ruleLog("warn", "Invalid time format '${result}', defaulting to 00:00") return "00:00" } catch (Exception e) { ruleLog("warn", "Error parsing time input '${timeInput}': ${e.message}, defaulting to 00:00") return "00:00" } } // ==================== CONDITION PAGES ==================== def editConditionsPage() { // Auto-save pending condition if (settings.conditionType) { savePendingCondition() } dynamicPage(name: "editConditionsPage", title: "Edit Conditions") { section("Condition Logic") { input "conditionLogic", "enum", title: "How should conditions be evaluated?", options: ["all": "ALL conditions must be true", "any": "ANY condition must be true"], defaultValue: settings.conditionLogic ?: "all", submitOnChange: true // Persist conditionLogic to settings using app.updateSetting for proper persistence across hub restarts if (settings.conditionLogic) { app.updateSetting("conditionLogic", settings.conditionLogic) } } section("Current Conditions") { if (atomicState.conditions && atomicState.conditions.size() > 0) { atomicState.conditions.eachWithIndex { condition, idx -> href name: "editCondition_${idx}", page: "editConditionPage", title: "${idx + 1}. ${describeCondition(condition)}", description: "Tap to edit", params: [conditionIndex: idx] } } else { paragraph "No conditions (rule always executes when triggered)" } } section { href name: "addCondition", page: "addConditionPage", title: "+ Add Condition" } section { href name: "backToMain", page: "mainPage", title: "← Done" } } } def addConditionPage() { // Only clear settings on fresh entry, not on submitOnChange re-renders if (!settings.conditionType) { state.editingConditionIndex = null clearConditionSettings() } dynamicPage(name: "addConditionPage", title: "Add Condition") { section("Condition Type") { input "conditionType", "enum", title: "What should be checked?", options: getConditionTypeOptions(), required: false, submitOnChange: true } renderConditionFields() section { if (settings.conditionType) { href name: "saveCondition", page: "editConditionsPage", title: "Save Condition", description: "Save and return to conditions list" } href name: "cancelCondition", page: "editConditionsPage", title: "Cancel" } } } def editConditionPage(params) { def conditionIndex = params?.conditionIndex != null ? params.conditionIndex.toInteger() : state.editingConditionIndex if (conditionIndex == null || conditionIndex < 0 || conditionIndex >= (atomicState.conditions?.size() ?: 0)) { return dynamicPage(name: "editConditionPage", title: "Condition Not Found") { section { paragraph "The requested condition could not be found." href name: "backToConditions", page: "editConditionsPage", title: "Back to Conditions" } } } state.editingConditionIndex = conditionIndex def condition = atomicState.conditions[conditionIndex] if (state.loadedConditionIndex != conditionIndex) { loadConditionSettings(condition) state.loadedConditionIndex = conditionIndex } dynamicPage(name: "editConditionPage", title: "Edit Condition ${conditionIndex + 1}") { section("Condition Type") { input "conditionType", "enum", title: "What should be checked?", options: getConditionTypeOptions(), required: false, submitOnChange: true } renderConditionFields() section { href name: "saveConditionEdit", page: "editConditionsPage", title: "Save Changes", description: "Save and return to conditions list" input "deleteConditionBtn", "button", title: "Delete Condition" href name: "cancelConditionEdit", page: "editConditionsPage", title: "Cancel" } } } def getConditionTypeOptions() { return [ "device_state": "Device State", "device_was": "Device Was (for duration)", "time_range": "Time Range", "mode": "Hub Mode", "variable": "Variable Value", "days_of_week": "Days of Week", "sun_position": "Sun Position (day/night)", "hsm_status": "HSM Status", "presence": "Presence Sensor", "lock": "Lock Status", "thermostat_mode": "Thermostat Mode", "thermostat_state": "Thermostat Operating State", "illuminance": "Illuminance Level", "power": "Power Level" ] } def renderConditionFields() { switch (settings.conditionType) { case "device_state": section("Device State Settings") { input "conditionDevice", "capability.*", title: "Device", required: false, submitOnChange: true if (settings.conditionDevice) { def attrs = settings.conditionDevice.supportedAttributes?.collect { it.name }?.unique()?.sort() input "conditionAttribute", "enum", title: "Attribute", options: attrs, required: false } input "conditionOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than", ">=": "Greater/Equal", "<=": "Less/Equal"], required: false, defaultValue: "equals" input "conditionValue", "text", title: "Value", required: false } break case "device_was": section("Device Was Settings") { input "conditionDevice", "capability.*", title: "Device", required: false, submitOnChange: true if (settings.conditionDevice) { def attrs = settings.conditionDevice.supportedAttributes?.collect { it.name }?.unique()?.sort() input "conditionAttribute", "enum", title: "Attribute", options: attrs, required: false } input "conditionValue", "text", title: "Value", required: false input "conditionDuration", "number", title: "For at least (seconds)", required: false, range: "1..86400" } break case "time_range": section("Time Range Settings") { input "conditionStartTime", "time", title: "Start Time", required: false input "conditionEndTime", "time", title: "End Time", required: false } break case "mode": section("Mode Settings") { def modes = location.modes?.collect { it.name } input "conditionModes", "enum", title: "Mode(s)", options: modes, multiple: true, required: false input "conditionModeOperator", "enum", title: "Condition", options: ["in": "Is one of", "not_in": "Is not one of"], required: false, defaultValue: "in" } break case "variable": section("Variable Settings") { input "conditionVariableName", "text", title: "Variable Name", required: false input "conditionOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than"], required: false, defaultValue: "equals" input "conditionValue", "text", title: "Value", required: false } break case "days_of_week": section("Days of Week Settings") { input "conditionDays", "enum", title: "Days", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], multiple: true, required: false } break case "sun_position": section("Sun Position Settings") { input "conditionSunPosition", "enum", title: "Sun is", options: ["up": "Up (daytime)", "down": "Down (nighttime)"], required: false } break case "hsm_status": section("HSM Status Settings") { input "conditionHsmStatus", "enum", title: "HSM Status", options: ["armedAway": "Armed Away", "armedHome": "Armed Home", "armedNight": "Armed Night", "disarmed": "Disarmed"], required: false } break case "presence": section("Presence Sensor Settings") { input "conditionDevice", "capability.presenceSensor", title: "Presence Sensor", required: false input "conditionPresenceStatus", "enum", title: "Status", options: ["present": "Present", "not present": "Not Present"], required: false } break case "lock": section("Lock Status Settings") { input "conditionDevice", "capability.lock", title: "Lock Device", required: false input "conditionLockStatus", "enum", title: "Status", options: ["locked": "Locked", "unlocked": "Unlocked"], required: false } break case "thermostat_mode": section("Thermostat Mode Settings") { input "conditionDevice", "capability.thermostat", title: "Thermostat", required: false input "conditionThermostatMode", "enum", title: "Mode", options: ["auto": "Auto", "cool": "Cool", "heat": "Heat", "off": "Off", "emergency heat": "Emergency Heat"], required: false } break case "thermostat_state": section("Thermostat Operating State Settings") { input "conditionDevice", "capability.thermostat", title: "Thermostat", required: false input "conditionThermostatState", "enum", title: "Operating State", options: ["idle": "Idle", "heating": "Heating", "cooling": "Cooling", "fan only": "Fan Only", "pending heat": "Pending Heat", "pending cool": "Pending Cool"], required: false } break case "illuminance": section("Illuminance Level Settings") { input "conditionDevice", "capability.illuminanceMeasurement", title: "Illuminance Sensor", required: false input "conditionOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than", ">=": "Greater/Equal", "<=": "Less/Equal"], required: false, defaultValue: "<" input "conditionValue", "number", title: "Lux Value", required: false } break case "power": section("Power Level Settings") { input "conditionDevice", "capability.powerMeter", title: "Power Meter Device", required: false input "conditionOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than", ">=": "Greater/Equal", "<=": "Less/Equal"], required: false, defaultValue: ">" input "conditionValue", "number", title: "Power (Watts)", required: false } break } } def savePendingCondition() { def condition = buildConditionFromSettings() if (condition) { def list = atomicState.conditions ?: [] if (state.editingConditionIndex != null && state.editingConditionIndex >= 0 && state.editingConditionIndex < list.size()) { list[state.editingConditionIndex] = condition atomicState.conditions = list log.info "Updated condition ${state.editingConditionIndex + 1}" } else { list.add(condition) atomicState.conditions = list log.info "Added new condition" } state.updatedAt = now() } clearConditionSettings() state.remove("editingConditionIndex") state.remove("loadedConditionIndex") } def buildConditionFromSettings() { if (!settings.conditionType) return null def condition = [type: settings.conditionType] switch (settings.conditionType) { case "device_state": if (!settings.conditionDevice || !settings.conditionAttribute) return null condition.deviceId = settings.conditionDevice.id.toString() condition.attribute = settings.conditionAttribute condition.operator = settings.conditionOperator ?: "equals" condition.value = settings.conditionValue break case "device_was": if (!settings.conditionDevice || !settings.conditionAttribute) return null condition.deviceId = settings.conditionDevice.id.toString() condition.attribute = settings.conditionAttribute condition.value = settings.conditionValue condition.forSeconds = settings.conditionDuration break case "time_range": if (!settings.conditionStartTime || !settings.conditionEndTime) return null condition.startTime = formatTimeInput(settings.conditionStartTime) condition.endTime = formatTimeInput(settings.conditionEndTime) break case "mode": if (!settings.conditionModes) return null condition.modes = settings.conditionModes condition.operator = settings.conditionModeOperator ?: "in" break case "variable": if (!settings.conditionVariableName) return null condition.variableName = settings.conditionVariableName condition.operator = settings.conditionOperator ?: "equals" condition.value = settings.conditionValue break case "days_of_week": if (!settings.conditionDays) return null condition.days = settings.conditionDays break case "sun_position": if (!settings.conditionSunPosition) return null condition.position = settings.conditionSunPosition break case "hsm_status": if (!settings.conditionHsmStatus) return null condition.status = settings.conditionHsmStatus break case "presence": if (!settings.conditionDevice || !settings.conditionPresenceStatus) return null condition.deviceId = settings.conditionDevice.id.toString() condition.status = settings.conditionPresenceStatus break case "lock": if (!settings.conditionDevice || !settings.conditionLockStatus) return null condition.deviceId = settings.conditionDevice.id.toString() condition.status = settings.conditionLockStatus break case "thermostat_mode": if (!settings.conditionDevice || !settings.conditionThermostatMode) return null condition.deviceId = settings.conditionDevice.id.toString() condition.mode = settings.conditionThermostatMode break case "thermostat_state": if (!settings.conditionDevice || !settings.conditionThermostatState) return null condition.deviceId = settings.conditionDevice.id.toString() condition.state = settings.conditionThermostatState break case "illuminance": if (!settings.conditionDevice || settings.conditionValue == null) return null condition.deviceId = settings.conditionDevice.id.toString() condition.operator = settings.conditionOperator ?: "<" condition.value = settings.conditionValue break case "power": if (!settings.conditionDevice || settings.conditionValue == null) return null condition.deviceId = settings.conditionDevice.id.toString() condition.operator = settings.conditionOperator ?: ">" condition.value = settings.conditionValue break } return condition } def loadConditionSettings(condition) { clearConditionSettings() app.updateSetting("conditionType", condition.type) switch (condition.type) { case "device_state": case "device_was": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("conditionDevice", [type: "capability.*", value: device.id]) } if (condition.attribute) app.updateSetting("conditionAttribute", [type: "text", value: condition.attribute]) if (condition.operator) app.updateSetting("conditionOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("conditionValue", [type: "text", value: condition.value]) if (condition.forSeconds != null) app.updateSetting("conditionDuration", [type: "number", value: condition.forSeconds]) break case "time_range": // Support both 'start'/'end' (MCP format) and 'startTime'/'endTime' (UI format) for backwards compatibility def startTime = condition.start ?: condition.startTime def endTime = condition.end ?: condition.endTime if (startTime) app.updateSetting("conditionStartTime", [type: "time", value: startTime]) if (endTime) app.updateSetting("conditionEndTime", [type: "time", value: endTime]) break case "mode": if (condition.modes) app.updateSetting("conditionModes", [type: "enum", value: condition.modes]) if (condition.operator) app.updateSetting("conditionModeOperator", [type: "enum", value: condition.operator]) break case "variable": if (condition.variableName) app.updateSetting("conditionVariableName", [type: "text", value: condition.variableName]) if (condition.operator) app.updateSetting("conditionOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("conditionValue", [type: "text", value: condition.value]) break case "days_of_week": if (condition.days) app.updateSetting("conditionDays", [type: "enum", value: condition.days]) break case "sun_position": if (condition.position) app.updateSetting("conditionSunPosition", [type: "enum", value: condition.position]) break case "hsm_status": if (condition.status) app.updateSetting("conditionHsmStatus", [type: "enum", value: condition.status]) break case "presence": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("conditionDevice", [type: "capability.presenceSensor", value: device.id]) } if (condition.status) app.updateSetting("conditionPresenceStatus", [type: "enum", value: condition.status]) break case "lock": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("conditionDevice", [type: "capability.lock", value: device.id]) } if (condition.status) app.updateSetting("conditionLockStatus", [type: "enum", value: condition.status]) break case "thermostat_mode": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("conditionDevice", [type: "capability.thermostat", value: device.id]) } if (condition.mode) app.updateSetting("conditionThermostatMode", [type: "enum", value: condition.mode]) break case "thermostat_state": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("conditionDevice", [type: "capability.thermostat", value: device.id]) } if (condition.state) app.updateSetting("conditionThermostatState", [type: "enum", value: condition.state]) break case "illuminance": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("conditionDevice", [type: "capability.illuminanceMeasurement", value: device.id]) } if (condition.operator) app.updateSetting("conditionOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("conditionValue", [type: "text", value: condition.value]) break case "power": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("conditionDevice", [type: "capability.powerMeter", value: device.id]) } if (condition.operator) app.updateSetting("conditionOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("conditionValue", [type: "text", value: condition.value]) break } } def clearConditionSettings() { ["conditionType", "conditionDevice", "conditionAttribute", "conditionOperator", "conditionValue", "conditionDuration", "conditionStartTime", "conditionEndTime", "conditionModes", "conditionModeOperator", "conditionVariableName", "conditionDays", "conditionSunPosition", "conditionHsmStatus", "conditionPresenceStatus", "conditionLockStatus", "conditionThermostatMode", "conditionThermostatState"].each { app.removeSetting(it) } } // ==================== ACTION PAGES ==================== def editActionsPage() { // Auto-save pending action if (settings.actionType) { savePendingAction() } dynamicPage(name: "editActionsPage", title: "Edit Actions") { section("Actions (executed in order)") { if (atomicState.actions && atomicState.actions.size() > 0) { atomicState.actions.eachWithIndex { action, idx -> href name: "editAction_${idx}", page: "editActionPage", title: "${idx + 1}. ${describeAction(action)}", description: "Tap to edit", params: [actionIndex: idx] } } else { paragraph "No actions defined. Add an action for this rule to do something." } } if (atomicState.actions && atomicState.actions.size() > 1) { section("Reorder Actions") { atomicState.actions.eachWithIndex { action, idx -> if (idx > 0) { input "moveUp_${idx}", "button", title: "↑ Move ${idx + 1} Up" } if (idx < atomicState.actions.size() - 1) { input "moveDown_${idx}", "button", title: "↓ Move ${idx + 1} Down" } } } } section { href name: "addAction", page: "addActionPage", title: "+ Add Action" } section { href name: "backToMain", page: "mainPage", title: "← Done" } } } def addActionPage() { // Only clear settings on fresh entry, not on submitOnChange re-renders if (!settings.actionType) { state.editingActionIndex = null clearActionSettings() } dynamicPage(name: "addActionPage", title: "Add Action") { section("Action Type") { input "actionType", "enum", title: "What should happen?", options: getActionTypeOptions(), required: false, submitOnChange: true } renderActionFields() section { if (settings.actionType) { href name: "saveAction", page: "editActionsPage", title: "Save Action", description: "Save and return to actions list" } href name: "cancelAction", page: "editActionsPage", title: "Cancel" } } } def editActionPage(params) { def actionIndex = params?.actionIndex != null ? params.actionIndex.toInteger() : state.editingActionIndex if (actionIndex == null || actionIndex < 0 || actionIndex >= (atomicState.actions?.size() ?: 0)) { return dynamicPage(name: "editActionPage", title: "Action Not Found") { section { paragraph "The requested action could not be found." href name: "backToActions", page: "editActionsPage", title: "Back to Actions" } } } state.editingActionIndex = actionIndex def action = atomicState.actions[actionIndex] if (state.loadedActionIndex != actionIndex) { loadActionSettings(action) state.loadedActionIndex = actionIndex } dynamicPage(name: "editActionPage", title: "Edit Action ${actionIndex + 1}") { section("Action Type") { input "actionType", "enum", title: "What should happen?", options: getActionTypeOptions(), required: false, submitOnChange: true } renderActionFields() section { href name: "saveActionEdit", page: "editActionsPage", title: "Save Changes", description: "Save and return to actions list" input "deleteActionBtn", "button", title: "Delete Action" href name: "cancelActionEdit", page: "editActionsPage", title: "Cancel" } } } def getActionTypeOptions() { return [ "device_command": "Device Command", "toggle_device": "Toggle Device On/Off", "set_level": "Set Dimmer Level", "set_color": "Set Color (RGB)", "set_color_temperature": "Set Color Temperature", "lock": "Lock Device", "unlock": "Unlock Device", "activate_scene": "Activate Scene", "set_mode": "Set Hub Mode", "set_hsm": "Set HSM Status", "set_variable": "Set Hub Variable", "set_local_variable": "Set Local Variable", "send_notification": "Send Notification", "capture_state": "Capture Device State", "restore_state": "Restore Device State", "delay": "Delay", "cancel_delayed": "Cancel Delayed Actions", "if_then_else": "If-Then-Else (Conditional)", "repeat": "Repeat Actions", "log": "Log Message", "stop": "Stop Rule Execution", "set_thermostat": "Set Thermostat", "http_request": "HTTP Request", "speak": "Speak (Text-to-Speech)", "comment": "Comment (Documentation)", "set_valve": "Set Valve (Open/Close)", "set_fan_speed": "Set Fan Speed", "set_shade": "Set Window Shade", "variable_math": "Variable Math Operation" ] } def renderActionFields() { switch (settings.actionType) { case "device_command": section("Device Command Settings") { input "actionDevice", "capability.*", title: "Device", required: false, submitOnChange: true if (settings.actionDevice) { def cmds = settings.actionDevice.supportedCommands?.collect { it.name }?.sort() input "actionCommand", "enum", title: "Command", options: cmds, required: false } input "actionParams", "text", title: "Parameters (comma separated, optional)", required: false } break case "toggle_device": section("Toggle Device Settings") { input "actionDevice", "capability.switch", title: "Device", required: false } break case "set_level": section("Set Level Settings") { input "actionDevice", "capability.switchLevel", title: "Device", required: false input "actionLevel", "number", title: "Level (0-100)", required: false, range: "0..100" input "actionDuration", "number", title: "Fade Duration (seconds, optional)", required: false } break case "set_color": section("Set Color Settings") { input "actionDevice", "capability.colorControl", title: "Device", required: false input "actionHue", "number", title: "Hue (0-100)", required: false, range: "0..100" input "actionSaturation", "number", title: "Saturation (0-100)", required: false, range: "0..100" input "actionLevel", "number", title: "Level (0-100)", required: false, range: "0..100" } break case "set_color_temperature": section("Set Color Temperature Settings") { input "actionDevice", "capability.colorTemperature", title: "Device", required: false input "actionColorTemperature", "number", title: "Color Temperature (Kelvin)", required: false, range: "1000..10000" input "actionLevel", "number", title: "Level (0-100, optional)", required: false, range: "0..100" } break case "lock": section("Lock Device Settings") { input "actionDevice", "capability.lock", title: "Lock Device", required: false } break case "unlock": section("Unlock Device Settings") { input "actionDevice", "capability.lock", title: "Lock Device", required: false } break case "activate_scene": section("Activate Scene Settings") { input "actionSceneDevice", "capability.switch", title: "Scene Device", required: false paragraph "Select a scene activator device. When triggered, this will turn the device on to activate the scene." } break case "set_mode": section("Set Mode Settings") { def modes = location.modes?.collect { it.name } input "actionMode", "enum", title: "Mode", options: modes, required: false } break case "set_hsm": section("Set HSM Settings") { input "actionHsmStatus", "enum", title: "HSM Status", options: ["armAway": "Arm Away", "armHome": "Arm Home", "armNight": "Arm Night", "disarm": "Disarm"], required: false } break case "set_variable": section("Set Hub Variable Settings") { input "actionVariableName", "text", title: "Variable Name", required: false input "actionVariableValue", "text", title: "Value", required: false paragraph "Sets a hub-level variable that persists across rules." } break case "set_local_variable": section("Set Local Variable Settings") { input "actionLocalVariableName", "text", title: "Variable Name", required: false input "actionLocalVariableValue", "text", title: "Value", required: false paragraph "Sets a variable local to this rule only." } break case "send_notification": section("Send Notification Settings") { input "actionNotificationDevice", "capability.notification", title: "Notification Device", required: false input "actionNotificationMessage", "text", title: "Message", required: false } break case "capture_state": section("Capture Device State Settings") { input "actionCaptureDevices", "capability.*", title: "Devices to Capture", required: false, multiple: true input "actionCaptureStateId", "text", title: "State ID (optional)", required: false, defaultValue: "default" paragraph "Captures switch, level, color, and color temperature states. Max 20 captured states stored. Use 'Restore State' to restore later." } break case "restore_state": section("Restore Device State Settings") { input "actionRestoreStateId", "text", title: "State ID to Restore", required: false, defaultValue: "default" paragraph "Restores previously captured device states." } break case "delay": section("Delay Settings") { input "actionDelaySeconds", "number", title: "Delay (seconds)", required: false, range: "1..86400" input "actionDelayId", "text", title: "Delay ID (optional)", required: false paragraph "Optional: Give this delay an ID to cancel it later with 'Cancel Delayed Actions'." } break case "cancel_delayed": section("Cancel Delayed Actions Settings") { input "actionCancelDelayId", "enum", title: "What to Cancel", options: ["all": "Cancel ALL Delayed Actions", "specific": "Cancel Specific Delay ID"], required: false, submitOnChange: true, defaultValue: "all" if (settings.actionCancelDelayId == "specific") { input "actionCancelSpecificId", "text", title: "Delay ID to Cancel", required: false } } break case "if_then_else": section("If-Then-Else Settings") { paragraph "Condition Type:" input "actionIfConditionType", "enum", title: "Condition Type", options: getConditionTypeOptions(), required: false, submitOnChange: true renderIfConditionFields() paragraph "
Note: This creates a conditional branch. Then/Else actions must be configured via MCP tools or will be empty." } break case "repeat": section("Repeat Actions Settings") { input "actionRepeatCount", "number", title: "Number of Times to Repeat", required: false, range: "1..100", defaultValue: 1 paragraph "Note: The actions to repeat must be configured via MCP tools. This UI creates an empty repeat container." } break case "log": section("Log Settings") { input "actionLogMessage", "text", title: "Message", required: false input "actionLogLevel", "enum", title: "Level", options: ["info": "Info", "warn": "Warning", "debug": "Debug"], required: false, defaultValue: "info" } break case "stop": section { paragraph "This action will stop rule execution. Any actions after this will not run." } break case "set_thermostat": section("Set Thermostat Settings") { input "actionDevice", "capability.thermostat", title: "Thermostat Device", required: false, submitOnChange: true input "actionThermostatMode", "enum", title: "Thermostat Mode (optional)", options: ["heat": "Heat", "cool": "Cool", "auto": "Auto", "off": "Off", "emergency heat": "Emergency Heat"], required: false input "actionHeatingSetpoint", "number", title: "Heating Setpoint (optional)", required: false input "actionCoolingSetpoint", "number", title: "Cooling Setpoint (optional)", required: false input "actionFanMode", "enum", title: "Fan Mode (optional)", options: ["auto": "Auto", "on": "On", "circulate": "Circulate"], required: false } break case "http_request": section("HTTP Request Settings") { input "actionHttpMethod", "enum", title: "Method", options: ["GET": "GET", "POST": "POST"], required: false, defaultValue: "GET", submitOnChange: true input "actionHttpUrl", "text", title: "URL", required: false if (settings.actionHttpMethod == "POST") { input "actionHttpContentType", "text", title: "Content Type (optional)", required: false, defaultValue: "application/json" input "actionHttpBody", "text", title: "Body (optional)", required: false } paragraph "Uses Hubitat's built-in httpGet/httpPost methods." } break case "speak": section("Speak (Text-to-Speech) Settings") { input "actionDevice", "capability.speechSynthesis", title: "TTS Device", required: false input "actionSpeakMessage", "text", title: "Message", required: false input "actionSpeakVolume", "number", title: "Volume (optional, 0-100)", required: false, range: "0..100" } break case "comment": section("Comment Settings") { input "actionCommentText", "text", title: "Comment Text", required: false paragraph "This action just logs the comment text. Useful for documenting action sequences." } break case "set_valve": section("Set Valve Settings") { input "actionDevice", "capability.valve", title: "Valve Device", required: false input "actionValveCommand", "enum", title: "Command", options: ["open": "Open", "close": "Close"], required: false } break case "set_fan_speed": section("Set Fan Speed Settings") { input "actionDevice", "capability.fanControl", title: "Fan Device", required: false input "actionFanSpeed", "enum", title: "Speed", options: ["low": "Low", "medium-low": "Medium-Low", "medium": "Medium", "medium-high": "Medium-High", "high": "High", "on": "On", "off": "Off", "auto": "Auto"], required: false } break case "set_shade": section("Set Window Shade Settings") { input "actionDevice", "capability.windowShade", title: "Shade Device", required: false input "actionShadeCommand", "enum", title: "Command (optional)", options: ["open": "Open", "close": "Close"], required: false input "actionShadePosition", "number", title: "Position (0-100, optional)", required: false, range: "0..100" paragraph "Set command OR position. If position is set, command is ignored." } break case "variable_math": section("Variable Math Operation Settings") { input "actionVariableMathName", "text", title: "Variable Name", required: false input "actionVariableMathOperation", "enum", title: "Operation", options: ["add": "Add", "subtract": "Subtract", "multiply": "Multiply", "divide": "Divide", "modulo": "Modulo", "set": "Set To"], required: false input "actionVariableMathOperand", "decimal", title: "Operand Value", required: false input "actionVariableMathScope", "enum", title: "Variable Scope", options: ["local": "Local (this rule only)", "global": "Global (hub variable)"], required: false, defaultValue: "local" } break } } /** * Renders condition fields for if_then_else action type */ def renderIfConditionFields() { switch (settings.actionIfConditionType) { case "device_state": input "actionIfDevice", "capability.*", title: "Device", required: false, submitOnChange: true if (settings.actionIfDevice) { def attrs = settings.actionIfDevice.supportedAttributes?.collect { it.name }?.unique()?.sort() input "actionIfAttribute", "enum", title: "Attribute", options: attrs, required: false } input "actionIfOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than", ">=": "Greater/Equal", "<=": "Less/Equal"], required: false, defaultValue: "equals" input "actionIfValue", "text", title: "Value", required: false break case "mode": def modes = location.modes?.collect { it.name } input "actionIfModes", "enum", title: "Mode(s)", options: modes, multiple: true, required: false input "actionIfModeOperator", "enum", title: "Condition", options: ["in": "Is one of", "not_in": "Is not one of"], required: false, defaultValue: "in" break case "time_range": input "actionIfStartTime", "time", title: "Start Time", required: false input "actionIfEndTime", "time", title: "End Time", required: false break case "variable": input "actionIfVariableName", "text", title: "Variable Name", required: false input "actionIfOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than"], required: false, defaultValue: "equals" input "actionIfValue", "text", title: "Value", required: false break case "hsm_status": input "actionIfHsmStatus", "enum", title: "HSM Status", options: ["armedAway": "Armed Away", "armedHome": "Armed Home", "armedNight": "Armed Night", "disarmed": "Disarmed"], required: false break case "sun_position": input "actionIfSunPosition", "enum", title: "Sun is", options: ["up": "Up (daytime)", "down": "Down (nighttime)"], required: false break case "days_of_week": input "actionIfDays", "enum", title: "Days", options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], multiple: true, required: false break case "device_was": input "actionIfDevice", "capability.*", title: "Device", required: false, submitOnChange: true if (settings.actionIfDevice) { def attrs = settings.actionIfDevice.supportedAttributes?.collect { it.name }?.unique()?.sort() input "actionIfAttribute", "enum", title: "Attribute", options: attrs, required: false } input "actionIfValue", "text", title: "Has Been Value", required: false input "actionIfDuration", "number", title: "For Seconds", required: false, range: "1..86400" break case "presence": input "actionIfDevice", "capability.presenceSensor", title: "Presence Sensor", required: false input "actionIfPresenceStatus", "enum", title: "Status", options: ["present": "Present", "not present": "Not Present"], required: false break case "lock": input "actionIfDevice", "capability.lock", title: "Lock Device", required: false input "actionIfLockStatus", "enum", title: "Status", options: ["locked": "Locked", "unlocked": "Unlocked"], required: false break case "thermostat_mode": input "actionIfDevice", "capability.thermostat", title: "Thermostat", required: false input "actionIfThermostatMode", "enum", title: "Mode", options: ["auto": "Auto", "cool": "Cool", "heat": "Heat", "off": "Off", "emergency heat": "Emergency Heat"], required: false break case "thermostat_state": input "actionIfDevice", "capability.thermostat", title: "Thermostat", required: false input "actionIfThermostatState", "enum", title: "Operating State", options: ["idle": "Idle", "heating": "Heating", "cooling": "Cooling", "fan only": "Fan Only", "pending heat": "Pending Heat", "pending cool": "Pending Cool"], required: false break case "illuminance": input "actionIfDevice", "capability.illuminanceMeasurement", title: "Illuminance Sensor", required: false input "actionIfOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than", ">=": "Greater/Equal", "<=": "Less/Equal"], required: false, defaultValue: "<" input "actionIfValue", "number", title: "Lux Value", required: false break case "power": input "actionIfDevice", "capability.powerMeter", title: "Power Meter", required: false input "actionIfOperator", "enum", title: "Comparison", options: ["equals": "Equals", "not_equals": "Not Equals", ">": "Greater Than", "<": "Less Than", ">=": "Greater/Equal", "<=": "Less/Equal"], required: false, defaultValue: ">" input "actionIfValue", "number", title: "Watts", required: false break } } def savePendingAction() { def action = buildActionFromSettings() if (action) { def list = atomicState.actions ?: [] if (state.editingActionIndex != null && state.editingActionIndex >= 0 && state.editingActionIndex < list.size()) { list[state.editingActionIndex] = action atomicState.actions = list log.info "Updated action ${state.editingActionIndex + 1}" } else { list.add(action) atomicState.actions = list log.info "Added new action" } state.updatedAt = now() } clearActionSettings() state.remove("editingActionIndex") state.remove("loadedActionIndex") } def buildActionFromSettings() { if (!settings.actionType) return null def action = [type: settings.actionType] switch (settings.actionType) { case "device_command": if (!settings.actionDevice || !settings.actionCommand) return null action.deviceId = settings.actionDevice.id.toString() action.command = settings.actionCommand if (settings.actionParams) { action.parameters = settings.actionParams.split(",").collect { it.trim() } } break case "toggle_device": if (!settings.actionDevice) return null action.deviceId = settings.actionDevice.id.toString() break case "set_level": if (!settings.actionDevice || settings.actionLevel == null) return null action.deviceId = settings.actionDevice.id.toString() action.level = settings.actionLevel if (settings.actionDuration) action.duration = settings.actionDuration break case "set_color": if (!settings.actionDevice || settings.actionHue == null || settings.actionSaturation == null) return null action.deviceId = settings.actionDevice.id.toString() action.hue = settings.actionHue action.saturation = settings.actionSaturation if (settings.actionLevel != null) action.level = settings.actionLevel break case "set_color_temperature": if (!settings.actionDevice || settings.actionColorTemperature == null) return null action.deviceId = settings.actionDevice.id.toString() action.temperature = settings.actionColorTemperature if (settings.actionLevel != null) action.level = settings.actionLevel break case "lock": if (!settings.actionDevice) return null action.deviceId = settings.actionDevice.id.toString() break case "unlock": if (!settings.actionDevice) return null action.deviceId = settings.actionDevice.id.toString() break case "activate_scene": if (!settings.actionSceneDevice) return null action.sceneDeviceId = settings.actionSceneDevice.id.toString() break case "set_mode": if (!settings.actionMode) return null action.mode = settings.actionMode break case "set_hsm": if (!settings.actionHsmStatus) return null action.status = settings.actionHsmStatus break case "set_variable": if (!settings.actionVariableName) return null action.variableName = settings.actionVariableName action.value = settings.actionVariableValue break case "set_local_variable": if (!settings.actionLocalVariableName) return null action.variableName = settings.actionLocalVariableName action.value = settings.actionLocalVariableValue break case "send_notification": if (!settings.actionNotificationDevice || !settings.actionNotificationMessage) return null action.deviceId = settings.actionNotificationDevice.id.toString() action.message = settings.actionNotificationMessage break case "capture_state": if (!settings.actionCaptureDevices) return null action.deviceIds = settings.actionCaptureDevices.collect { it.id.toString() } action.stateId = settings.actionCaptureStateId ?: "default" break case "restore_state": action.stateId = settings.actionRestoreStateId ?: "default" break case "delay": if (!settings.actionDelaySeconds) return null action.seconds = settings.actionDelaySeconds if (settings.actionDelayId) action.delayId = settings.actionDelayId break case "cancel_delayed": if (settings.actionCancelDelayId == "all") { action.delayId = "all" } else if (settings.actionCancelDelayId == "specific" && settings.actionCancelSpecificId) { action.delayId = settings.actionCancelSpecificId } else { action.delayId = "all" // Default to all if not specified } break case "if_then_else": def condition = buildIfConditionFromSettings() if (!condition) return null action.condition = condition action.thenActions = [] // Empty - must be configured via MCP tools action.elseActions = [] // Empty - must be configured via MCP tools break case "repeat": action.count = settings.actionRepeatCount ?: 1 action.actions = [] // Empty - must be configured via MCP tools break case "log": if (!settings.actionLogMessage) return null action.message = settings.actionLogMessage action.level = settings.actionLogLevel ?: "info" break case "stop": // No additional fields needed break case "set_thermostat": if (!settings.actionDevice) return null action.deviceId = settings.actionDevice.id.toString() if (settings.actionThermostatMode) action.thermostatMode = settings.actionThermostatMode if (settings.actionHeatingSetpoint != null) action.heatingSetpoint = settings.actionHeatingSetpoint if (settings.actionCoolingSetpoint != null) action.coolingSetpoint = settings.actionCoolingSetpoint if (settings.actionFanMode) action.fanMode = settings.actionFanMode break case "http_request": if (!settings.actionHttpUrl) return null action.method = settings.actionHttpMethod ?: "GET" action.url = settings.actionHttpUrl if (action.method == "POST") { if (settings.actionHttpContentType) action.contentType = settings.actionHttpContentType if (settings.actionHttpBody) action.body = settings.actionHttpBody } break case "speak": if (!settings.actionDevice || !settings.actionSpeakMessage) return null action.deviceId = settings.actionDevice.id.toString() action.message = settings.actionSpeakMessage if (settings.actionSpeakVolume != null) action.volume = settings.actionSpeakVolume break case "comment": if (!settings.actionCommentText) return null action.text = settings.actionCommentText break case "set_valve": if (!settings.actionDevice || !settings.actionValveCommand) return null action.deviceId = settings.actionDevice.id.toString() action.command = settings.actionValveCommand break case "set_fan_speed": if (!settings.actionDevice || !settings.actionFanSpeed) return null action.deviceId = settings.actionDevice.id.toString() action.speed = settings.actionFanSpeed break case "set_shade": if (!settings.actionDevice) return null action.deviceId = settings.actionDevice.id.toString() if (settings.actionShadePosition != null) { action.position = settings.actionShadePosition } else if (settings.actionShadeCommand) { action.command = settings.actionShadeCommand } else { return null // Need either position or command } break case "variable_math": if (!settings.actionVariableMathName || !settings.actionVariableMathOperation) return null action.variableName = settings.actionVariableMathName action.operation = settings.actionVariableMathOperation action.operand = settings.actionVariableMathOperand ?: 0 action.scope = settings.actionVariableMathScope ?: "local" break } return action } /** * Builds the condition object for if_then_else actions from UI settings */ def buildIfConditionFromSettings() { if (!settings.actionIfConditionType) return null def condition = [type: settings.actionIfConditionType] switch (settings.actionIfConditionType) { case "device_state": if (!settings.actionIfDevice || !settings.actionIfAttribute) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.attribute = settings.actionIfAttribute condition.operator = settings.actionIfOperator ?: "equals" condition.value = settings.actionIfValue break case "mode": if (!settings.actionIfModes) return null condition.modes = settings.actionIfModes condition.operator = settings.actionIfModeOperator ?: "in" break case "time_range": if (!settings.actionIfStartTime || !settings.actionIfEndTime) return null condition.startTime = formatTimeInput(settings.actionIfStartTime) condition.endTime = formatTimeInput(settings.actionIfEndTime) break case "variable": if (!settings.actionIfVariableName) return null condition.variableName = settings.actionIfVariableName condition.operator = settings.actionIfOperator ?: "equals" condition.value = settings.actionIfValue break case "hsm_status": if (!settings.actionIfHsmStatus) return null condition.status = settings.actionIfHsmStatus break case "sun_position": if (!settings.actionIfSunPosition) return null condition.position = settings.actionIfSunPosition break case "days_of_week": if (!settings.actionIfDays) return null condition.days = settings.actionIfDays break case "device_was": if (!settings.actionIfDevice || !settings.actionIfAttribute) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.attribute = settings.actionIfAttribute condition.value = settings.actionIfValue condition.forSeconds = settings.actionIfDuration break case "presence": if (!settings.actionIfDevice) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.status = settings.actionIfPresenceStatus break case "lock": if (!settings.actionIfDevice) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.status = settings.actionIfLockStatus break case "thermostat_mode": if (!settings.actionIfDevice) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.mode = settings.actionIfThermostatMode break case "thermostat_state": if (!settings.actionIfDevice) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.state = settings.actionIfThermostatState break case "illuminance": if (!settings.actionIfDevice || settings.actionIfValue == null) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.operator = settings.actionIfOperator ?: "<" condition.value = settings.actionIfValue break case "power": if (!settings.actionIfDevice || settings.actionIfValue == null) return null condition.deviceId = settings.actionIfDevice.id.toString() condition.operator = settings.actionIfOperator ?: ">" condition.value = settings.actionIfValue break } return condition } def loadActionSettings(action) { clearActionSettings() app.updateSetting("actionType", action.type) switch (action.type) { case "device_command": case "toggle_device": case "set_level": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) { def capType = action.type == "set_level" ? "capability.switchLevel" : action.type == "toggle_device" ? "capability.switch" : "capability.*" app.updateSetting("actionDevice", [type: capType, value: device.id]) } } if (action.command) app.updateSetting("actionCommand", [type: "enum", value: action.command]) if (action.parameters) app.updateSetting("actionParams", [type: "text", value: action.parameters.join(", ")]) if (action.level != null) app.updateSetting("actionLevel", [type: "number", value: action.level]) if (action.duration != null) app.updateSetting("actionDuration", [type: "number", value: action.duration]) break case "set_color": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.colorControl", value: device.id]) } if (action.hue != null) app.updateSetting("actionHue", [type: "number", value: action.hue]) if (action.saturation != null) app.updateSetting("actionSaturation", [type: "number", value: action.saturation]) if (action.level != null) app.updateSetting("actionLevel", [type: "number", value: action.level]) break case "set_color_temperature": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.colorTemperature", value: device.id]) } if (action.temperature != null) app.updateSetting("actionColorTemperature", [type: "number", value: action.temperature]) if (action.level != null) app.updateSetting("actionLevel", [type: "number", value: action.level]) break case "lock": case "unlock": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.lock", value: device.id]) } break case "activate_scene": if (action.sceneDeviceId) { def device = parent.findDevice(action.sceneDeviceId) if (device) app.updateSetting("actionSceneDevice", [type: "capability.switch", value: device.id]) } break case "set_mode": if (action.mode) app.updateSetting("actionMode", [type: "enum", value: action.mode]) break case "set_hsm": if (action.status) app.updateSetting("actionHsmStatus", [type: "enum", value: action.status]) break case "set_variable": if (action.variableName) app.updateSetting("actionVariableName", [type: "text", value: action.variableName]) if (action.value != null) app.updateSetting("actionVariableValue", [type: "text", value: action.value]) break case "set_local_variable": if (action.variableName) app.updateSetting("actionLocalVariableName", [type: "text", value: action.variableName]) if (action.value != null) app.updateSetting("actionLocalVariableValue", [type: "text", value: action.value]) break case "send_notification": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionNotificationDevice", [type: "capability.notification", value: device.id]) } if (action.message) app.updateSetting("actionNotificationMessage", [type: "text", value: action.message]) break case "capture_state": if (action.deviceIds) { // For multiple devices, we need to load them as a list def devices = action.deviceIds.collect { parent.findDevice(it) }.findAll { it != null } if (devices) app.updateSetting("actionCaptureDevices", [type: "capability.*", value: devices.collect { it.id }]) } if (action.stateId) app.updateSetting("actionCaptureStateId", [type: "text", value: action.stateId]) break case "restore_state": if (action.stateId) app.updateSetting("actionRestoreStateId", [type: "text", value: action.stateId]) break case "delay": if (action.seconds != null) app.updateSetting("actionDelaySeconds", [type: "number", value: action.seconds]) if (action.delayId) app.updateSetting("actionDelayId", [type: "text", value: action.delayId]) break case "cancel_delayed": if (action.delayId == "all") { app.updateSetting("actionCancelDelayId", [type: "enum", value: "all"]) } else if (action.delayId) { app.updateSetting("actionCancelDelayId", [type: "enum", value: "specific"]) app.updateSetting("actionCancelSpecificId", [type: "text", value: action.delayId]) } break case "if_then_else": if (action.condition) { loadIfConditionSettings(action.condition) } break case "repeat": if (action.count != null) app.updateSetting("actionRepeatCount", [type: "number", value: action.count]) else if (action.times != null) app.updateSetting("actionRepeatCount", [type: "number", value: action.times]) break case "log": if (action.message) app.updateSetting("actionLogMessage", [type: "text", value: action.message]) if (action.level) app.updateSetting("actionLogLevel", [type: "enum", value: action.level]) break case "set_thermostat": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.thermostat", value: device.id]) } if (action.thermostatMode) app.updateSetting("actionThermostatMode", [type: "enum", value: action.thermostatMode]) if (action.heatingSetpoint != null) app.updateSetting("actionHeatingSetpoint", [type: "number", value: action.heatingSetpoint]) if (action.coolingSetpoint != null) app.updateSetting("actionCoolingSetpoint", [type: "number", value: action.coolingSetpoint]) if (action.fanMode) app.updateSetting("actionFanMode", [type: "enum", value: action.fanMode]) break case "http_request": if (action.method) app.updateSetting("actionHttpMethod", [type: "enum", value: action.method]) if (action.url) app.updateSetting("actionHttpUrl", [type: "text", value: action.url]) if (action.contentType) app.updateSetting("actionHttpContentType", [type: "text", value: action.contentType]) if (action.body) app.updateSetting("actionHttpBody", [type: "text", value: action.body]) break case "speak": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.speechSynthesis", value: device.id]) } if (action.message) app.updateSetting("actionSpeakMessage", [type: "text", value: action.message]) if (action.volume != null) app.updateSetting("actionSpeakVolume", [type: "number", value: action.volume]) break case "comment": if (action.text) app.updateSetting("actionCommentText", [type: "text", value: action.text]) break case "set_valve": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.valve", value: device.id]) } if (action.command) app.updateSetting("actionValveCommand", [type: "enum", value: action.command]) break case "set_fan_speed": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.fanControl", value: device.id]) } if (action.speed) app.updateSetting("actionFanSpeed", [type: "enum", value: action.speed]) break case "set_shade": if (action.deviceId) { def device = parent.findDevice(action.deviceId) if (device) app.updateSetting("actionDevice", [type: "capability.windowShade", value: device.id]) } if (action.command) app.updateSetting("actionShadeCommand", [type: "enum", value: action.command]) if (action.position != null) app.updateSetting("actionShadePosition", [type: "number", value: action.position]) break case "variable_math": if (action.variableName) app.updateSetting("actionVariableMathName", [type: "text", value: action.variableName]) if (action.operation) app.updateSetting("actionVariableMathOperation", [type: "enum", value: action.operation]) if (action.operand != null) app.updateSetting("actionVariableMathOperand", [type: "decimal", value: action.operand]) if (action.scope) app.updateSetting("actionVariableMathScope", [type: "enum", value: action.scope]) break } } /** * Loads condition settings for if_then_else action type */ def loadIfConditionSettings(condition) { if (!condition?.type) return app.updateSetting("actionIfConditionType", condition.type) switch (condition.type) { case "device_state": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.*", value: device.id]) } if (condition.attribute) app.updateSetting("actionIfAttribute", [type: "text", value: condition.attribute]) if (condition.operator) app.updateSetting("actionIfOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("actionIfValue", [type: "text", value: condition.value]) break case "mode": if (condition.modes) app.updateSetting("actionIfModes", [type: "enum", value: condition.modes]) if (condition.operator) app.updateSetting("actionIfModeOperator", [type: "enum", value: condition.operator]) break case "time_range": if (condition.startTime) app.updateSetting("actionIfStartTime", [type: "time", value: condition.startTime]) if (condition.endTime) app.updateSetting("actionIfEndTime", [type: "time", value: condition.endTime]) break case "variable": if (condition.variableName) app.updateSetting("actionIfVariableName", [type: "text", value: condition.variableName]) if (condition.operator) app.updateSetting("actionIfOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("actionIfValue", [type: "text", value: condition.value]) break case "hsm_status": if (condition.status) app.updateSetting("actionIfHsmStatus", [type: "enum", value: condition.status]) break case "sun_position": if (condition.position) app.updateSetting("actionIfSunPosition", [type: "enum", value: condition.position]) break case "days_of_week": if (condition.days) app.updateSetting("actionIfDays", [type: "enum", value: condition.days]) break case "device_was": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.*", value: device.id]) } if (condition.attribute) app.updateSetting("actionIfAttribute", [type: "text", value: condition.attribute]) if (condition.value != null) app.updateSetting("actionIfValue", [type: "text", value: condition.value]) if (condition.forSeconds != null) app.updateSetting("actionIfDuration", [type: "number", value: condition.forSeconds]) break case "presence": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.presenceSensor", value: device.id]) } if (condition.status) app.updateSetting("actionIfPresenceStatus", [type: "enum", value: condition.status]) break case "lock": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.lock", value: device.id]) } if (condition.status) app.updateSetting("actionIfLockStatus", [type: "enum", value: condition.status]) break case "thermostat_mode": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.thermostat", value: device.id]) } if (condition.mode) app.updateSetting("actionIfThermostatMode", [type: "enum", value: condition.mode]) break case "thermostat_state": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.thermostat", value: device.id]) } if (condition.state) app.updateSetting("actionIfThermostatState", [type: "enum", value: condition.state]) break case "illuminance": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.illuminanceMeasurement", value: device.id]) } if (condition.operator) app.updateSetting("actionIfOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("actionIfValue", [type: "number", value: condition.value]) break case "power": if (condition.deviceId) { def device = parent.findDevice(condition.deviceId) if (device) app.updateSetting("actionIfDevice", [type: "capability.powerMeter", value: device.id]) } if (condition.operator) app.updateSetting("actionIfOperator", [type: "enum", value: condition.operator]) if (condition.value != null) app.updateSetting("actionIfValue", [type: "number", value: condition.value]) break } } def clearActionSettings() { ["actionType", "actionDevice", "actionCommand", "actionParams", "actionLevel", "actionDuration", "actionMode", "actionHsmStatus", "actionVariableName", "actionVariableValue", "actionDelaySeconds", "actionDelayId", "actionLogMessage", "actionLogLevel", // New action type settings "actionHue", "actionSaturation", "actionColorTemperature", "actionSceneDevice", "actionLocalVariableName", "actionLocalVariableValue", "actionNotificationDevice", "actionNotificationMessage", "actionCaptureDevices", "actionCaptureStateId", "actionRestoreStateId", "actionCancelDelayId", "actionCancelSpecificId", "actionRepeatCount", // If-then-else condition settings "actionIfConditionType", "actionIfDevice", "actionIfAttribute", "actionIfOperator", "actionIfValue", "actionIfModes", "actionIfModeOperator", "actionIfStartTime", "actionIfEndTime", "actionIfVariableName", "actionIfHsmStatus", "actionIfSunPosition", "actionIfDays", // Additional if_then_else condition settings for all 14 condition types "actionIfDuration", "actionIfPresenceStatus", "actionIfLockStatus", "actionIfThermostatMode", "actionIfThermostatState", // set_thermostat, http_request, speak, comment action settings "actionThermostatMode", "actionHeatingSetpoint", "actionCoolingSetpoint", "actionFanMode", "actionHttpMethod", "actionHttpUrl", "actionHttpContentType", "actionHttpBody", "actionSpeakMessage", "actionSpeakVolume", "actionCommentText", // set_valve, set_fan_speed, set_shade action settings "actionValveCommand", "actionFanSpeed", "actionShadeCommand", "actionShadePosition", // variable_math action settings "actionVariableMathName", "actionVariableMathOperation", "actionVariableMathOperand", "actionVariableMathScope"].each { app.removeSetting(it) } } // ==================== BUTTON HANDLER ==================== def appButtonHandler(btn) { if (btn == "testRuleBtn") { testRule() } else if (btn == "deleteTriggerBtn" && state.editingTriggerIndex != null) { def list = atomicState.triggers ?: [] list.remove((int) state.editingTriggerIndex) atomicState.triggers = list state.updatedAt = now() clearTriggerSettings() state.remove("editingTriggerIndex") } else if (btn == "deleteConditionBtn" && state.editingConditionIndex != null) { def list = atomicState.conditions ?: [] list.remove((int) state.editingConditionIndex) atomicState.conditions = list state.updatedAt = now() clearConditionSettings() state.remove("editingConditionIndex") } else if (btn == "deleteActionBtn" && state.editingActionIndex != null) { def list = atomicState.actions ?: [] list.remove((int) state.editingActionIndex) atomicState.actions = list state.updatedAt = now() clearActionSettings() state.remove("editingActionIndex") } else if (btn.startsWith("moveUp_")) { try { def idx = btn.replace("moveUp_", "").toInteger() def list = atomicState.actions ?: [] if (idx > 0 && idx < list.size()) { def temp = list[idx] list[idx] = list[idx - 1] list[idx - 1] = temp atomicState.actions = list state.updatedAt = now() } } catch (NumberFormatException e) { log.error "Invalid moveUp button index: ${btn}" } } else if (btn.startsWith("moveDown_")) { try { def idx = btn.replace("moveDown_", "").toInteger() def list = atomicState.actions ?: [] if (idx >= 0 && idx < list.size() - 1) { def temp = list[idx] list[idx] = list[idx + 1] list[idx + 1] = temp atomicState.actions = list state.updatedAt = now() } } catch (NumberFormatException e) { log.error "Invalid moveDown button index: ${btn}" } } } // ==================== DESCRIPTION HELPERS ==================== def describeTrigger(trigger) { switch (trigger.type) { case "device_event": def device = parent.findDevice(trigger.deviceId) def deviceName = device?.label ?: trigger.deviceId def valueMatch = trigger.value ? " ${trigger.operator ?: '=='} '${trigger.value}'" : "" def duration = "" if (trigger.duration) { // Use stored original unit/value if available, otherwise format seconds nicely if (trigger.durationValue && trigger.durationUnit) { def unitLabel = trigger.durationUnit == "seconds" ? "s" : (trigger.durationUnit == "minutes" ? "m" : "h") duration = " for ${trigger.durationValue}${unitLabel}" } else { // Legacy: just seconds duration = " for ${trigger.duration}s" } } return "When ${deviceName} ${trigger.attribute} changes${valueMatch}${duration}" case "button_event": def device = parent.findDevice(trigger.deviceId) def deviceName = device?.label ?: trigger.deviceId def btn = trigger.buttonNumber ? " button ${trigger.buttonNumber}" : "" return "When ${deviceName}${btn} is ${trigger.action}" case "time": if (trigger.time) return "At ${trigger.time}" if (trigger.sunrise) return "At sunrise${trigger.offset ? " ${trigger.offset > 0 ? '+' : ''}${trigger.offset}min" : ''}" if (trigger.sunset) return "At sunset${trigger.offset ? " ${trigger.offset > 0 ? '+' : ''}${trigger.offset}min" : ''}" return "Time trigger" case "periodic": return "Every ${trigger.interval} ${trigger.unit}" case "mode_change": def from = trigger.fromMode ? "from ${trigger.fromMode} " : "" def to = trigger.toMode ? "to ${trigger.toMode}" : "changes" return "When mode ${from}${to}" case "hsm_change": return trigger.status ? "When HSM becomes ${trigger.status}" : "When HSM changes" default: return "Unknown trigger: ${trigger.type}" } } /** * Formats a duration trigger for display in logs and messages. * Uses original unit/value if available, otherwise displays in seconds. */ def formatDurationForDisplay(trigger) { if (!trigger?.duration) return "" if (trigger.durationValue && trigger.durationUnit) { def unitLabel = trigger.durationUnit == "seconds" ? "s" : (trigger.durationUnit == "minutes" ? "m" : "h") return "${trigger.durationValue}${unitLabel}" } // Legacy: just seconds return "${trigger.duration}s" } def describeCondition(condition) { switch (condition.type) { case "device_state": def device = parent.findDevice(condition.deviceId) def deviceName = device?.label ?: condition.deviceId return "${deviceName} ${condition.attribute} ${condition.operator} '${condition.value}'" case "device_was": def device = parent.findDevice(condition.deviceId) def deviceName = device?.label ?: condition.deviceId return "${deviceName} ${condition.attribute} was '${condition.value}' for ${condition.forSeconds}s" case "time_range": // Support both 'start'/'end' (MCP format) and 'startTime'/'endTime' (UI format) for backwards compatibility def startTime = condition.start ?: condition.startTime def endTime = condition.end ?: condition.endTime return "Time is between ${startTime} and ${endTime}" case "mode": def op = condition.operator == "not_in" ? "is not" : "is" return "Mode ${op} ${condition.modes ? condition.modes.join(' or ') : '(none)'}" case "variable": return "Variable '${condition.variableName}' ${condition.operator} '${condition.value}'" case "days_of_week": return "Day is ${condition.days ? condition.days.join(', ') : '(none)'}" case "sun_position": return "Sun is ${condition.position}" case "hsm_status": return "HSM is ${condition.status}" case "presence": def presenceDevice = parent.findDevice(condition.deviceId) def presenceDeviceName = presenceDevice?.label ?: condition.deviceId return "${presenceDeviceName} is ${condition.status}" case "lock": def lockDevice = parent.findDevice(condition.deviceId) def lockDeviceName = lockDevice?.label ?: condition.deviceId return "${lockDeviceName} is ${condition.status}" case "thermostat_mode": def thermostatDevice = parent.findDevice(condition.deviceId) def thermostatDeviceName = thermostatDevice?.label ?: condition.deviceId return "${thermostatDeviceName} mode is ${condition.mode}" case "thermostat_state": def thermostatStateDevice = parent.findDevice(condition.deviceId) def thermostatStateDeviceName = thermostatStateDevice?.label ?: condition.deviceId return "${thermostatStateDeviceName} is ${condition.state}" case "illuminance": def illuminanceDevice = parent.findDevice(condition.deviceId) def illuminanceDeviceName = illuminanceDevice?.label ?: condition.deviceId return "${illuminanceDeviceName} illuminance ${condition.operator} ${condition.value} lux" case "power": def powerDevice = parent.findDevice(condition.deviceId) def powerDeviceName = powerDevice?.label ?: condition.deviceId return "${powerDeviceName} power ${condition.operator} ${condition.value}W" default: return "Unknown condition: ${condition.type}" } } def describeAction(action) { switch (action.type) { case "device_command": def device = parent.findDevice(action.deviceId) def deviceName = device?.label ?: action.deviceId def params = action.parameters ? "(${action.parameters.join(', ')})" : "" return "Send '${action.command}${params}' to ${deviceName}" case "toggle_device": def device = parent.findDevice(action.deviceId) def deviceName = device?.label ?: action.deviceId return "Toggle ${deviceName}" case "set_level": def device = parent.findDevice(action.deviceId) def deviceName = device?.label ?: action.deviceId def duration = action.duration ? " over ${action.duration}s" : "" return "Set ${deviceName} to ${action.level}%${duration}" case "set_mode": return "Set mode to ${action.mode}" case "set_hsm": return "Set HSM to ${action.status}" case "set_variable": return "Set variable '${action.variableName}' to '${action.value}'" case "delay": return "Wait ${action.seconds} seconds" case "log": return "Log [${action.level ?: 'info'}]: '${action.message}'" case "stop": return "Stop rule execution" case "if_then_else": def condDesc = action.condition ? describeCondition(action.condition) : "condition" def thenCount = action.thenActions?.size() ?: 0 def elseCount = action.elseActions?.size() ?: 0 return "If ${condDesc}: then ${thenCount} action(s)${elseCount > 0 ? ', else ' + elseCount + ' action(s)' : ''}" case "cancel_delayed": return action.delayId == "all" ? "Cancel all delayed actions" : "Cancel delayed '${action.delayId}'" case "set_local_variable": return "Set local variable '${action.variableName}' to '${action.value}'" case "activate_scene": def device = parent.findDevice(action.sceneDeviceId) def deviceName = device?.label ?: action.sceneDeviceId return "Activate scene ${deviceName}" case "set_color": def colorDev = parent.findDevice(action.deviceId) def colorDevName = colorDev?.label ?: action.deviceId return "Set ${colorDevName} color to hue:${action.hue}, sat:${action.saturation}, level:${action.level}" case "set_color_temperature": def ctDev = parent.findDevice(action.deviceId) def ctDevName = ctDev?.label ?: action.deviceId def ctLevel = action.level ? " at ${action.level}%" : "" return "Set ${ctDevName} color temperature to ${action.temperature}K${ctLevel}" case "lock": def lockDev = parent.findDevice(action.deviceId) def lockDevName = lockDev?.label ?: action.deviceId return "Lock ${lockDevName}" case "unlock": def unlockDev = parent.findDevice(action.deviceId) def unlockDevName = unlockDev?.label ?: action.deviceId return "Unlock ${unlockDevName}" case "capture_state": def captureCount = action.deviceIds?.size() ?: 0 def captureId = action.stateId ?: "default" return "Capture state of ${captureCount} device(s) (id: ${captureId})" case "restore_state": def restoreId = action.stateId ?: "default" return "Restore state (id: ${restoreId})" case "send_notification": def notifyDev = parent.findDevice(action.deviceId) def notifyDevName = notifyDev?.label ?: action.deviceId return "Send notification to ${notifyDevName}: '${action.message}'" case "repeat": def repeatActions = action.actions?.size() ?: 0 return "Repeat ${repeatActions} action(s) ${action.times ?: action.count ?: 1} time(s)" case "set_thermostat": def tstatDev = parent.findDevice(action.deviceId) def tstatDevName = tstatDev?.label ?: action.deviceId def tstatParts = [] if (action.thermostatMode) tstatParts << "mode:${action.thermostatMode}" if (action.heatingSetpoint != null) tstatParts << "heat:${action.heatingSetpoint}" if (action.coolingSetpoint != null) tstatParts << "cool:${action.coolingSetpoint}" if (action.fanMode) tstatParts << "fan:${action.fanMode}" return "Set thermostat ${tstatDevName} (${tstatParts.join(', ')})" case "http_request": return "${action.method ?: 'GET'} ${action.url}" case "speak": def speakDev = parent.findDevice(action.deviceId) def speakDevName = speakDev?.label ?: action.deviceId def volStr = action.volume != null ? " at volume ${action.volume}" : "" return "Speak '${action.message}' on ${speakDevName}${volStr}" case "comment": def truncated = action.text?.length() > 50 ? action.text.substring(0, 50) + "..." : action.text return "Comment: ${truncated}" case "set_valve": def valveDev = parent.findDevice(action.deviceId) def valveDevName = valveDev?.label ?: action.deviceId return "${action.command?.capitalize()} valve ${valveDevName}" case "set_fan_speed": def fanDev = parent.findDevice(action.deviceId) def fanDevName = fanDev?.label ?: action.deviceId return "Set ${fanDevName} fan speed to ${action.speed}" case "set_shade": def shadeDev = parent.findDevice(action.deviceId) def shadeDevName = shadeDev?.label ?: action.deviceId if (action.position != null) return "Set ${shadeDevName} shade position to ${action.position}%" return "${action.command?.capitalize()} shade ${shadeDevName}" case "variable_math": def mathScope = action.scope ?: "local" return "Variable math: ${mathScope} '${action.variableName}' ${action.operation} ${action.operand}" default: return "Unknown action: ${action.type}" } } // ==================== RULE EXECUTION ==================== def subscribeToTriggers() { def subscribedEvents = [] as Set atomicState.triggers?.each { trigger -> try { switch (trigger.type) { case "device_event": // Support multi-device triggers (deviceIds) and single device (deviceId) def deviceIdList = trigger.deviceIds ?: (trigger.deviceId ? [trigger.deviceId] : []) deviceIdList.each { devId -> def device = parent.findDevice(devId) if (device) { subscribe(device, trigger.attribute, "handleDeviceEvent") } else { ruleLog("warn", "Trigger subscription skipped: device not found (ID: ${devId})") } } break case "button_event": def device = parent.findDevice(trigger.deviceId) if (device) { subscribe(device, trigger.action, "handleButtonEvent") } else { ruleLog("warn", "Trigger subscription skipped: device not found (ID: ${trigger.deviceId})") } break case "time": if (trigger.time) { // trigger.time is "HH:mm" format — convert to cron expression for schedule() // schedule() only accepts cron strings or ISO 8601 date strings, not bare "HH:mm" def parts = trigger.time.split(":") if (parts.size() < 2) { ruleLog("error", "Invalid time format '${trigger.time}' - expected HH:mm") return } def cronTime = "0 ${parts[1]} ${parts[0]} ? * * *" schedule(cronTime, "handleTimeEvent") } else if (trigger.sunrise) { if (location.sunrise) { def offset = trigger.offset ?: 0 def sunriseDate = new Date(location.sunrise.time + (offset * 60000)) // If sunrise already passed today, schedule for tomorrow if (sunriseDate.time <= now()) { sunriseDate = new Date(sunriseDate.time + 86400000) } // Use distinct handler name so sunset runOnce doesn't overwrite this runOnce(sunriseDate, "handleSunriseEvent", [overwrite: true]) } else { ruleLog("warn", "Cannot schedule sunrise trigger: sunrise time not available for this location") } } else if (trigger.sunset) { if (location.sunset) { def offset = trigger.offset ?: 0 def sunsetDate = new Date(location.sunset.time + (offset * 60000)) // If sunset already passed today, schedule for tomorrow if (sunsetDate.time <= now()) { sunsetDate = new Date(sunsetDate.time + 86400000) } // Use distinct handler name so sunrise runOnce doesn't overwrite this runOnce(sunsetDate, "handleSunsetEvent", [overwrite: true]) } else { ruleLog("warn", "Cannot schedule sunset trigger: sunset time not available for this location") } } break case "mode_change": if (!subscribedEvents.contains("location:mode")) { subscribe(location, "mode", "handleModeEvent") subscribedEvents.add("location:mode") } break case "hsm_change": if (!subscribedEvents.contains("location:hsmStatus")) { subscribe(location, "hsmStatus", "handleHsmEvent") subscribedEvents.add("location:hsmStatus") } break case "periodic": def interval = trigger.interval ?: 1 def unit = trigger.unit ?: "minutes" def cronExpr switch (unit) { case "minutes": interval = Math.max(1, Math.min(interval as Integer, 59)) cronExpr = "0 */${interval} * ? * *" break case "hours": interval = Math.max(1, Math.min(interval as Integer, 23)) cronExpr = "0 0 */${interval} ? * *" break case "days": interval = Math.max(1, Math.min(interval as Integer, 31)) cronExpr = "0 0 0 */${interval} * ?" break default: ruleLog("warn", "Unknown periodic unit '${unit}', defaulting to minutes") interval = Math.max(1, Math.min(interval as Integer, 59)) cronExpr = "0 */${interval} * ? * *" } schedule(cronExpr, "handlePeriodicEvent") break } } catch (Exception e) { ruleLog("error", "Failed to subscribe to trigger (type=${trigger.type}): ${e.message}") } } } /** * Checks if a trigger matches a given device ID. * Supports both single-device (deviceId) and multi-device (deviceIds) triggers. */ def triggerMatchesDevice(trigger, deviceIdStr) { if (trigger.deviceIds) { return trigger.deviceIds.any { it.toString() == deviceIdStr } } return trigger.deviceId == deviceIdStr } /** * For multi-device "all" mode triggers, checks that ALL devices in the list * currently have the target attribute value. Returns true if all match. */ def checkAllDevicesMatch(trigger) { if (!trigger.deviceIds) return true return trigger.deviceIds.every { devId -> def device = parent.findDevice(devId.toString()) if (!device) return false def currentValue = device.currentValue(trigger.attribute) if (trigger.value == null) return true // No value constraint = any value is fine return evaluateComparison(currentValue, trigger.operator ?: "equals", trigger.value) } } /** * Evaluates a per-trigger condition gate. If the trigger has an inline "condition" * field, it must evaluate to true for the trigger to proceed. Returns true if * there is no condition or if the condition is met; false otherwise. */ def evaluateTriggerCondition(trigger, triggerSource) { if (!trigger?.condition) return true try { def result = evaluateCondition(trigger.condition) if (!result) { log.info "Rule '${settings.ruleName}' trigger (${triggerSource}) skipped: per-trigger condition not met (${describeCondition(trigger.condition)})" } return result } catch (Exception e) { ruleLog("error", "Error evaluating per-trigger condition for ${triggerSource}: ${e.message}") return false // Fail closed } } def handlePeriodicEvent() { if (!settings.ruleEnabled) return log.debug "Periodic event triggered" def matchingTrigger = atomicState.triggers?.find { t -> t.type == "periodic" } if (matchingTrigger) { if (!evaluateTriggerCondition(matchingTrigger, "periodic")) return executeRule("periodic") } } def handleDeviceEvent(evt) { if (!settings.ruleEnabled) return log.debug "Device event: ${evt.device.label} ${evt.name} = ${evt.value}" def evtDeviceId = evt.device.id.toString() def matchingTrigger = atomicState.triggers?.find { t -> t.type == "device_event" && t.attribute == evt.name && triggerMatchesDevice(t, evtDeviceId) && (t.value == null || evaluateComparison(evt.value, t.operator ?: "equals", t.value)) } if (matchingTrigger) { // For "all" matchMode, verify ALL devices in the list currently match the target state if (matchingTrigger.matchMode == "all" && matchingTrigger.deviceIds) { def allMatch = checkAllDevicesMatch(matchingTrigger) if (!allMatch) { log.debug "Multi-device trigger (all mode): not all devices match, skipping" return } } // Check per-trigger condition gate before proceeding if (!evaluateTriggerCondition(matchingTrigger, "device_event: ${evt.device.label} ${evt.name}")) return // Check if this trigger has a duration requirement if (matchingTrigger.duration && matchingTrigger.duration > 0) { def triggerDeviceKey = matchingTrigger.deviceId ?: (matchingTrigger.deviceIds?.sort()?.join("_") ?: "unknown") def triggerKey = "duration_${triggerDeviceKey}_${matchingTrigger.attribute}" // Initialize state maps if needed if (!atomicState.durationTimers) atomicState.durationTimers = [:] if (!atomicState.durationFired) atomicState.durationFired = [:] // Check if this trigger already fired and is waiting for condition to go false def firedMap = atomicState.durationFired ?: [:] if (firedMap[triggerKey]) { log.debug "Duration trigger: already fired, waiting for condition to go false before re-arming" return } def timers = atomicState.durationTimers ?: [:] if (!timers[triggerKey]) { // First time condition met - start the timer def durationDisplay = formatDurationForDisplay(matchingTrigger) log.debug "Duration trigger: condition met, starting ${durationDisplay} timer for ${evt.device.label} ${evt.name}" timers[triggerKey] = [startTime: now(), trigger: matchingTrigger] atomicState.durationTimers = timers runIn(matchingTrigger.duration, "checkDurationTrigger", [data: [triggerKey: triggerKey, deviceLabel: evt.device.label, attribute: evt.name]]) } // If timer already running, just let it continue } else { // No duration - trigger immediately executeRule("device_event: ${evt.device.label} ${evt.name}", evt) } } else { // Condition no longer met - cancel any pending duration timer and reset fired state def triggersForDevice = atomicState.triggers?.findAll { t -> t.type == "device_event" && triggerMatchesDevice(t, evtDeviceId) && t.attribute == evt.name && t.duration && t.duration > 0 } def timers = atomicState.durationTimers ?: [:] def fired = atomicState.durationFired ?: [:] def timersChanged = false def firedChanged = false triggersForDevice?.each { t -> def tDeviceKey = t.deviceId ?: (t.deviceIds?.sort()?.join("_") ?: "unknown") def triggerKey = "duration_${tDeviceKey}_${t.attribute}" if (timers.get(triggerKey)) { log.debug "Duration trigger: condition no longer met, canceling timer for ${evt.device.label} ${evt.name}" timers.remove(triggerKey) timersChanged = true // Note: We don't call unschedule("checkDurationTrigger") here because: // 1. It would cancel ALL duration trigger timers, not just this one // 2. checkDurationTrigger already handles missing timer data gracefully } // Reset the fired flag so it can trigger again next time condition is met if (fired.get(triggerKey)) { log.debug "Duration trigger: condition false, re-arming trigger for ${evt.device.label} ${evt.name}" fired.remove(triggerKey) firedChanged = true } } if (timersChanged) atomicState.durationTimers = timers if (firedChanged) atomicState.durationFired = fired } } def checkDurationTrigger(data) { def triggerKey = data.triggerKey def timers = atomicState.durationTimers ?: [:] def timerData = timers.get(triggerKey) if (!timerData) { log.debug "Duration trigger: timer was canceled for ${triggerKey}" return } // Re-check that the condition is still met def trigger = timerData.trigger def stillMet = false if (trigger.deviceIds) { // Multi-device trigger: check based on matchMode if (trigger.matchMode == "all") { stillMet = checkAllDevicesMatch(trigger) } else { // "any" mode: at least one device still matches stillMet = trigger.deviceIds.any { devId -> def dev = parent.findDevice(devId.toString()) if (!dev) return false def val = dev.currentValue(trigger.attribute) return trigger.value == null || evaluateComparison(val, trigger.operator ?: "equals", trigger.value) } } } else { def device = parent.findDevice(trigger.deviceId) if (!device) { timers.remove(triggerKey) atomicState.durationTimers = timers return } def currentValue = device.currentValue(trigger.attribute) stillMet = trigger.value == null || evaluateComparison(currentValue, trigger.operator ?: "equals", trigger.value) } if (stillMet) { def durationDisplay = formatDurationForDisplay(trigger) log.debug "Duration trigger: condition still met after ${durationDisplay}, executing rule" timers.remove(triggerKey) atomicState.durationTimers = timers // Mark as fired - won't fire again until condition goes false def fired = atomicState.durationFired ?: [:] fired[triggerKey] = true atomicState.durationFired = fired executeRule("device_event: ${data.deviceLabel} ${data.attribute} (held for ${durationDisplay})") } else { log.debug "Duration trigger: condition no longer met at check time" timers.remove(triggerKey) atomicState.durationTimers = timers } } def handleButtonEvent(evt) { if (!settings.ruleEnabled) return log.debug "Button event: ${evt.device.label} ${evt.name} = ${evt.value}" def matchingTrigger = atomicState.triggers?.find { t -> t.type == "button_event" && t.deviceId == evt.device.id.toString() && t.action == evt.name && (t.buttonNumber == null || t.buttonNumber.toString() == evt.value) } if (matchingTrigger) { if (!evaluateTriggerCondition(matchingTrigger, "button_event: ${evt.device.label} ${evt.name}")) return executeRule("button_event: ${evt.device.label} ${evt.name}", evt) } } def handleTimeEvent() { if (!settings.ruleEnabled) return def matchingTrigger = atomicState.triggers?.find { t -> t.type == "time" && t.time } if (!matchingTrigger) return if (!evaluateTriggerCondition(matchingTrigger, "time trigger")) return executeRule("time trigger") } def handleSunriseEvent() { if (!settings.ruleEnabled) return // Re-schedule this sunrise trigger for the next day (runOnce only fires once) rescheduleSunriseTrigger() def matchingTrigger = atomicState.triggers?.find { t -> t.type == "time" && t.sunrise } if (!matchingTrigger) return if (!evaluateTriggerCondition(matchingTrigger, "sunrise trigger")) return executeRule("sunrise trigger") } def handleSunsetEvent() { if (!settings.ruleEnabled) return // Re-schedule this sunset trigger for the next day (runOnce only fires once) rescheduleSunsetTrigger() def matchingTrigger = atomicState.triggers?.find { t -> t.type == "time" && t.sunset } if (!matchingTrigger) return if (!evaluateTriggerCondition(matchingTrigger, "sunset trigger")) return executeRule("sunset trigger") } private void rescheduleSunTrigger(String sunType, String handlerName) { atomicState.triggers?.findAll { it.type == "time" && it."${sunType}" }?.each { trigger -> try { // Use getSunriseAndSunset() for accurate next-day times (avoids drift from +24h) def tomorrow = new Date(now() + 86400000) def sunTimes = getSunriseAndSunset(date: tomorrow) def sunTime = sunTimes?."${sunType}" ?: location."${sunType}" if (sunTime) { def offset = trigger.offset ?: 0 def sunDate = new Date(sunTime.time + (offset * 60000)) // Safety: if calculated time is still in the past, fall back to +24h from now if (sunDate.time <= now()) { sunDate = new Date(now() + 86400000) } runOnce(sunDate, handlerName, [overwrite: true]) } } catch (Exception e) { ruleLog("error", "Failed to reschedule ${sunType} trigger: ${e.message}") } } } def rescheduleSunriseTrigger() { rescheduleSunTrigger("sunrise", "handleSunriseEvent") } def rescheduleSunsetTrigger() { rescheduleSunTrigger("sunset", "handleSunsetEvent") } def handleModeEvent(evt) { if (!settings.ruleEnabled) return def matchingTrigger = atomicState.triggers?.find { t -> t.type == "mode_change" && (!t.toMode || t.toMode == evt.value) && (!t.fromMode || t.fromMode == state.previousMode) } state.previousMode = evt.value if (matchingTrigger) { if (!evaluateTriggerCondition(matchingTrigger, "mode_change: ${evt.value}")) return executeRule("mode_change: ${evt.value}", evt) } } def handleHsmEvent(evt) { if (!settings.ruleEnabled) return def matchingTrigger = atomicState.triggers?.find { t -> t.type == "hsm_change" && (!t.status || t.status == evt.value) } if (matchingTrigger) { if (!evaluateTriggerCondition(matchingTrigger, "hsm_change: ${evt.value}")) return executeRule("hsm_change: ${evt.value}", evt) } } def executeRule(triggerSource, evt = null) { // Execution loop guard — prevents infinite event loops // (e.g., rule triggers on "Switch A on" with action "Turn on Switch A") // Thresholds configurable via parent app settings; defaults: 30 executions / 60 seconds def loopGuardMax = (parent?.settings?.loopGuardMax ?: 30) as Integer def loopGuardWindow = ((parent?.settings?.loopGuardWindowSec ?: 60) as Integer) * 1000 def currentTime = now() def recentExecs = atomicState.recentExecutions ?: [] // Prune entries outside the sliding window recentExecs = recentExecs.findAll { it > (currentTime - loopGuardWindow) } if (recentExecs.size() >= loopGuardMax) { def msg = "Rule '${settings.ruleName}' auto-disabled: ${recentExecs.size()} executions in ${loopGuardWindow / 1000}s — possible infinite loop." log.warn msg ruleLog("warn", "Execution loop detected (${recentExecs.size()} runs in ${loopGuardWindow / 1000}s). Rule auto-disabled to protect hub stability.") app.updateSetting("ruleEnabled", false) unsubscribe() unschedule() atomicState.recentExecutions = [] notifyLoopGuard(msg) return } recentExecs << currentTime atomicState.recentExecutions = recentExecs log.info "Rule '${settings.ruleName}' triggered by ${triggerSource}" // Check conditions if (atomicState.conditions && atomicState.conditions.size() > 0) { def conditionsMet = evaluateConditions() if (!conditionsMet) { log.info "Rule '${settings.ruleName}' conditions not met, skipping actions" return } } // Execute actions state.lastTriggered = now() state.executionCount = (state.executionCount ?: 0) + 1 executeActions(evt) } def evaluateConditions() { def logic = settings.conditionLogic ?: "all" def conditions = atomicState.conditions ?: [] // Short-circuit: stop evaluating as soon as outcome is determined def safeEval = { condition -> try { evaluateCondition(condition) } catch (Exception e) { ruleLog("error", "Error evaluating condition (${condition.type}): ${e.message}") false // Treat failed conditions as not met (fail closed) } } if (logic == "all") { return conditions.every(safeEval) } else { return conditions.any(safeEval) } } /** * Parse a time string into a Date object. Handles bare "HH:mm" format (which toDateTime() rejects) * by constructing today's date, as well as full ISO 8601 strings via toDateTime(). */ private Date parseTimeString(timeStr) { if (!timeStr) throw new IllegalArgumentException("Time string is null or empty") def s = timeStr.toString() // Bare HH:mm format — construct today's date with that time if (s =~ /^\d{1,2}:\d{2}$/) { def parts = s.split(":") def cal = Calendar.getInstance() cal.set(Calendar.HOUR_OF_DAY, parts[0] as Integer) cal.set(Calendar.MINUTE, parts[1] as Integer) cal.set(Calendar.SECOND, 0) cal.set(Calendar.MILLISECOND, 0) return cal.time } // Full date/time string — delegate to toDateTime() return toDateTime(s) } def evaluateCondition(condition) { switch (condition.type) { case "device_state": def device = parent.findDevice(condition.deviceId) if (!device) return false def currentValue = device.currentValue(condition.attribute) return evaluateComparison(currentValue, condition.operator, condition.value) case "mode": def currentMode = location.mode // Accept both singular 'mode' (string) and plural 'modes' (list) def modeList = condition.modes ?: (condition.mode ? [condition.mode] : []) def inModes = modeList.contains(currentMode) return condition.operator == "not_in" ? !inModes : inModes case "time_range": // Support both 'start'/'end' (MCP format) and 'startTime'/'endTime' (UI format) for backwards compatibility def startTime = condition.start ?: condition.startTime def endTime = condition.end ?: condition.endTime try { // toDateTime() requires ISO 8601 — bare "HH:mm" strings must be converted to today's date def startDate = parseTimeString(startTime) def endDate = parseTimeString(endTime) return timeOfDayIsBetween(startDate, endDate, new Date()) } catch (Exception e) { ruleLog("warn", "time_range condition failed to parse times (start=${startTime}, end=${endTime}): ${e.message}") return false } case "days_of_week": def today = new Date().format("EEEE") return condition.days ? condition.days.contains(today) : false case "sun_position": def sunriseTime = location.sunrise def sunsetTime = location.sunset if (!sunriseTime || !sunsetTime) { ruleLog("warn", "Cannot evaluate sun_position: sunrise/sunset times not available for this location") return false } def currentTime = new Date() def isSunUp = currentTime.after(sunriseTime) && currentTime.before(sunsetTime) return condition.position == "up" ? isSunUp : !isSunUp case "hsm_status": return location.hsmStatus == condition.status case "variable": // Check local variables first, then global def varValue = atomicState.localVariables?."${condition.variableName}" if (varValue == null) { // Try global variable from parent varValue = parent.getVariableValue(condition.variableName) } return evaluateComparison(varValue, condition.operator, condition.value) case "device_was": def device = parent.findDevice(condition.deviceId) if (!device) return false if (condition.forSeconds == null) return false def forSeconds = Math.max(1, condition.forSeconds as Integer) def currentValue = device.currentValue(condition.attribute) if (currentValue?.toString() != condition.value?.toString()) return false // Check how long it's been in this state — filter by attribute to avoid // chatty devices exhausting the event limit with irrelevant attributes. // Add 2-second margin to account for event timestamp vs wall-clock differences def lookbackMs = (forSeconds * 1000L) + 2000L def events = device.eventsSince(new Date(now() - lookbackMs), [max: 100]) ?.findAll { it.name == condition.attribute } def recentChange = events?.find { it.value?.toString() != condition.value?.toString() } return recentChange == null case "presence": def device = parent.findDevice(condition.deviceId) if (!device) return false def currentPresence = device.currentValue("presence") return currentPresence == condition.status case "lock": def device = parent.findDevice(condition.deviceId) if (!device) return false def currentLock = device.currentValue("lock") return currentLock == condition.status case "thermostat_mode": def device = parent.findDevice(condition.deviceId) if (!device) return false def currentMode = device.currentValue("thermostatMode") return currentMode == condition.mode case "thermostat_state": def device = parent.findDevice(condition.deviceId) if (!device) return false def currentState = device.currentValue("thermostatOperatingState") return currentState == condition.state case "illuminance": def device = parent.findDevice(condition.deviceId) if (!device) return false def currentLux = device.currentValue("illuminance") return evaluateComparison(currentLux, condition.operator, condition.value) case "power": def device = parent.findDevice(condition.deviceId) if (!device) return false def currentPower = device.currentValue("power") return evaluateComparison(currentPower, condition.operator, condition.value) // Note: "expression" condition type removed - Eval.me() not allowed in Hubitat sandbox default: ruleLog("warn", "Unknown condition type: ${condition.type} — treating as not met (fail closed)") return false } } def evaluateComparison(current, operator, target) { // Null-safe: if current is null, only equality checks are meaningful if (current == null) { switch (operator) { case "equals": case "==": return target == null || target?.toString() == "null" case "not_equals": case "!=": return target != null && target?.toString() != "null" default: // Numeric comparisons with null are always false (fail closed) return false } } try { switch (operator) { case "equals": case "==": return current.toString() == target?.toString() case "not_equals": case "!=": return current.toString() != target?.toString() case ">": return current.toBigDecimal() > target?.toBigDecimal() case "<": return current.toBigDecimal() < target?.toBigDecimal() case ">=": return current.toBigDecimal() >= target?.toBigDecimal() case "<=": return current.toBigDecimal() <= target?.toBigDecimal() default: return current.toString() == target?.toString() } } catch (Exception e) { // Numeric conversion failed — fall back to string comparison return current.toString() == target?.toString() } } /** * Substitutes %variableName% placeholders in text with actual variable values. * Supports built-in event variables (%device%, %value%, %name%, %time%, %date%), * time variables (%now%), hub variables (%mode%), local rule variables, and global hub variables. */ def substituteVariables(String text, evt = null) { if (!text) return text def result = text def currentDate = new Date() // Built-in event variables if (evt) { result = result.replace("%device%", evt.displayName ?: "") result = result.replace("%value%", evt.value?.toString() ?: "") result = result.replace("%name%", evt.name ?: "") result = result.replace("%time%", currentDate.format("HH:mm:ss")) result = result.replace("%date%", currentDate.format("yyyy-MM-dd")) } result = result.replace("%now%", currentDate.format("yyyy-MM-dd HH:mm:ss")) result = result.replace("%mode%", location.mode ?: "") // Local variables def locals = atomicState.localVariables ?: [:] locals.each { name, value -> result = result.replace("%${name}%", value?.toString() ?: "") } // Global hub variables and rule engine variables (via parent.getVariableValue) def varPattern = /%([^%]+)%/ def matcher = result =~ varPattern while (matcher.find()) { def varName = matcher.group(1) try { def hubVar = getGlobalVar(varName) if (hubVar != null) { result = result.replace("%${varName}%", hubVar.value?.toString() ?: "") } else { // Fall back to rule engine variables managed by the parent server def ruleVar = parent.getVariableValue(varName) if (ruleVar != null) { result = result.replace("%${varName}%", ruleVar.toString()) } } } catch (e) { // Variable not found, leave placeholder } } return result } def executeActions(evt = null) { executeActionsFromIndex(0, evt) } def executeActionsFromIndex(startIndex, evt = null) { def actions = atomicState.actions ?: [] for (int i = startIndex; i < actions.size(); i++) { def action = actions[i] def result = executeAction(action, i, evt) if (result == false) { break // Stop if action returns false (e.g., stop action) } else if (result == "delayed") { break // Delay scheduled, will resume later } } } def resumeDelayedActions(data) { // Check if this specific delay was cancelled def cancelledIds = atomicState.cancelledDelayIds ?: [:] if (data.delayId && cancelledIds.containsKey(data.delayId)) { log.debug "Delay '${data.delayId}' was cancelled, skipping execution" cancelledIds.remove(data.delayId) atomicState.cancelledDelayIds = cancelledIds return } log.debug "Resuming actions from index ${data.nextIndex} (delayId: ${data.delayId})" // Reconstruct a pseudo-event from serialized fields so %device%/%value%/%name% substitutions work def pseudoEvt = null if (data.evtDisplayName || data.evtValue || data.evtName) { pseudoEvt = [displayName: data.evtDisplayName, value: data.evtValue, name: data.evtName] } executeActionsFromIndex(data.nextIndex, pseudoEvt) } def executeAction(action, actionIndex = null, evt = null) { log.debug "Executing action: ${describeAction(action)}" try { switch (action.type) { case "device_command": def device = parent.findDevice(action.deviceId) if (device) { try { if (action.parameters) { def params = action.parameters // Ensure params is a List (may arrive as JSON string) if (params instanceof String) { try { def parsed = new groovy.json.JsonSlurper().parseText(params) params = (parsed instanceof List) ? parsed : [parsed] } catch (Exception e) { params = [params] } } def convertedParams = params.collect { param -> def s = param.toString() if (s.isNumber()) { return s.contains(".") ? s.toDouble() : s.toInteger() } // Parse JSON strings into Maps/Lists (e.g., setColor map parameter) if (param instanceof String && (s.startsWith("{") || s.startsWith("["))) { try { return new groovy.json.JsonSlurper().parseText(s) } catch (Exception e) { // Not valid JSON, pass as string } } return param } device."${action.command}"(*convertedParams) } else { device."${action.command}"() } } catch (Exception e) { ruleLog("error", "Error executing command '${action.command}' on device ${device.label}: ${e.message}") } } else { ruleLog("warn", "Action 'device_command' skipped: device not found (ID: ${action.deviceId})") } break case "toggle_device": def device = parent.findDevice(action.deviceId) if (device) { if (device.currentValue("switch") == "on") { device.off() } else { device.on() } } else { ruleLog("warn", "Action 'toggle_device' skipped: device not found (ID: ${action.deviceId})") } break case "set_level": def device = parent.findDevice(action.deviceId) if (device) { def level = clampPercent((action.level as Integer) ?: 0) if (action.duration) { device.setLevel(level, action.duration) } else { device.setLevel(level) } } else { ruleLog("warn", "Action 'set_level' skipped: device not found (ID: ${action.deviceId})") } break case "set_mode": if (!action.mode) { ruleLog("error", "set_mode action missing 'mode' value") return false } location.setMode(action.mode) break case "set_hsm": if (!action.status) { ruleLog("error", "set_hsm action missing 'status' value") return false } sendLocationEvent(name: "hsmSetArm", value: action.status) break case "set_variable": parent.setRuleVariable(action.variableName, substituteVariables(action.value?.toString() ?: "", evt)) break case "delay": if (actionIndex != null) { def delaySeconds = Math.max(1, Math.min(86400, (action.seconds as Integer) ?: 1)) // 1s to 24h max def delayId = action.delayId ?: "delay_${now()}" def handlerName = "resumeDelayedActions" log.debug "Scheduling delayed continuation in ${delaySeconds} seconds (delayId: ${delayId})" // Serialize key event fields so %device%/%value% substitutions work after delay def delayData = [nextIndex: actionIndex + 1, delayId: delayId] if (evt) { delayData.evtDisplayName = evt.displayName ?: "" delayData.evtValue = evt.value?.toString() ?: "" delayData.evtName = evt.name ?: "" } runIn(delaySeconds, handlerName, [data: delayData, overwrite: false]) return "delayed" // Signal to stop current execution, will resume via scheduled handler } else { ruleLog("warn", "Delay action skipped: delays inside if_then_else or repeat blocks are not supported (no actionIndex context)") } break case "log": def logMsg = substituteVariables(action.message, evt) switch (action.level) { case "warn": log.warn logMsg; break case "debug": log.debug logMsg; break default: log.info logMsg } break case "if_then_else": if (!action.condition) { ruleLog("warn", "if_then_else action has no condition, skipping") break } def conditionResult = evaluateCondition(action.condition) log.debug "if_then_else condition result: ${conditionResult}" if (conditionResult) { def thenList = action.thenActions ?: [] for (int i = 0; i < thenList.size(); i++) { if (!executeAction(thenList[i], null, evt)) return false } } else if (action.elseActions) { def elseList = action.elseActions for (int i = 0; i < elseList.size(); i++) { if (!executeAction(elseList[i], null, evt)) return false } } break case "cancel_delayed": if (action.delayId == "all") { // Cancel all pending delayed actions unschedule("resumeDelayedActions") atomicState.cancelledDelayIds = [:] // Clear cancelled IDs since we cancelled everything log.debug "Cancelled all delayed actions" } else if (action.delayId) { // Mark this specific delay ID as cancelled - will be checked in resumeDelayedActions def cancelIds = atomicState.cancelledDelayIds ?: [:] cancelIds[action.delayId] = true atomicState.cancelledDelayIds = cancelIds log.debug "Marked delay '${action.delayId}' for cancellation" } break case "set_local_variable": def vars = atomicState.localVariables ?: [:] vars[action.variableName] = substituteVariables(action.value?.toString() ?: "", evt) atomicState.localVariables = vars break case "activate_scene": def sceneDevice = parent.findDevice(action.sceneDeviceId) if (sceneDevice) { sceneDevice.on() } else { ruleLog("warn", "Action 'activate_scene' skipped: device not found (ID: ${action.sceneDeviceId})") } break case "set_color": def colorDevice = parent.findDevice(action.deviceId) if (colorDevice) { def colorMap = [:] if (action.hue != null) colorMap.hue = clampPercent(action.hue) if (action.saturation != null) colorMap.saturation = clampPercent(action.saturation) if (action.level != null) colorMap.level = clampPercent(action.level) colorDevice.setColor(colorMap) } else { ruleLog("warn", "Action 'set_color' skipped: device not found (ID: ${action.deviceId})") } break case "set_color_temperature": def ctDevice = parent.findDevice(action.deviceId) if (ctDevice) { if (action.level != null) { ctDevice.setColorTemperature(action.temperature, action.level) } else { ctDevice.setColorTemperature(action.temperature) } } else { ruleLog("warn", "Action 'set_color_temperature' skipped: device not found (ID: ${action.deviceId})") } break case "lock": def lockDevice = parent.findDevice(action.deviceId) if (lockDevice) { lockDevice.lock() } else { ruleLog("warn", "Action 'lock' skipped: device not found (ID: ${action.deviceId})") } break case "unlock": def unlockDevice = parent.findDevice(action.deviceId) if (unlockDevice) { unlockDevice.unlock() } else { ruleLog("warn", "Action 'unlock' skipped: device not found (ID: ${action.deviceId})") } break case "capture_state": def captureDevices = action.deviceIds?.collect { parent.findDevice(it) }?.findAll { it != null } if (captureDevices) { def capturedStates = [:] captureDevices.each { dev -> def devState = [:] if (dev.hasCapability("Switch")) devState.switch = dev.currentValue("switch") if (dev.hasCapability("SwitchLevel")) devState.level = dev.currentValue("level") if (dev.hasCapability("ColorControl")) { devState.hue = dev.currentValue("hue") devState.saturation = dev.currentValue("saturation") } if (dev.hasCapability("ColorTemperature")) devState.colorTemperature = dev.currentValue("colorTemperature") capturedStates[dev.id.toString()] = devState } def stateKey = action.stateId ?: "default" // Store in parent app so other rules can access it def saveResult = parent.saveCapturedState(stateKey, capturedStates) log.debug "Captured states for ${captureDevices.size()} devices (stateId: ${stateKey}, total: ${saveResult?.totalStored}/${saveResult?.maxLimit})" // Log warnings about capacity if (saveResult?.deletedStates) { ruleLog("warn", "Captured state limit reached: Deleted old state(s) '${saveResult.deletedStates.join(', ')}' to make room") } if (saveResult?.nearLimit) { ruleLog("warn", "Captured states nearing limit: ${saveResult.totalStored}/${saveResult.maxLimit} slots used") } } break case "restore_state": def stateKey = action.stateId ?: "default" // Get from parent app so any rule can restore states captured by any other rule def savedStates = parent.getCapturedState(stateKey) if (savedStates) { savedStates.each { deviceId, devState -> def dev = parent.findDevice(deviceId) if (dev) { // If restoring to "off", just turn off — don't set level/color first (causes flash) if (devState.switch == "off") { dev.off() } else { // Restore color/level attributes before turning on if (devState.hue != null && devState.saturation != null) { dev.setColor([hue: devState.hue, saturation: devState.saturation, level: devState.level ?: 100]) } else if (devState.colorTemperature != null) { dev.setColorTemperature(devState.colorTemperature) } if (devState.level != null && devState.hue == null) { dev.setLevel(devState.level) } if (devState.switch == "on") { dev.on() } } } } log.debug "Restored states for ${savedStates.size()} devices (stateId: ${stateKey})" } else { ruleLog("warn", "No captured state found for stateId: ${stateKey}") } break case "send_notification": def notifyDevice = parent.findDevice(action.deviceId) if (notifyDevice) { notifyDevice.deviceNotification(substituteVariables(action.message, evt)) } else { ruleLog("warn", "Action 'send_notification' skipped: device not found (ID: ${action.deviceId})") } break case "repeat": def repeatCount = Math.max(1, Math.min(100, (action.times ?: action.count ?: 1) as Integer)) // 1 to 100 max def repeatActions = action.actions ?: [] for (int r = 0; r < repeatCount; r++) { for (int i = 0; i < repeatActions.size(); i++) { if (!executeAction(repeatActions[i], null, evt)) return false } } break case "stop": return false // Signal to stop execution case "set_thermostat": def tstatDevice = parent.findDevice(action.deviceId) if (tstatDevice) { try { if (action.thermostatMode) tstatDevice.setThermostatMode(action.thermostatMode) if (action.heatingSetpoint != null) tstatDevice.setHeatingSetpoint(action.heatingSetpoint) if (action.coolingSetpoint != null) tstatDevice.setCoolingSetpoint(action.coolingSetpoint) if (action.fanMode) tstatDevice.setThermostatFanMode(action.fanMode) } catch (Exception e) { ruleLog("error", "Error setting thermostat ${tstatDevice.label}: ${e.message}") } } else { ruleLog("warn", "Action 'set_thermostat' skipped: device not found (ID: ${action.deviceId})") } break case "http_request": try { def method = action.method ?: "GET" if (method == "GET") { httpGet([uri: action.url]) { resp -> log.debug "HTTP GET ${action.url} returned status ${resp.status}" } } else if (method == "POST") { def params = [uri: action.url] if (action.contentType) params.contentType = action.contentType if (action.body) params.body = action.body httpPost(params) { resp -> log.debug "HTTP POST ${action.url} returned status ${resp.status}" } } } catch (Exception e) { ruleLog("error", "Error executing HTTP ${action.method ?: 'GET'} to ${action.url}: ${e.message}") } break case "speak": def speakDevice = parent.findDevice(action.deviceId) if (speakDevice) { try { if (action.volume != null) speakDevice.setVolume(action.volume) speakDevice.speak(substituteVariables(action.message, evt)) } catch (Exception e) { ruleLog("error", "Error speaking on ${speakDevice.label}: ${e.message}") } } else { ruleLog("warn", "Action 'speak' skipped: device not found (ID: ${action.deviceId})") } break case "comment": log.info "Comment: ${action.text}" break case "set_valve": def valveDevice = parent.findDevice(action.deviceId) if (valveDevice) { try { if (action.command == "open") { valveDevice.open() } else if (action.command == "close") { valveDevice.close() } } catch (Exception e) { ruleLog("error", "Error setting valve ${valveDevice.label}: ${e.message}") } } else { ruleLog("warn", "Action 'set_valve' skipped: device not found (ID: ${action.deviceId})") } break case "set_fan_speed": def fanDevice = parent.findDevice(action.deviceId) if (fanDevice) { try { fanDevice.setSpeed(action.speed) } catch (Exception e) { ruleLog("error", "Error setting fan speed on ${fanDevice.label}: ${e.message}") } } else { ruleLog("warn", "Action 'set_fan_speed' skipped: device not found (ID: ${action.deviceId})") } break case "set_shade": def shadeDevice = parent.findDevice(action.deviceId) if (shadeDevice) { try { if (action.position != null) { shadeDevice.setPosition(action.position) } else if (action.command == "open") { shadeDevice.open() } else if (action.command == "close") { shadeDevice.close() } } catch (Exception e) { ruleLog("error", "Error setting shade ${shadeDevice.label}: ${e.message}") } } else { ruleLog("warn", "Action 'set_shade' skipped: device not found (ID: ${action.deviceId})") } break case "variable_math": def varName = action.variableName def scope = action.scope ?: "local" def currentVal = 0 def locals = null if (scope == "local") { locals = atomicState.localVariables ?: [:] currentVal = locals[varName] ?: 0 } else { // Global hub variable def hubVar = getGlobalVar(varName) currentVal = hubVar?.value ?: 0 } // Ensure numeric currentVal = currentVal instanceof Number ? currentVal : (currentVal?.toString()?.isNumber() ? currentVal.toString().toBigDecimal() : 0) def operand = action.operand instanceof Number ? action.operand : action.operand?.toString()?.toBigDecimal() ?: 0 def mathResult switch (action.operation) { case "add": mathResult = currentVal + operand; break case "subtract": mathResult = currentVal - operand; break case "multiply": mathResult = currentVal * operand; break case "divide": mathResult = operand != 0 ? currentVal / operand : currentVal; break case "modulo": mathResult = operand != 0 ? currentVal % operand : currentVal; break case "set": mathResult = operand; break default: mathResult = currentVal } if (scope == "local") { locals[varName] = mathResult atomicState.localVariables = locals } else { setGlobalVar(varName, mathResult) } break default: ruleLog("warn", "Unknown action type '${action.type}', skipping") break } } catch (Exception e) { ruleLog("error", "Unhandled error in action '${action.type}': ${e.message}") } return true // Continue execution } def testRule() { log.info "Testing rule '${settings.ruleName}' (dry run)" def results = [ ruleName: settings.ruleName, conditionsMet: true, conditionResults: [], wouldExecute: true, actions: [] ] if (atomicState.conditions && atomicState.conditions.size() > 0) { atomicState.conditions.each { condition -> def result = evaluateCondition(condition) results.conditionResults << [ condition: describeCondition(condition), result: result ] } def logic = settings.conditionLogic ?: "all" if (logic == "all") { results.conditionsMet = results.conditionResults.every { it.result } } else { results.conditionsMet = results.conditionResults.any { it.result } } results.wouldExecute = results.conditionsMet } if (results.wouldExecute) { results.actions = atomicState.actions?.collect { describeAction(it) } ?: [] } log.info "Test results: ${results}" return results } def formatTimestamp(timestamp) { if (!timestamp) return "Never" try { return new Date(timestamp).format("yyyy-MM-dd HH:mm:ss") } catch (Exception e) { return timestamp.toString() } } /** Clamp an integer value to 0-100 range (for percentages like level, hue, saturation). */ private int clampPercent(value) { return Math.max(0, Math.min(100, value as Integer)) } // ==================== API FOR PARENT ==================== def getRuleData() { return [ id: app.id.toString(), name: settings.ruleName, description: settings.ruleDescription, enabled: settings.ruleEnabled ?: false, testRule: atomicState.testRule ?: false, // Test rules skip backup on deletion triggers: atomicState.triggers ?: [], conditions: atomicState.conditions ?: [], conditionLogic: settings.conditionLogic ?: "all", actions: atomicState.actions ?: [], localVariables: atomicState.localVariables ?: [:], createdAt: state.createdAt, updatedAt: state.updatedAt, lastTriggered: state.lastTriggered, executionCount: state.executionCount ?: 0 ] } def updateRuleFromParent(data) { // CRITICAL FIX v0.2.2: Use atomicState for immediate persistence // Regular state is only persisted when app execution ends, but atomicState // persists immediately. When enabled=true, updateSetting triggers lifecycle // methods that may start a new execution context which reads stale state // from database. atomicState ensures data is persisted before any lifecycle // methods can run. log.debug "updateRuleFromParent: Received ${data.triggers?.size() ?: 0} triggers, ${data.conditions?.size() ?: 0} conditions, ${data.actions?.size() ?: 0} actions (enabled=${data.enabled})" // Step 1: Store all rule data using atomicState (persists immediately to database) if (data.triggers != null) atomicState.triggers = data.triggers if (data.conditions != null) atomicState.conditions = data.conditions if (data.actions != null) atomicState.actions = data.actions if (data.localVariables != null) atomicState.localVariables = data.localVariables if (data.testRule != null) atomicState.testRule = data.testRule // Test rules skip backup on deletion state.updatedAt = now() log.debug "updateRuleFromParent: atomicState now has ${atomicState.triggers?.size() ?: 0} triggers, ${atomicState.actions?.size() ?: 0} actions" // Step 2: NOW update settings (these may trigger updated() lifecycle) if (data.name != null) { app.updateSetting("ruleName", data.name) // Update the app label to match (for display in Apps list) app.updateLabel(data.name) } if (data.description != null) app.updateSetting("ruleDescription", data.description) if (data.conditionLogic != null) app.updateSetting("conditionLogic", data.conditionLogic) // Step 3: Set enabled status last (this is most likely to trigger subscriptions) if (data.enabled != null) app.updateSetting("ruleEnabled", data.enabled) // Re-subscribe based on current enabled state // NOTE: app.updateSetting() does NOT update the in-memory settings map within the // same execution context. We must use data.enabled directly when available. def shouldBeEnabled = (data.enabled != null) ? data.enabled : settings.ruleEnabled unsubscribe() unschedule() clearDurationState() // Clear duration state when rule is updated to prevent orphaned triggers if (shouldBeEnabled) { subscribeToTriggers() } } def enableRule() { app.updateSetting("ruleEnabled", true) state.updatedAt = now() clearDurationState() // Clear orphaned duration state from previous disable unsubscribe() unschedule() subscribeToTriggers() } // Send loop guard notification to any notification-capable devices in the parent's selected devices. // Also fires a "mcpLoopGuard" location event so other automations can react. def notifyLoopGuard(String message) { try { // Fire a location event that other apps (Rule Machine, etc.) can subscribe to sendLocationEvent(name: "mcpLoopGuard", value: settings.ruleName, descriptionText: message) } catch (Exception e) { log.warn "Failed to send loop guard location event: ${e.message}" } try { def devices = parent.getSelectedDevices() ?: [] def notifiers = devices.findAll { dev -> dev.hasCommand("deviceNotification") } notifiers.each { dev -> try { dev.deviceNotification(message) } catch (Exception e) { log.warn "Failed to notify ${dev.label ?: dev.name}: ${e.message}" } } if (notifiers.size() > 0) { ruleLog("info", "Loop guard notification sent to ${notifiers.size()} device(s)") } } catch (Exception e) { log.warn "Failed to send loop guard notifications: ${e.message}" } } // Bridge to parent's mcpLog for MCP debug log visibility // Falls back to standard logging if parent method unavailable def ruleLog(String level, String message, Map extraData = null) { def ruleId = app.id?.toString() try { parent.mcpLog(level, "rule", message, ruleId, extraData) } catch (Exception e) { // Fallback to standard logging if parent method unavailable switch (level) { case "debug": log.debug message; break case "info": log.info message; break case "warn": log.warn message; break case "error": log.error message; break } } } def disableRule() { app.updateSetting("ruleEnabled", false) state.updatedAt = now() clearDurationState() // Clear duration state to prevent orphaned durationFired flags unsubscribe() unschedule() } def testRuleFromParent() { def results = testRule() return [ ruleId: app.id.toString(), ruleName: settings.ruleName, conditionsMet: results.conditionsMet, wouldExecute: results.wouldExecute, conditionResults: results.conditionResults, actions: results.actions ?: [] ] }