/**
* VisualAlert Child
*
* Advanced alert configuration for the VisualAlert system
* Manages individual alert patterns and triggers
*
* Copyright 2024
* Licensed under the Apache License, Version 2.0
*
* Version: 1.2.8
*/
import groovy.json.JsonOutput
import groovy.json.JsonSlurper
definition(
name: "VisualAlert Child",
namespace: "TechBill",
author: "Bill Fleming",
description: "Configure individual visual alert patterns and triggers",
parent: "TechBill:VisualAlert",
category: "Convenience",
iconUrl: "http://cdn.device-icons.smartthings.com/Lighting/light11-icn.png",
iconX2Url: "http://cdn.device-icons.smartthings.com/Lighting/light11-icn@2x.png",
iconX3Url: "http://cdn.device-icons.smartthings.com/Lighting/light11-icn@3x.png"
)
preferences {
page(name: "mainPage")
page(name: "patternPage")
page(name: "schedulePage")
page(name: "advancedPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "", install: true, uninstall: true, nextPage: null) { // Removed title
display() // Added display call
section(getFormat("header-blue", "${getImage("Blank")}" + "Alert Name & Devices")) {
input "alertName", "text",
title: "VisualAlert Child Name", // Changed label
required: true,
submitOnChange: true
input "devices", "capability.switch",
title: "Select Devices",
multiple: true,
required: true,
description: "Works with any switchable device: smart bulbs, Zigbee/Z-Wave switches, dimmers, outlets, etc."
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Trigger Sources")) {
paragraph "Select switches, buttons, or sensors (Contact, Motion, Smoke, CO, Water) that will activate this alert" // Updated paragraph
input "triggerSwitches", "capability.switch",
title: "Switches",
multiple: true,
required: false,
submitOnChange: true,
description: "Select switches that can trigger this alert" // Updated description
// Dynamic inputs for ON/OFF selection for each trigger switch
if (triggerSwitches) {
triggerSwitches.each { device ->
input "switchTrigger_${device.id}", "enum",
title: "${device.displayName} Trigger",
options: ["ON", "OFF"],
required: true,
defaultValue: "ON",
description: "Trigger when this switch turns ON or OFF?"
}
}
input "buttons", "capability.pushableButton",
title: "Buttons",
multiple: true,
required: false,
submitOnChange: true,
description: "Select button devices that can trigger this alert"
if (buttons) {
// Generate button options
def buttonOptions = [:]
buttons.each { device ->
def numberOfButtons = device.currentValue("numberOfButtons") ?: 1
for (def i = 1; i <= numberOfButtons; i++) {
buttonOptions["${device.id}:$i"] = "${device.displayName}: Button ${i}"
}
}
if (buttonOptions.size() > 0) {
paragraph "Available Buttons:"
buttons.each { device ->
def numberOfButtons = device.currentValue("numberOfButtons") ?: 1
paragraph "• ${device.displayName} has ${numberOfButtons} button${numberOfButtons > 1 ? 's' : ''}"
}
input "selectedButtons", "enum",
title: "Select Button(s) to Trigger Alert",
options: buttonOptions,
required: false,
multiple: true,
submitOnChange: true,
description: "Pick which button(s) will trigger the alert when pressed"
}
// Display selected buttons (Moved inside the 'if (buttonOptions)' block)
if (selectedButtons) {
paragraph "Selected Trigger Button(s):"
selectedButtons.each { buttonId ->
def (deviceId, buttonNumber) = buttonId.split(":")
def device = buttons.find { it.id == deviceId }
if (device) {
paragraph "• ${device.displayName}: Button ${buttonNumber} will trigger the alert"
}
}
}
}
// Contact Sensors
input "contactSensors", "capability.contactSensor",
title: "Contact Sensors",
multiple: true,
required: false,
submitOnChange: true,
description: "Select contact sensors that can trigger this alert when opened"
// Motion Sensors
input "motionSensors", "capability.motionSensor",
title: "Motion Sensors",
multiple: true,
required: false,
submitOnChange: true,
description: "Select motion sensors that can trigger this alert when motion becomes active"
// Removed leftover comment
// Removed duplicate comment
// Smoke Sensors
input "smokeSensors", "capability.smokeDetector",
title: "Smoke Sensors",
multiple: true,
required: false,
submitOnChange: true,
description: "Select smoke sensors that can trigger this alert when smoke is detected"
// CO Sensors
input "coSensors", "capability.carbonMonoxideDetector",
title: "Carbon Monoxide Sensors",
multiple: true,
required: false,
submitOnChange: true,
description: "Select CO sensors that can trigger this alert when CO is detected"
// Water Sensors
input "waterSensors", "capability.waterSensor",
title: "Water Sensors",
multiple: true,
required: false,
submitOnChange: true,
description: "Select water sensors that can trigger this alert when water is detected"
// Removed erroneous leftover code block
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Stop Alert | Enable/Disable VisualAlert Trigger")) { // Changed title
paragraph "Optional: Select buttons that will stop this alert when pressed"
input "stopButtonDevices", "capability.pushableButton",
title: "Select Button(s)",
multiple: true,
required: false,
submitOnChange: true,
description: "Choose button devices that can stop this alert"
paragraph "Optional: Select switch that will enable/disable this child app." // Added description for disable switch
input "disableSwitch", "capability.switch",
title: "Disable Alert with Switch?",
required: false,
multiple: false, // Only allow one switch for this
submitOnChange: true, // Ensure this is true to show the next input dynamically
description: "Optional: Select a switch to enable/disable this alert."
// Dynamic input for ON/OFF condition for the disable switch
if (disableSwitch) {
input "disableCondition", "enum",
title: "Disable When Switch Is",
options: ["ON", "OFF"],
required: true,
defaultValue: "ON",
description: "Disable the alert when the selected switch turns ON or OFF?"
}
if (stopButtonDevices) {
// Generate button options
def buttonOptions = [:]
stopButtonDevices.each { device ->
def numberOfButtons = device.currentValue("numberOfButtons") ?: 1
for (def i = 1; i <= numberOfButtons; i++) {
buttonOptions["${device.id}:$i"] = "${device.displayName}: Button ${i}"
}
}
if (buttonOptions.size() > 0) {
paragraph "Available Buttons:"
stopButtonDevices.each { device ->
def numberOfButtons = device.currentValue("numberOfButtons") ?: 1
paragraph "• ${device.displayName} has ${numberOfButtons} button${numberOfButtons > 1 ? 's' : ''}"
}
input "selectedStopButtons", "enum",
title: "Select Button(s) to Stop Alert",
options: buttonOptions,
required: false,
multiple: true,
submitOnChange: true,
description: "Pick which button(s) will stop the alert when pressed"
}
}
if (selectedStopButtons) {
paragraph "Selected Stop Button(s):"
selectedStopButtons.each { buttonId ->
def (deviceId, buttonNumber) = buttonId.split(":")
def device = stopButtonDevices.find { it.id == deviceId }
if (device) {
paragraph "• ${device.displayName}: Button ${buttonNumber} will stop the alert"
}
}
}
}
// Test Alert section moved below
section(getFormat("header-blue", "${getImage("Blank")}" + "Pattern Configuration")) {
href "patternPage", title: "Configure Alert Pattern", description: "Set up how devices will alert"
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Test Alert")) {
paragraph "Use these buttons to test the currently configured alert pattern on the selected devices."
input "btnTestAlert", "button", title: "Test Alert Pattern"
input "btnStopTest", "button", title: "Stop Test"
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Schedule & Conditions")) {
href "schedulePage", title: "Set Schedule & Conditions", description: "When alerts should be active"
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Advanced Settings")) {
href "advancedPage", title: "Advanced Configuration", description: "Failsafe and notification settings"
}
section("Support") {
paragraph "Donations are always appreciated!"
paragraph "💸 Donate via PayPal"
paragraph "☕ Buy Me a Coffee"
}
}
}
// Helper function to get pattern description (Moved before patternPage for clarity)
String getPatternDescription(String patternType) {
switch (patternType) {
case "Simple Flash":
return "Simple Flash: A single flash on, then off. Repeats based on 'Number of Repeats'."
case "Doorbell":
return "Doorbell: Two quick flashes followed by a pause. Repeats based on 'Number of Repeats'."
case "Emergency":
return "Emergency: Rapid flashing sequence. Repeats based on 'Number of Repeats'." // Removed color mention
case "Strobe": // New
return "Strobe: Three rapid flashes followed by a short pause. Repeats based on 'Number of Repeats'."
case "Standby": // New
return "Standby: Device stays on for 3 seconds, then off for 3 seconds. Repeats based on 'Number of Repeats'."
case "Custom":
return "Custom: Define a sequence of ON and OFF steps with durations in milliseconds (e.g., ON:1000, OFF:500, ON:200). Repeats based on 'Number of Repeats'."
default:
return "Select a pattern type."
}
}
def patternPage() {
dynamicPage(name: "patternPage", title: "", nextPage: "mainPage") { // Removed title
display() // Added display call
section(getFormat("header-blue", "${getImage("Blank")}" + "Pattern Type")) { // Renamed section
input "patternType", "enum",
title: "Pattern Type",
options: ["Simple Flash", "Doorbell", "Emergency", "Strobe", "Standby", "Custom"], // Added Strobe & Standby
defaultValue: "Simple Flash",
required: true,
submitOnChange: true // Keep this to update the description dynamically
// Display description based on selected pattern type
if (settings.patternType == null || settings.patternType == "Simple Flash") { // Handle initial load default
paragraph "Simple Flash: A single flash on, then off. Repeats based on 'Number of Repeats'."
} else if (settings.patternType == "Doorbell") {
paragraph "Doorbell: Two quick flashes followed by a pause. Repeats based on 'Number of Repeats'."
} else if (settings.patternType == "Emergency") {
paragraph "Emergency: Rapid flashing sequence. Repeats based on 'Number of Repeats'."
} else if (settings.patternType == "Strobe") {
paragraph "Strobe: Three rapid flashes followed by a short pause. Repeats based on 'Number of Repeats'."
} else if (settings.patternType == "Standby") {
paragraph "Standby: Device stays on for 3 seconds, then off for 3 seconds. Repeats based on 'Number of Repeats'."
} else if (settings.patternType == "Custom") {
paragraph "Custom: Define your own flash duration and pause between flashes. Repeats based on 'Number of Repeats'."
} else {
paragraph "Select a pattern type." // Default message
}
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Pattern Repetition & Timing")) { // New section combining repeats and custom timing
input "repeatCount", "number",
title: "Number of Repeats (0 = indefinite)",
required: true,
defaultValue: 5, // Default to 5 repeats
range: "0..100", // Limit to 100 repeats (adjust if needed)
description: "Applies to all pattern types. 0 means the pattern repeats until stopped."
if (patternType == "Custom") { // Only show sequence input for Custom pattern
paragraph "Custom Sequence Definition"
input "customPatternSequence", "textArea", // Changed to textArea
title: "Custom ON/OFF Sequence",
required: true,
defaultValue: "ON:1000, OFF:1000", // Default to a simple flash
description: "Define the custom alert sequence here." // Shortened description
// Add detailed instructions paragraph below the input
paragraph """Instructions:
• Enter a comma-separated list of ON and OFF steps.
• Each step must be in the format STATE:DURATION (e.g., ON:1000 or OFF:500).
• STATE must be either ON or OFF (case-insensitive).
• DURATION is the time in milliseconds (e.g., 1000ms = 1 second).
• Do not include spaces within or immediately around the commas.
Example 1 (Simple Flash): ON:1000,OFF:1000
Example 2 (Quick Double Flash): ON:200,OFF:200,ON:200,OFF:1000
"""
}
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Device Properties")) {
paragraph "Note: Color and level options only apply to devices that support these capabilities. Regular switches will simply turn on and off."
input "useColor", "bool",
title: "Use Color (for color-capable devices only)",
defaultValue: false
input "alertColor", "color",
title: "Alert Color (for color-capable devices only)",
required: false
input "alertLevel", "number",
title: "Alert Brightness Level (1-100, for dimmable devices only)",
range: "1..100",
required: false,
defaultValue: 100
input "offLevel", "number",
title: "Off-State Brightness Level (0-100, used instead of full off)",
range: "0..100",
required: false,
defaultValue: 0,
description: "Set to 0 for complete off, or higher to dim instead of turning completely off"
input "restorePrevious", "bool",
title: "Restore Last State (disabled = all devices off after alert)",
defaultValue: true
}
// Pattern Preview section removed
}
}
def schedulePage() {
dynamicPage(name: "schedulePage", title: "", nextPage: "mainPage") { // Removed title
display() // Added display call
section(getFormat("header-blue", "${getImage("Blank")}" + "Active Hours")) {
input "activeStart", "time",
title: "Start Time",
required: false
input "activeEnd", "time",
title: "End Time",
required: false
input "days", "enum",
title: "Active Days",
multiple: true,
options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
required: false
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Conditions")) {
input "modeRestriction", "mode",
title: "Restrict to Modes",
multiple: true,
required: false
input "presenceRestriction", "capability.presenceSensor",
title: "Restrict to Presence",
multiple: true,
required: false
input "illuminanceRestriction", "capability.illuminanceMeasurement",
title: "Restrict Based on Illuminance",
multiple: false,
required: false
input "illuminanceThreshold", "number",
title: "Illuminance Threshold (lux)",
required: false,
defaultValue: 50,
description: "Only activate when illuminance is below this value"
}
}
}
def advancedPage() {
dynamicPage(name: "advancedPage", title: "", nextPage: "mainPage") { // Removed title
display() // Added display call
section(getFormat("header-blue", "${getImage("Blank")}" + "Failsafe Settings")) {
input "timeout", "number",
title: "Pattern Timeout (minutes)",
required: true,
defaultValue: 5
input "retryCount", "number", // Note: retryCount is defined but not used in pattern execution
title: "Retry Attempts",
required: true,
defaultValue: 3
input "cancelOnReverse", "bool", // Note: cancelOnReverse is defined but not used (switchHandler is inactive)
title: "Cancel Alert When Trigger Reverses",
defaultValue: true,
description: "Example: Stop alert when a switch turns off"
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Notifications")) {
input "notifyStart", "bool",
title: "Notify on Alert Start",
defaultValue: true
input "notifyEnd", "bool",
title: "Notify on Alert End",
defaultValue: true
input "notificationDevices", "capability.notification",
title: "Notification Devices",
multiple: true,
required: false,
description: "Optional: Send notifications to these devices"
}
section(getFormat("header-blue", "${getImage("Blank")}" + "Logging")) {
input "enableLogging", "bool",
title: "Enable Debug Logging",
defaultValue: false
}
}
}
// --- Lifecycle Methods ---
def installed() {
logDebug "VisualAlert Child installed"
app.updateLabel(alertName ?: "New Alert")
initialize()
}
def updated() {
logDebug "VisualAlert Child updated"
app.updateLabel(alertName ?: "New Alert")
unsubscribe()
unschedule() // Ensure no old schedules persist (clears finalCleanupAndRestore, processAlertDevices etc.)
unschedule("executeDevicePattern") // Explicitly clear old handler name
initialize()
}
def initialize() {
log.info "Initializing VisualAlert Child: ${alertName ?: 'Unnamed Alert'}"
// Initialize state variables
state.isAlertRunning = false // Use this flag for active alert state
// isPreviewRunning state variable removed
state.lastTriggerDevice = null
// atomicState.runLoop = false // Removed
state.previousStates = [:] // Store device states before alert (No longer used for restore, but kept for reference?)
// previewStates state variable removed
// Store restorePrevious setting in state for reliable access (read via settings map)
// Default to true only if the setting is null (not set), otherwise use the actual boolean value
state.restorePreviousEnabled = (settings.restorePrevious != null) ? (settings.restorePrevious as Boolean) : true
logDebug "initialize: Stored restorePreviousEnabled in state: ${state.restorePreviousEnabled}"
// Initialize disabled state based on disableSwitch and disableCondition
if (disableSwitch) {
def conditionToDisable = settings.disableCondition ?: "ON" // Default to ON
def currentSwitchValue = disableSwitch.currentValue("switch")
state.isDisabled = currentSwitchValue?.equalsIgnoreCase(conditionToDisable) ?: false // Be safe if current value is null
logDebug "initialize: Disable switch selected. Condition='${conditionToDisable}', CurrentValue='${currentSwitchValue}'. Initial isDisabled state: ${state.isDisabled}"
} else {
state.isDisabled = false // No disable switch selected
logDebug "initialize: No disable switch selected. Initial isDisabled state: false"
}
// Subscribe to events
subscribeToEvents()
unschedule("executeDevicePattern") // Explicitly clear old handler name during init too
// Subscribe to app button events (Test/Stop Test buttons)
subscribe(app, "buttonPressed", "appButtonHandler") // Re-enabled for Test button
}
def subscribeToEvents() {
try {
logDebug "*************** Subscribing to Events ***************"
// Buttons - use single handler for both trigger and stop buttons
def allButtonDevices = []
if (buttons) allButtonDevices.addAll(buttons)
if (stopButtonDevices) allButtonDevices.addAll(stopButtonDevices)
allButtonDevices = allButtonDevices.unique { it.id } // Remove duplicates
if (allButtonDevices) {
logDebug "Setting up button subscriptions for ${allButtonDevices.size()} devices"
allButtonDevices.each { device ->
logDebug "Subscribing to button device: ${device.displayName} (ID: ${device.id})"
subscribe(device, "pushed", buttonHandler)
def numberOfButtons = device.currentValue("numberOfButtons") ?: 1
logDebug "Device ${device.displayName} has ${numberOfButtons} button(s)"
}
}
// Switches - Trigger (Subscribe to both ON and OFF)
if (triggerSwitches) {
logDebug "Setting up switch subscriptions for ${triggerSwitches.size()} devices"
triggerSwitches.each { device ->
logDebug "Subscribing to switch ON/OFF events for: ${device.displayName} (ID: ${device.id})"
subscribe(device, "switch.on", switchHandler)
subscribe(device, "switch.off", switchHandler)
}
}
// Contact Sensors - Trigger
if (contactSensors) {
logDebug "Setting up contact sensor subscriptions for ${contactSensors.size()} devices"
contactSensors.each { device ->
logDebug "Subscribing to contact sensor: ${device.displayName} (ID: ${device.id})"
subscribe(device, "contact.open", sensorHandler) // Subscribe to 'open' event
}
}
// Motion Sensors - Trigger
if (motionSensors) {
logDebug "Setting up motion sensor subscriptions for ${motionSensors.size()} devices"
motionSensors.each { device ->
logDebug "Subscribing to motion sensor: ${device.displayName} (ID: ${device.id})"
subscribe(device, "motion.active", sensorHandler) // Subscribe to 'active' event
}
}
// Smoke Sensors - Trigger
if (smokeSensors) {
logDebug "Setting up smoke sensor subscriptions for ${smokeSensors.size()} devices"
smokeSensors.each { device ->
logDebug "Subscribing to smoke sensor: ${device.displayName} (ID: ${device.id})"
subscribe(device, "smoke.detected", sensorHandler) // Subscribe to 'detected' event
}
}
// CO Sensors - Trigger
if (coSensors) {
logDebug "Setting up CO sensor subscriptions for ${coSensors.size()} devices"
coSensors.each { device ->
logDebug "Subscribing to CO sensor: ${device.displayName} (ID: ${device.id})"
subscribe(device, "carbonMonoxide.detected", sensorHandler) // Subscribe to 'detected' event
}
}
// Water Sensors - Trigger
if (waterSensors) {
logDebug "Setting up water sensor subscriptions for ${waterSensors.size()} devices"
waterSensors.each { device ->
logDebug "Subscribing to water sensor: ${device.displayName} (ID: ${device.id})"
subscribe(device, "water.wet", sensorHandler) // Subscribe to 'wet' event
}
}
// Subscribe to the disable switch
if (disableSwitch) {
logDebug "Subscribing to disable switch: ${disableSwitch.displayName}"
subscribe(disableSwitch, "switch", disableSwitchHandler)
}
logDebug "Subscribed to all event sources"
logDebug "Subscribed to all event sources"
logDebug "Subscribed to all event sources"
logDebug "Current configuration:"
logDebug "Selected Trigger Buttons: ${selectedButtons}"
logDebug "Selected Stop Buttons: ${selectedStopButtons}"
} catch (e) {
log.error "Error subscribing to events: ${e}"
}
} // End of subscribeToEvents
// Handler for the disable switch
def disableSwitchHandler(evt) {
logDebug "disableSwitchHandler called with event: ${evt.value} from ${evt.displayName}"
// Get the configured condition ("ON" or "OFF", default to "ON" if somehow not set)
def conditionToDisable = settings.disableCondition ?: "ON"
def eventValue = evt.value // "on" or "off"
logDebug "Checking event '${eventValue}' against disable condition '${conditionToDisable}'"
// Check if the current event value matches the condition that should disable the alert
if (eventValue.equalsIgnoreCase(conditionToDisable)) {
if (!state.isDisabled) { // Only log and stop if changing state to disabled
state.isDisabled = true
logDebug "Alerts DISABLED because ${evt.displayName} turned ${eventValue} (matches condition '${conditionToDisable}')"
// Stop any active alert immediately when disabled
stopAlertImmediate(reason: "disabled by switch ${evt.displayName}")
} else {
logDebug "Alerts remain disabled (switch ${evt.displayName} is ${eventValue})"
}
} else {
if (state.isDisabled) { // Only log if changing state to enabled
state.isDisabled = false
logDebug "Alerts ENABLED because ${evt.displayName} turned ${eventValue} (does not match condition '${conditionToDisable}')"
} else {
logDebug "Alerts remain enabled (switch ${evt.displayName} is ${eventValue})"
}
logDebug "Alerts enabled by switch: ${evt.displayName}"
}
}
// --- Event Handlers ---
// Handles physical button presses for trigger and stop
def buttonHandler(evt) {
// Check if disabled
if (state.isDisabled) {
logDebug "Button event ignored: Alert is disabled by switch."
return
}
logDebug "*************** Button Handler ***************"
logDebug "Button Event: device=${evt.device.displayName}, deviceId=${evt.deviceId}, value=${evt.value}"
def deviceAndButton = "${evt.deviceId}:${evt.value}"
logDebug "Button press detected - deviceAndButton: '${deviceAndButton}'"
// Convert to string and trim for comparison
def buttonToCheck = deviceAndButton.toString().trim()
def selectedButtonsList = selectedButtons?.collect { it.toString().trim() }
def selectedStopButtonsList = selectedStopButtons?.collect { it.toString().trim() }
// First check if this is a stop button
if (selectedStopButtonsList?.contains(buttonToCheck)) {
log.info "Stop button match found! Button: ${buttonToCheck}. Setting flags."
// atomicState.runLoop = false // Removed
// atomicState.stopRequested = true // Removed
// Call stopAlertImmediate mainly for cleanup, indicate source
stopAlertImmediate(reason: "Stop button pressed: ${evt.device.displayName} Button ${evt.value}", fromButton: true)
return
}
// Then check if this is a trigger button
if (selectedButtonsList?.contains(buttonToCheck)) {
log.info "Trigger button match found! Button: ${buttonToCheck}"
state.lastTriggerDevice = evt.deviceId
// Call startAlert directly - it will handle stopping existing alert and checking conditions
startAlert("Button ${evt.device.displayName} pressed")
return
}
logDebug "Button press ${buttonToCheck} did not match any configured trigger or stop buttons."
}
// Handles button presses from the app UI (Preview/Stop Preview)
def appButtonHandler(btn) {
logDebug "*************** App Button pressed: $btn ***************"
switch(btn) {
case "btnTestAlert":
logDebug "Test Alert button pressed - starting alert"
// Call startAlert directly, it handles conditions and stopping existing alerts
startAlert("Test Alert button pressed")
break
case "btnStopTest":
logDebug "Stop Test button pressed - setting flags and calling stopAlertImmediate"
// atomicState.runLoop = false // Removed
// atomicState.stopRequested = true // Removed
// Call stopAlertImmediate mainly for cleanup, indicate source
stopAlertImmediate(reason: "Stop Test button pressed", fromButton: true)
break
default:
logDebug "Unknown app button: $btn"
}
}
// Handles switch 'on' and 'off' events for trigger switches
def switchHandler(evt) {
// Check if disabled
if (state.isDisabled) {
logDebug "Switch event ignored: Alert is disabled by switch."
return
}
logDebug "*************** Switch Handler ***************"
logDebug "Switch Event: device=${evt.device.displayName}, deviceId=${evt.deviceId}, value=${evt.value}"
def deviceId = evt.deviceId
def eventValue = evt.value // "on" or "off"
def deviceName = evt.device.displayName
// Get the configured trigger condition ("ON" or "OFF") for this specific switch
def settingName = "switchTrigger_${deviceId}"
def configuredTrigger = settings[settingName] // Access setting dynamically
// Check if a trigger condition is actually configured for this switch
if (!configuredTrigger) {
logDebug "Switch event ignored: No trigger condition configured for ${deviceName} (Setting: ${settingName})"
return // Exit if no configuration found for this specific switch
}
logDebug "Device ${deviceName}: Event='${eventValue}', Configured Trigger='${configuredTrigger}'"
// Check if the event value matches the configured trigger condition (case-insensitive)
if (eventValue.equalsIgnoreCase(configuredTrigger)) {
log.info "Trigger condition met for ${deviceName}: Event '${eventValue}' matches configured '${configuredTrigger}'"
state.lastTriggerDevice = deviceId
startAlert("Switch ${deviceName} turned ${eventValue}") // Use eventValue in reason
} else {
// Log if the event occurred but didn't match the configured trigger (e.g., switch turned ON but configured for OFF)
logDebug "Switch event ignored: Event '${eventValue}' does not match configured trigger '${configuredTrigger}' for ${deviceName}"
}
}
// Handles sensor 'detected' events for trigger sensors
def sensorHandler(evt) {
// Check if disabled
if (state.isDisabled) {
logDebug "Sensor event ignored: Alert is disabled by switch."
return
}
logDebug "*************** Sensor Handler ***************"
logDebug "Sensor Event: device=${evt.device.displayName}, deviceId=${evt.deviceId}, name=${evt.name}, value=${evt.value}"
def triggerReason = null
// Check for smoke detection
if (evt.name == "smoke" && evt.value == "detected") {
triggerReason = "Smoke detected by ${evt.device.displayName}"
}
// Check for CO detection
else if (evt.name == "carbonMonoxide" && evt.value == "detected") {
triggerReason = "CO detected by ${evt.device.displayName}"
}
// Check for water detection
else if (evt.name == "water" && evt.value == "wet") {
triggerReason = "Water detected by ${evt.device.displayName}"
}
// Check for contact opening
else if (evt.name == "contact" && evt.value == "open") {
triggerReason = "Contact opened on ${evt.device.displayName}"
}
// Check for motion activation
else if (evt.name == "motion" && evt.value == "active") {
triggerReason = "Motion detected by ${evt.device.displayName}"
}
// If a valid trigger was found, start the alert
if (triggerReason) {
log.info triggerReason
state.lastTriggerDevice = evt.deviceId
// Call startAlert - it handles conditions and stopping existing alerts
startAlert(triggerReason)
} else {
// Log if the event wasn't a 'detected' state we care about
logDebug "Sensor event ignored (name: ${evt.name}, value: ${evt.value})"
}
}
// --- Core Alert Logic ---
// Checks if conditions (time, day, mode, presence, illuminance) are met
def isValidTrigger() {
// Check time restrictions
if (activeStart && activeEnd) {
try {
if (!timeOfDayIsBetween(toDateTime(activeStart), toDateTime(activeEnd), new Date(), location.timeZone)) {
logDebug "Outside active hours"
return false
}
} catch (e) {
log.warn "Error parsing time restriction: ${e.message}"
}
}
// Check day restrictions
if (days && days.size() > 0) {
def today = new Date().format("EEEE", location.timeZone)
if (!days.contains(today)) {
logDebug "Not an active day: ${today}"
return false
}
}
// Check mode restrictions
if (modeRestriction && modeRestriction.size() > 0) {
if (!modeRestriction.contains(location.mode)) {
logDebug "Mode not active: ${location.mode}"
return false
}
}
// Check presence restrictions
if (presenceRestriction && presenceRestriction.size() > 0) {
def anyoneHome = presenceRestriction.any { it.currentValue("presence") == "present" }
if (!anyoneHome) {
logDebug "No one home based on selected presence sensors"
return false
}
}
// Check illuminance restrictions
if (illuminanceRestriction && illuminanceThreshold != null) {
def currentLux = illuminanceRestriction.currentValue("illuminance")
if (currentLux != null && currentLux > illuminanceThreshold) {
logDebug "Room too bright: ${currentLux} lux > ${illuminanceThreshold} lux threshold"
return false
}
}
logDebug "All trigger conditions met."
return true
}
// Starts the alert sequence
def startAlert(reason, emergency = false) {
logDebug "startAlert called with reason: ${reason}"
// If an alert is already running, stop it immediately before starting new one
if (state.isAlertRunning) {
log.info "Alert already running, stopping it before starting new one: ${reason}"
// Pass a specific reason to avoid double "end" notifications if desired
stopAlertImmediate(reason: "new alert triggered", sendNotify: false)
pauseExecution(1500) // Give time for stop/restore to settle
}
// Check conditions *after* potentially stopping previous alert
if (!isValidTrigger()) {
logDebug "Conditions not met, not starting alert."
return
}
log.info "Starting alert: ${reason}" + (state.lastTriggerDevice ? " (triggered by device ID: ${state.lastTriggerDevice})" : "")
// --- Set Initial Alert State ---
state.isAlertRunning = true
// atomicState.runLoop = true // Removed, using state.isAlertRunning
state.alertStart = now()
state.alertReason = reason
unschedule() // Clear any previous timeout or repeat schedules
// atomicState.activePatternRuns = 0 // Removed counter logic
// Save current device states if restoration is enabled (using value stored in state)
logDebug "startAlert: Checking restorePreviousEnabled state value: ${state.restorePreviousEnabled}"
if (state.restorePreviousEnabled) {
saveDeviceStates() // Call directly, now saves JSON to atomicState.previousStatesJson
} else {
logDebug "startAlert: Not saving device states as restorePreviousEnabled is false/null."
atomicState.previousStatesJson = null // Clear atomic state JSON string if not restoring
}
// --- Execute Pattern ---
// This needs to happen *after* state is set and devices saved
def patternInfo = getPatternInfo(emergency ? "Emergency" : null)
// No need to pass restorePrevious in patternInfo anymore
logDebug "Running pattern: ${patternInfo.type}"
// Store consolidated pattern parameters in a single atomicState key
def patternParamsMap = [
commands: patternInfo.commands ?: [],
repeatCount: patternInfo.repeatCount,
isInfinite: patternInfo.isInfinite,
colorMap: patternInfo.colorMap,
level: patternInfo.level,
offLevel: patternInfo.offLevel,
patternType: patternInfo.type
]
String patternParamsJson = JsonOutput.toJson(patternParamsMap)
atomicState.currentPatternParamsJson = patternParamsJson
logDebug "Stored consolidated pattern data in atomicState.currentPatternParamsJson"
// --- Schedule First Pattern Step ---
long startDelayMs = 200 // Short delay before starting processing
logDebug "Scheduling first processPatternStep in ${startDelayMs} ms"
// Schedule the first step (index 0, loop 0)
runInMillis(startDelayMs, "processPatternStep", [data: [stepIndex: 0, loopCounter: 0], overwrite: false])
// --- Notifications & Schedule Final Cleanup ---
if (notifyStart) {
sendNotification("VisualAlert: ${alertName} activated - ${reason}")
}
// Calculate total duration for finite patterns or use timeout for infinite (PARALLEL execution)
def cleanupDelaySeconds
if (patternInfo.isInfinite) {
def effectiveTimeoutMinutes = timeout ?: 5 // Default to 5 minutes if not set
if (effectiveTimeoutMinutes > 0) {
cleanupDelaySeconds = effectiveTimeoutMinutes * 60
logDebug "Pattern is indefinite, scheduling final cleanup/restore in ${cleanupDelaySeconds} seconds (timeout)."
} else {
cleanupDelaySeconds = -1 // Indicates no scheduled cleanup for infinite without timeout
logDebug "Pattern is indefinite and timeout is 0, alert will run until stopped manually. No cleanup scheduled."
}
} else {
// Calculate total duration for ONE pattern loop running in PARALLEL
long loopDurationMs = patternInfo.commands.sum { it.duration ?: 0 }
long totalPatternMs = loopDurationMs * patternInfo.repeatCount
// Add a buffer (e.g., 5 seconds overall) for overhead and potential delays
long bufferMs = 5000
cleanupDelaySeconds = ((totalPatternMs + bufferMs) / 1000)
logDebug "Pattern is finite (${patternInfo.repeatCount} repeats). Scheduling final cleanup/restore in ${cleanupDelaySeconds} seconds (parallel execution)."
}
// Schedule the final cleanup if a delay was determined (for both finite and infinite with timeout)
if (cleanupDelaySeconds > 0) {
logDebug "Scheduling finalCleanupAndRestore in ${cleanupDelaySeconds} seconds. Reason: ${patternInfo.isInfinite ? 'timeout' : 'pattern completed'}"
runIn(cleanupDelaySeconds.toInteger(), "finalCleanupAndRestore", [data: [reason: patternInfo.isInfinite ? "timeout" : "pattern completed"], overwrite: true])
}
}
// Central function for cleanup and restoration, called by runIn or stop handlers
def finalCleanupAndRestore(data) {
def reason = data?.reason ?: "unknown completion"
log.info "Final cleanup and restore initiated due to: ${reason}"
// Check if alert is still considered running (might have been stopped manually already)
// Note: state.isAlertRunning might be false if called directly from stopAlertImmediate
// We proceed anyway to ensure restoration happens after an immediate stop.
// if (!state.isAlertRunning && reason != "manual stop" && !reason.startsWith("Stop")) { // More complex check if needed
if (!state.isAlertRunning && reason == "pattern completed") { // Only skip if already stopped AND it was a normal completion
logDebug "Alert already stopped (likely via immediate stop), ignoring scheduled finalCleanupAndRestore call for 'pattern completed'."
unschedule("finalCleanupAndRestore")
return
}
// --- Stop Pattern & Restore State ---
// Set running flag false FIRST to signal loops (if not already false from immediate stop)
if (state.isAlertRunning) {
state.isAlertRunning = false
logDebug "finalCleanupAndRestore: Set state.isAlertRunning to false"
}
// atomicState.runLoop = false // Removed
unschedule("processPatternStep") // Cancel any pending steps
// --- Conditionally Force ON for Immediate Stops ---
def immediateStopReasons = ["manual stop", "Stop Test button pressed", "Stop button pressed", "disabled by switch", "new alert triggered"]
boolean isImmediateStop = immediateStopReasons.any { reason.contains(it) }
if (isImmediateStop) {
logDebug("finalCleanupAndRestore: Immediate stop detected ('${reason}'). Forcing devices ON before restore.")
devices?.on()
pauseExecution(200) // Shorter pause just in case
}
// --- End Conditional Force ON ---
boolean shouldRestore = state.restorePreviousEnabled ?: false
logDebug "finalCleanupAndRestore: Checking restorePreviousEnabled state value: ${shouldRestore}"
if (shouldRestore) {
String previousStatesJson = atomicState.previousStatesJson
if (previousStatesJson) {
logDebug("finalCleanupAndRestore: Found previousStatesJson: ${previousStatesJson}")
try {
Map statesToRestoreMap = new JsonSlurper().parseText(previousStatesJson)
if (statesToRestoreMap && !statesToRestoreMap.isEmpty()) {
logDebug("finalCleanupAndRestore: Successfully parsed JSON. Calling restoreDeviceStates.")
restoreDeviceStates(statesToRestoreMap) // Restore from JSON map
} else {
log.warn("finalCleanupAndRestore: Parsed JSON map is empty or null. Turning devices off.")
devices?.off()
atomicState.previousStatesJson = null // Clear invalid state
}
} catch (Exception e) {
log.error("finalCleanupAndRestore: Error parsing previousStatesJson: ${e.message}. Turning devices off.")
devices?.off()
atomicState.previousStatesJson = null // Clear invalid state
}
} else {
logDebug("finalCleanupAndRestore: atomicState.previousStatesJson is empty/null. Turning devices off.")
devices?.off()
}
} else {
// --- UPDATED: Always turn off if restore is disabled ---
logDebug "finalCleanupAndRestore: Restore disabled, turning devices off."
devices?.off()
// --- END UPDATE ---
}
// --- Notifications ---
// Send notification only if it wasn't triggered by a new alert starting
if (notifyEnd && reason != "new alert triggered") {
// Use the original alert reason if available, otherwise the cleanup reason
def notifyReason = state.alertReason ?: reason
sendNotification("VisualAlert: ${alertName} deactivated - ${notifyReason}")
}
// --- Final State Cleanup ---
logDebug "Final cleanup: Clearing state variables."
state.lastTriggerDevice = null
state.alertStart = null
state.alertReason = null
atomicState.previousStatesJson = null // Ensure JSON is cleared after use/attempt
atomicState.currentPatternParamsJson = null // Clean up pattern params
}
// Stops the alert due to external trigger (e.g., timeout handled by runIn calling finalCleanupAndRestore)
def stopAlert(data) {
// This handler is now primarily for the timeout case scheduled by startAlert
// It should just call the central cleanup function
logDebug("stopAlert called (likely timeout), unscheduling and calling finalCleanupAndRestore.")
unschedule("finalCleanupAndRestore") // Prevent duplicate execution
finalCleanupAndRestore(data) // Pass the reason ("timeout")
}
// Stops the alert immediately (e.g., manual stop button, new alert starting)
def stopAlertImmediate(evtOrParams = null) {
// Determine if called by runIn (evtOrParams will have a 'data' key) or directly
def params = evtOrParams instanceof Map ? evtOrParams : [:]
def data = evtOrParams?.data instanceof Map ? evtOrParams.data : [:]
def reason = data.reason ?: params.reason ?: "manual stop"
// Notification sending is now handled solely within finalCleanupAndRestore
// boolean sendNotify = data.get("sendNotify", params.get("sendNotify", true)) // Removed
log.info "Stopping alert immediately (stopAlertImmediate): ${reason}"
// Check if alert is actually running
if (!state.isAlertRunning) {
logDebug "Alert not running when stopAlertImmediate called, ignoring."
// Ensure any lingering cleanup schedule is cancelled
unschedule("finalCleanupAndRestore")
return
}
// Signal loops to stop by setting the central flag FIRST
state.isAlertRunning = false
logDebug "stopAlertImmediate: Set state.isAlertRunning to false"
// atomicState.runLoop = false // Removed
unschedule("processPatternStep") // Cancel any pending steps FIRST
logDebug "stopAlertImmediate: Unscheduled any pending processPatternStep."
// Cancel any scheduled final cleanup
unschedule("finalCleanupAndRestore")
logDebug "stopAlertImmediate: Unscheduled any pending finalCleanupAndRestore."
// --- REMOVED: Force devices ON before calling cleanup/restore for immediate stops ---
// logDebug("stopAlertImmediate: Forcing devices ON before calling finalCleanupAndRestore.")
// devices?.on()
// --- END REMOVAL ---
// Call the central cleanup function immediately
// Pass the reason, which will now be checked inside finalCleanupAndRestore
finalCleanupAndRestore([reason: reason])
}
// --- State Management ---
// Saves current device states before starting an alert and stores as JSON in atomicState
void saveDeviceStates() {
log.info "Saving device states before alert"
Map tempStates = [:] // Use a temporary map
atomicState.previousStatesJson = null // Clear previous JSON string *before* loop
settings.devices.each { device -> // Explicitly use settings.devices
try {
def currentLevel = hasLevelCapability(device) ? device.currentValue("level") : null
def currentHue = hasColorCapability(device) ? device.currentValue("hue") : null
def currentSat = hasColorCapability(device) ? device.currentValue("saturation") : null
def currentCT = hasColorTemperature(device) ? device.currentValue("colorTemperature") : null
// --- UPDATED Switch State Logic ---
// Determine switch state directly first
def currentSwitch = device.currentValue("switch")
logDebug "Device ${device.displayName}: Reported switch state: '${currentSwitch}'"
// Fallback for invalid reported switch state
if (currentSwitch != "on" && currentSwitch != "off") {
logWarn "Device ${device.displayName} reported invalid switch state '${currentSwitch}'. Attempting fallback using level."
// Fallback: Check level only if switch state is invalid
if (hasLevelCapability(device) && currentLevel != null && currentLevel > 0) {
currentSwitch = "on"
logWarn "Fallback: Using level ${currentLevel} > 0, assuming switch state is 'on'."
} else {
currentSwitch = "off" // Final fallback
logWarn "Fallback: Assuming switch state is 'off'."
}
}
// --- END UPDATED Switch State Logic ---
def stateObj = [
switch: currentSwitch,
level: currentLevel,
hue: currentHue,
saturation: currentSat,
colorTemperature: currentCT
]
tempStates[device.id.toString()] = stateObj // Save to temporary map
logDebug "Saved initial state for ${device.displayName} to temp map: ${stateObj}"
} catch (e) {
log.error "Error saving state for ${device.displayName}: ${e.message}"
}
}
try {
atomicState.previousStatesJson = JsonOutput.toJson(tempStates) // Convert map to JSON and store in atomicState
logDebug "Finished saving device states as JSON to atomicState.previousStatesJson. Count: ${tempStates?.size() ?: 0}. JSON: ${atomicState.previousStatesJson}"
} catch (Exception e) {
log.error "Error converting device states map to JSON: ${e.message}"
atomicState.previousStatesJson = null // Ensure it's null on error
}
// Removed return statement
}
// Restores device states after an alert stops
def restoreDeviceStates(Map statesToRestore) { // Now requires the map to restore from
// No longer need defensive copy as we get a new map from JSON parsing
if (!statesToRestore || statesToRestore.isEmpty()) {
log.warn "restoreDeviceStates called but no states found to restore (or map was empty). Turning devices off."
devices?.off()
// Clear atomicState JSON even if restore failed/wasn't needed
atomicState.previousStatesJson = null
// Clear both state maps after attempting restore
state.previousStates = [:]
state.currentAlertStates = null
return
}
log.info "Restoring device states. Count: ${statesToRestore?.size() ?: 0}. States: ${statesToRestore}"
// --- REMOVED pre-restore OFF loop ---
// settings.devices?.each { device -> // Explicitly use settings.devices
// try {
// device.off()
// } catch (Exception e) {
// log.warn "Error turning off ${device.displayName} during pre-restore: ${e.message}"
// }
// }
// pauseExecution(1000) // Give devices time to settle
// --- END REMOVAL ---
// Now restore previous states
settings.devices.each { device -> // Explicitly use settings.devices
def deviceIdStr = device.id.toString()
def prevState = statesToRestore[deviceIdStr] // Use the map directly
if (prevState) {
logDebug "Attempting to restore ${device.displayName} to previous state: ${prevState}"
try {
boolean shouldBeOn = (prevState.switch == "on")
logDebug "Device ${device.displayName}: Saved state indicates shouldBeOn=${shouldBeOn}"
if (shouldBeOn) {
// Restore attributes BEFORE turning on
restoreDeviceAttributes(device, prevState)
pauseExecution(500) // Wait for attributes to potentially take effect
// Turn ON
device.on()
logDebug "Restored ${device.displayName} to ON"
// Verification step (optional but recommended for problematic devices)
pauseExecution(1000)
if (device.currentValue("switch") != "on") {
log.warn "Device ${device.displayName} did not report ON after restore (current: ${device.currentValue('switch')}), trying ON command again."
device.on()
}
} else {
// --- ADDED: Restore attributes even if turning off ---
logDebug "Device ${device.displayName} should be OFF. Restoring attributes first."
restoreDeviceAttributes(device, prevState)
pauseExecution(500) // Wait for attributes to potentially take effect
// --- END ADD ---
// Ensure device is OFF
device.off()
logDebug "Ensured ${device.displayName} is OFF (as per saved state)"
}
} catch (Exception e) {
log.error "Error restoring state for ${device.displayName}: ${e.message}"
// Fallback: try to ensure it's at least off if restoration failed badly
try { device.off() } catch (e2) {}
}
} else {
log.warn "No saved state found for ${device.displayName} in restore map. Ensuring device is off."
try { device.off() } catch (e) {}
}
}
// Clear atomicState JSON after attempting restore
atomicState.previousStatesJson = null
logDebug "Finished restoring device states."
// Schedule a final check (optional, good for Z-Wave) - Commented out as it causes errors and restore seems to work
// Commented-out finalStateCheck call is removed
}
// Helper to restore specific attributes (color, level, CT)
private void restoreDeviceAttributes(device, Map attrs) {
logDebug "Restoring attributes for ${device.displayName}: ${attrs}"
// Prioritize Color Temp -> Color -> Level
try {
if (attrs.colorTemperature != null && hasColorTemperature(device)) {
device.setColorTemperature(attrs.colorTemperature)
logDebug "Restored Color Temperature: ${attrs.colorTemperature}"
pauseExecution(300)
}
// Restore Hue/Saturation if available (Remove 'else' to allow both CT and Color restore if needed)
if (attrs.hue != null && attrs.saturation != null && hasColorCapability(device)) {
// Use setColor map for reliability if possible
try {
def colorMap = [hue: attrs.hue, saturation: attrs.saturation]
// Include level only if it's part of the color command for this device type
// if (device.hasCommand("setColor", [Map])) { // Check if setColor accepts level
// if (attrs.level != null) colorMap.level = attrs.level
// }
device.setColor(colorMap)
logDebug "Restored Color (Hue/Sat) using setColor: ${colorMap}"
pauseExecution(300)
} catch (e) {
logWarn "setColor failed for ${device.displayName}, trying individual Hue/Sat: ${e.message}"
// Fallback to individual commands
device.setHue(attrs.hue)
pauseExecution(200)
device.setSaturation(attrs.saturation)
logDebug "Restored Color using individual setHue/setSaturation"
pauseExecution(300)
}
}
// Restore Level (do this after color/CT)
if (attrs.level != null && hasLevelCapability(device)) {
// Avoid setting level if setColor already handled it (might cause issues)
// This requires knowing if device's setColor includes level, which is tricky.
// Safest is often to set it separately.
device.setLevel(attrs.level)
logDebug "Restored Level: ${attrs.level}"
pauseExecution(300)
}
} catch (Exception e) {
log.warn "Error restoring attributes for ${device.displayName}: ${e.message}"
}
}
// --- Pattern Execution ---
// Determines pattern details based on settings
def getPatternInfo(String type = null) {
def patternTypeToUse = type ?: settings.patternType ?: "Simple Flash" // Use settings.patternType
// Use the value from the input field directly. Default to 1 if null or invalid.
def repeatCountInput = settings.repeatCount != null ? settings.repeatCount.toInteger() : 5 // Default to 5 if setting is null
// Determine if infinite based on repeatCount being 0
def isInfinite = (repeatCountInput == 0)
// Use 1 for loop checks if infinite, otherwise use the input value
def actualRepeatCount = isInfinite ? 1 : repeatCountInput
def info = [
type: patternTypeToUse,
isInfinite: isInfinite,
repeatCount: actualRepeatCount, // Store the count for executeDevicePattern loop
commands: []
]
switch(patternTypeToUse) {
case "Doorbell":
info.commands = [
[on: true, duration: 300], [on: false, duration: 300], // Quick flash 1
[on: true, duration: 300], [on: false, duration: 1000] // Quick flash 2, pause
// Removed extra flashes, repeatCount handles repetition
]
// info.repeatCount is set above from input
break
case "Emergency":
info.commands = [
[on: true, duration: 300], [on: false, duration: 300]
]
// info.repeatCount is set above from input
// info.color = "#FF0000" // Removed hardcoded red color
info.level = 100 // Force full brightness
break
case "Strobe": // New Strobe pattern
info.commands = [
[on: true, duration: 200], [on: false, duration: 200],
[on: true, duration: 200], [on: false, duration: 200],
[on: true, duration: 200], [on: false, duration: 1500] // Longer pause
]
// info.repeatCount is set above from input
break
case "Standby": // New Standby pattern
info.commands = [
[on: true, duration: 3000], // On for 3 seconds
[on: false, duration: 3000] // Off for 3 seconds
]
// info.repeatCount is set above from input
break
case "Custom":
info.commands = [] // Initialize empty command list
def sequenceString = settings.customPatternSequence ?: "ON:1000,OFF:1000" // Get sequence from settings, default if null/empty
logDebug "Parsing custom sequence: ${sequenceString}"
try {
sequenceString.split(',').each { step ->
step = step.trim() // Remove leading/trailing whitespace
if (step) { // Ensure step is not empty after trimming
def parts = step.split(':')
if (parts.size() == 2) {
def stateStr = parts[0].trim().toUpperCase()
def durationStr = parts[1].trim()
boolean onState = (stateStr == "ON")
long durationMs = durationStr.toLong() // Convert duration to long
if (durationMs <= 0) {
log.warn "Custom pattern step '${step}' has invalid duration (${durationMs}ms). Skipping."
} else if (stateStr != "ON" && stateStr != "OFF") {
log.warn "Custom pattern step '${step}' has invalid state ('${parts[0]}'). Skipping."
} else {
info.commands << [on: onState, duration: durationMs]
}
} else {
log.warn "Custom pattern step '${step}' has incorrect format (expected STATE:ms). Skipping."
}
}
}
} catch (NumberFormatException e) {
log.error "Error parsing custom pattern duration in sequence '${sequenceString}': ${e.message}. Defaulting pattern."
info.commands = [[on: true, duration: 1000], [on: false, duration: 1000]] // Default on error
} catch (Exception e) {
log.error "Error parsing custom pattern sequence '${sequenceString}': ${e.message}. Defaulting pattern."
info.commands = [[on: true, duration: 1000], [on: false, duration: 1000]] // Default on error
}
// Ensure commands list is not empty after parsing
if (info.commands.isEmpty()) {
log.warn "Custom pattern sequence '${sequenceString}' resulted in empty command list. Defaulting pattern."
info.commands = [[on: true, duration: 1000], [on: false, duration: 1000]]
}
// info.repeatCount and info.isInfinite are set above from input
break
case "Simple Flash":
default:
info.commands = [
[on: true, duration: 1000], [on: false, duration: 1000]
]
// info.repeatCount is set above from input
break
}
// Add common properties (color/level) if not emergency
if (patternTypeToUse != "Emergency") {
if (settings.useColor && settings.alertColor) { // Use settings.useColor etc.
// Convert hex color to HSL map using the alertLevel as the target level
info.colorMap = hexToHslMap(settings.alertColor, (settings.alertLevel ?: 100) as Integer)
// Keep original hex for logging if needed, but colorMap is used for commands
info.colorHex = settings.alertColor
}
if (settings.alertLevel != null) { // Use settings.alertLevel
info.level = settings.alertLevel
}
}
// Add offLevel from settings
info.offLevel = settings.offLevel ?: 0 // Use settings.offLevel
logDebug "Generated pattern info: ${info}" // Keep log for debugging
// Duplicate offLevel line removed
// logDebug "Generated pattern info: ${info}" // Log moved up
return info
}
// New function to handle one step of the pattern for ALL devices
def processPatternStep(data) {
int stepIndex = data.stepIndex ?: 0
int loopCounter = data.loopCounter ?: 0
// Check if alert is still running
if (!state.isAlertRunning) {
logDebug "processPatternStep: Alert stopped. Exiting step ${stepIndex}, loop ${loopCounter}."
atomicState.remove("currentPatternParamsJson") // Clean up if stopped mid-pattern
return
}
// Retrieve pattern data from atomicState
String patternParamsJson = atomicState.currentPatternParamsJson
if (!patternParamsJson) {
log.error "processPatternStep: Could not find pattern data in atomicState.currentPatternParamsJson. Stopping alert."
stopAlertImmediate(reason: "Pattern data missing")
return
}
// Parse the consolidated JSON payload
def jsonSlurper = new JsonSlurper()
Map patternParams = [:]
try {
patternParams = jsonSlurper.parseText(patternParamsJson)
} catch (Exception e) {
log.error "Error parsing patternParamsJson from atomicState in processPatternStep: ${e.message}"
log.error "--> JSON was: ${patternParamsJson}"
stopAlertImmediate(reason: "Error parsing pattern data")
return
}
// Access parameters from the parsed map
List commands = patternParams.commands ?: []
Map colorMap = patternParams.colorMap // May be null
def repeatCount = patternParams.repeatCount
def isInfinite = patternParams.isInfinite
def level = patternParams.level
def offLevel = patternParams.offLevel
def patternType = patternParams.patternType
// Basic validation
if (commands.isEmpty() || repeatCount == null || isInfinite == null || patternType == null) {
log.error "processPatternStep: Missing essential parameters after parsing JSON. Parsed map: ${patternParams}. Stopping alert."
stopAlertImmediate(reason: "Invalid pattern data")
return
}
// Check if pattern loops are finished (for finite patterns)
if (!isInfinite && loopCounter >= repeatCount) {
logDebug "processPatternStep: Finite pattern completed (${loopCounter}/${repeatCount} loops). No more steps."
// Final cleanup is handled by the scheduled finalCleanupAndRestore
return
}
// Get the command for the current step index
if (stepIndex >= commands.size()) {
log.error "processPatternStep: stepIndex ${stepIndex} is out of bounds for commands list size ${commands.size()}. This shouldn't happen. Stopping alert."
stopAlertImmediate(reason: "Pattern step index error")
return
}
Map currentCommand = commands[stepIndex]
boolean turnOn = currentCommand.on
long duration = currentCommand.duration ?: 1000 // Default duration if missing
logDebug "processPatternStep: Loop ${loopCounter + 1}/${isInfinite ? 'Inf' : repeatCount}, Step ${stepIndex + 1}/${commands.size()}. Action: ${turnOn ? 'ON' : 'OFF'}, Duration: ${duration}ms"
// --- Apply command to ALL devices ---
settings.devices.each { device -> // Explicitly use settings.devices
try {
if (turnOn) {
def targetLevel = level ?: 100
// Apply color FIRST if specified
if (colorMap && hasColorCapability(device)) {
try {
// logDebug "Setting color map for ${device.displayName}: ${colorMap}" // Reduce noise
device.setColor(colorMap)
} catch (e) { log.warn "Error setting color map for ${device.displayName}: ${e.message}" }
}
// Apply level SECOND
if (hasLevelCapability(device)) {
try {
// logDebug "Setting level for ${device.displayName}: ${targetLevel}" // Reduce noise
device.setLevel(targetLevel)
} catch (e) { log.warn "Error setting level for ${device.displayName}: ${e.message}" }
}
// Ensure device is ON THIRD (after color/level)
// logDebug "Turning ON ${device.displayName}" // Reduce noise
device.on()
// logDebug "${device.displayName} is ON" // Reduce noise
} else { // OFF state logic
def targetOffLevel = offLevel ?: 0
if (targetOffLevel > 0 && hasLevelCapability(device)) {
// logDebug "Setting OFF level for ${device.displayName}: ${targetOffLevel}" // Reduce noise
device.setLevel(targetOffLevel)
// Ensure the device is ON to maintain the dim level
if (device.currentValue("switch") != "on") {
// logDebug "Turning ON ${device.displayName} to maintain OFF level" // Reduce noise
device.on()
}
// logDebug "${device.displayName} set to OFF Level ${targetOffLevel}" // Reduce noise
} else {
// Standard OFF behavior
// logDebug "Turning OFF ${device.displayName}" // Reduce noise
device.off()
// logDebug "${device.displayName} is OFF" // Reduce noise
}
}
} catch (Exception e) {
log.error "Error executing step ${stepIndex} on ${device.displayName}: ${e.message}"
// Continue to next device even if one fails
}
} // End devices.each
// --- Check if this was the absolute last step ---
boolean isLastStepOfLastLoop = !isInfinite && loopCounter == (repeatCount - 1) && stepIndex == (commands.size() - 1)
// --- Schedule the next step OR Force ON if last step ---
int nextStepIndex = stepIndex + 1
int nextLoopCounter = loopCounter
// Check if we finished the command list for this loop
if (nextStepIndex >= commands.size()) {
nextStepIndex = 0 // Reset to first step
nextLoopCounter++ // Increment loop counter
}
// Check again if alert is running before scheduling/forcing ON
if (!state.isAlertRunning) {
logDebug "processPatternStep: Alert stopped after processing step ${stepIndex}. Not scheduling next step or forcing ON."
return
}
if (isLastStepOfLastLoop) {
// This was the final step of a finite pattern
logDebug "processPatternStep: Executed final step of finite pattern. Forcing devices ON immediately."
settings.devices?.on() // Force ON immediately after last step
logDebug "processPatternStep: Finished last loop (${nextLoopCounter}/${repeatCount}). No more steps to schedule."
// Final cleanup (restore) is handled by the scheduled finalCleanupAndRestore
} else if (isInfinite || nextLoopCounter < repeatCount) {
// Schedule the next step if the pattern should continue
logDebug "processPatternStep: Scheduling next step (Index: ${nextStepIndex}, Loop: ${nextLoopCounter}) in ${duration}ms"
runInMillis(duration, "processPatternStep", [data: [stepIndex: nextStepIndex, loopCounter: nextLoopCounter], overwrite: false]) // Use overwrite: false to allow multiple steps pending
} else {
// This case should technically not be reached if isLastStepOfLastLoop is handled above, but kept as safety
logDebug "processPatternStep: Finished last loop (${nextLoopCounter}/${repeatCount}). No more steps to schedule."
// Final cleanup is handled by the scheduled finalCleanupAndRestore
}
}
// Old executeDevicePattern function removed
// Helper to set device attributes (level, color, CT)
private void setDeviceAttributes(device, Map attrs) {
logDebug "Setting attributes for ${device.displayName}: ${attrs}"
try {
// Set Color Temp or Color first
if (attrs.colorTemperature != null && hasColorTemperature(device)) {
device.setColorTemperature(attrs.colorTemperature)
logDebug "Set Color Temperature: ${attrs.colorTemperature}"
pauseExecution(300)
} else if (attrs.colorMap && hasColorCapability(device)) { // Check for colorMap first
try {
// Use setColor with the HSL map
device.setColor(attrs.colorMap)
logDebug "Set Color using setColor with Map: ${attrs.colorMap}"
pauseExecution(300)
} catch (e) {
logWarn "setColor with Map failed for ${device.displayName}: ${e.message}"
// Optional: Fallback to trying hex if map fails? Might be risky.
}
} else if (attrs.color && hasColorCapability(device)) { // Fallback to original color attribute if colorMap not present
try {
// Use setColor map if possible (original logic)
device.setColor(attrs.color) // Assumes color is a hex string or map
logDebug "Set Color using setColor (fallback): ${attrs.color}"
pauseExecution(300)
} catch (e) {
logWarn "setColor (fallback) failed for ${device.displayName}: ${e.message}"
}
}
// Set Level
if (attrs.level != null && hasLevelCapability(device)) {
device.setLevel(attrs.level)
logDebug "Set Level: ${attrs.level}"
pauseExecution(300)
}
} catch (Exception e) {
log.warn "Error setting attributes for ${device.displayName}: ${e.message}"
}
}
// --- Preview Logic Removed ---
// Preview functions (previewPattern, stopPreviewImmediate) removed.
// --- Utility Methods ---
// Convert Hex Color String (#RRGGBB) to Hubitat HSL Map [hue, saturation, level]
private Map hexToHslMap(String hexColor, Integer level = 100) {
if (!hexColor || !hexColor.matches("^#([A-Fa-f0-9]{6}|[A-Fa-f0-9]{3})\$")) {
log.warn "Invalid hex color format received: ${hexColor}. Defaulting to White."
// Default to White (Hue 0, Sat 0, Level 100) for Hubitat
return [hue: 0, saturation: 0, level: level ?: 100]
}
try {
// Remove #
String hex = hexColor.substring(1)
// Handle shorthand hex (e.g., #RGB)
if (hex.length() == 3) {
hex = hex.collect { it + it }.join()
}
// Convert hex to RGB (0-255)
Integer r = Integer.parseInt(hex.substring(0, 2), 16)
Integer g = Integer.parseInt(hex.substring(2, 4), 16)
Integer b = Integer.parseInt(hex.substring(4, 6), 16)
// Convert RGB to HSL (formulas adapted for Groovy)
BigDecimal r_ = r / 255.0
BigDecimal g_ = g / 255.0
BigDecimal b_ = b / 255.0
BigDecimal cmax = [r_, g_, b_].max()
BigDecimal cmin = [r_, g_, b_].min()
BigDecimal delta = cmax - cmin
BigDecimal hue = 0
BigDecimal saturation = 0
BigDecimal lightness = (cmax + cmin) / 2.0
if (delta != 0) {
BigDecimal satDenominator = (1.0 - (2.0 * lightness - 1.0).abs()) // Keep denominator check
if (satDenominator == 0) {
logWarn "Saturation denominator is zero, setting saturation to 0."
saturation = 0
} else {
saturation = delta / satDenominator
}
// Hue calculation (already guarded by delta != 0)
if (cmax == r_) {
hue = 60.0 * (((g_ - b_) / delta).remainder(6.0)) // Use remainder for BigDecimal
} else if (cmax == g_) {
hue = 60.0 * (((b_ - r_) / delta) + 2.0)
} else { // cmax == b_
hue = 60.0 * (((r_ - g_) / delta) + 4.0) // No modulo needed here based on standard HSL conversion
}
} else {
// If delta is 0, hue and saturation are 0 (it's a gray color)
hue = 0
saturation = 0
}
if (hue < 0) {
hue += 360.0
}
// Scale to Hubitat's 0-100 range
// Add checks before rounding/casting
if (hue == null || saturation == null) {
log.error "Hue or Saturation became null during calculation!"
return [hue: 0, saturation: 0, level: level ?: 100] // Default to white
}
// Scale hue
BigDecimal scaledHue = (hue / 360.0) * 100.0
Integer hubHue = 0 // Default value
if (scaledHue != null) {
try {
hubHue = scaledHue.intValue() // Use intValue() instead of round() as Integer
} catch (NullPointerException npe) {
log.error "NPE during hue rounding/casting: ${npe.message}. ScaledHue was: ${scaledHue}"
// Keep hubHue as 0
} catch (Exception e) { // Corrected line number based on previous diff
log.error "Error during hue rounding/casting: [${e.class.name}] ${e.message}. ScaledHue was: ${scaledHue}"
// Keep hubHue as 0
}
} else {
log.error "scaledHue was null before rounding!"
}
// Scale saturation
BigDecimal scaledSaturation = saturation * 100.0
Integer hubSaturation = 0 // Default value
if (scaledSaturation != null) {
try {
hubSaturation = scaledSaturation.intValue() // Use intValue() instead of round() as Integer
} catch (NullPointerException npe) { // Corrected line number based on previous diff
log.error "NPE during saturation rounding/casting: ${npe.message}. ScaledSaturation was: ${scaledSaturation}"
// Keep hubSaturation as 0
} catch (Exception e) { // Corrected line number based on previous diff
log.error "Error during saturation rounding/casting: [${e.class.name}] ${e.message}. ScaledSaturation was: ${scaledSaturation}"
// Keep hubSaturation as 0
}
} else {
log.error "scaledSaturation was null before rounding!"
}
// Return the map
return [hue: hubHue, saturation: hubSaturation, level: level ?: 100]
} catch (Exception e) {
log.error "Error converting hex color ${hexColor} to HSL map: ${e.message}"
return [hue: 0, saturation: 0, level: level ?: 100] // Default to white on any error
}
}
// Checks if a device has the ColorControl capability
private boolean hasColorCapability(device) {
return device.hasCapability("ColorControl")
}
// Checks if a device has the ColorTemperature capability
private boolean hasColorTemperature(device) {
return device.hasCapability("ColorTemperature")
}
// Checks if a device has the SwitchLevel capability
private boolean hasLevelCapability(device) {
return device.hasCapability("SwitchLevel")
}
// Send notification to selected devices and/or parent app
def sendNotification(String message) {
if (notificationDevices) {
logDebug "Sending notification to devices: ${message}"
notificationDevices.deviceNotification(message)
}
// Check if parent notifications are enabled
if (parent?.sendChildNotification) { // Check if method exists
try {
parent.sendChildNotification(message) // Send notification via parent
logDebug "Sent notification to parent app: ${message}"
} catch (Exception e) {
log.warn "Could not send notification to parent app: ${e.message}"
}
} else {
logDebug "Parent app notifications are disabled."
}
}
// Logging helper
void logDebug(String msg) {
if (enableLogging) {
log.debug msg
}
}
// Logging helper for warnings
void logWarn(String msg) {
log.warn msg // Warnings are always logged
}
// Helper to format section headers
private String getFormat(type, msg) {
if (type == "header-blue") { // Example type
msg = "