/** * Vacation Lighting Simulator (Child) * V0.4.0 - March 2026 * - Configuration checklist on main page (shows ✅/❌/⚠️ for each setting) * - Dynamic arming summary in Setup page (explains effective arming condition) * - Enhanced status dashboard: arming context, time since last cycle, frequency info * - Actionable "Why not running" diagnostics with specific hints * - Test cycle section collapsed by default; add clear button + last test result * - Time window page shows resolved today's window when sunrise/sunset used * - Fix: anchor lights no longer turned off during mode changes when app was not running * * V0.3.3 - February 2026 * - Fix to ensure schedule maintains over multiple days * - Option for notifications to be immediate after a session * V0.3.2.2 - January 2026 * - Small bug fix for daily summary (only sends if cycles > 0, only schedules when armed) * - Warning displayed if Hubitat timezone is not configured * - Small if check fix that prevents lights from staying on * V0.3.2 - January 2026 * - Converted to parent/child architecture (managed by Vacation Lighting Suite) * - Child app retains randomized scheduling, summaries, and test cycle tools * - Vacation switch ON bypasses both the configured time window and mode restriction (manual override) * - New: Added a Analysis tool to aggregate lighting stats and a timeline chart for how the lights behaved over a period of time. * * V0.2.5 - December 2025 Updated to: * - Turn on a set of lights during active time, and turn them off at end of vacation time * - Instantly shut off when leaving configured modes / vacation switch * - More pseudo random with per-light on duration = frequency_minutes ± ~20% (style B) * - Optionally send a daily summary notification with cycle and light-change counts * - Hubitat Performance Improvements: * - Use a single queue-based scheduler to turn lights off after per-light durations * - Use Hubitat `state` instead of `atomicState` * - Optional Vacation switch in addition to modes * - Only schedules when "armed" (by mode and/or vacation switch) * - Shows status + Last Cycle + "Next cycle" + debug reason on main page * - Test Functionality to run a one-off cycle (on main page under Options) * - Improved Debug and Trace logging * * Status icons: * 🔴 Not configured * 🟡 Idle (waiting for trigger: mode / vacation switch / etc.) * 🟢 Armed (ready; waiting for next cycle within allowed time window) * ✅ Active (currently simulating / queued) * * Based on original by tslagle and Eric Schott * Optimized for Hubitat with additional features December 2025 by Jed Brown * * Original source: * https://github.com/imnotbob/vacation-lighting-director/blob/master/smartapps/imnotbob/vacation-lighting-director.src/vacation-lighting-director.groovy * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import groovy.transform.Field import java.text.SimpleDateFormat @Field static final String APP_VERSION = "v0.4.0 • Mar 2026" definition( name: "Vacation Lighting Simulator", namespace: "Logicalnonsense", author: "Jed Brown", category: "Safety & Security", description: "Simulate light and switch behaviors of an occupied home while you are away or on Vacation.", iconUrl: "", iconX2Url: "", parent: "Logicalnonsense:Vacation Lighting Simulator Suite" ) preferences { page(name:"pageSetup") page(name:"Setup") page(name:"Settings") page(name:"timeIntervalPage") } // --------- Logging helpers --------- private Boolean isDescriptiveLoggingEnabled() { return (descriptiveLogging != null ? descriptiveLogging : true) } private Boolean isDebugLoggingEnabled() { return (debugLogging != null ? debugLogging : false) } private logInfo(msg) { if (isDescriptiveLoggingEnabled()) { log.info msg } } private logDebug(msg) { if (isDebugLoggingEnabled()) { log.debug msg } } private logTrace(msg) { if (isDebugLoggingEnabled()) { log.trace msg } } // --------- Version helper --------- private String appVersion() { APP_VERSION } // --------- Arming helper (mode + optional switch) --------- private getModeOk() { def result = !newMode || newMode.contains(location.mode) result } private boolean hasModeRestriction() { // newMode is typically a List when multiple:true; treat empty as "no restriction configured" if (newMode == null) return false if (newMode instanceof List) return !newMode.isEmpty() // Defensive: if Hubitat provides a scalar value return true } /** * armOk: * - If no vacationSwitch configured: rely on modes only * - If vacationSwitch configured: armed when (switch ON) OR (mode allowed) */ private getArmOk() { boolean modesConfigured = hasModeRestriction() boolean modeAllowed = modesConfigured ? (modeOk as boolean) : false // No vacation switch: rely on modes only (if modes aren't configured, we are not armed) if (!vacationSwitch) { return modeAllowed } boolean switchIsOn = vacationSwitchOn() // Vacation switch configured: // - If modes configured: armed when (switch ON) OR (mode allowed) // - If modes NOT configured: armed only when switch is ON return modesConfigured ? (switchIsOn || modeAllowed) : switchIsOn } private boolean vacationSwitchOn() { return vacationSwitch && (vacationSwitch.currentSwitch == "on") } // --------- Status helpers --------- private String nextCycleStatus() { def ts = state?.nextCycleAtMs if (!ts) return "not scheduled." long diff = (ts as Long) - now() if (diff <= 0) return "any moment." int mins = Math.round(diff / 60000.0) if (mins <= 1) return "~1 minute." return "~${mins} minutes." } private String timeSinceLastCycle() { def ts = state?.lastCycleAt if (!ts) return null long diff = now() - (ts as Long) int mins = Math.round(diff / 60000.0) if (mins <= 1) return "~1 minute ago" return "~${mins} minutes ago" } private String armingContext() { boolean modesConfigured = hasModeRestriction() boolean switchIsOn = vacationSwitchOn() boolean modeAllowed = modesConfigured && (modeOk as boolean) if (switchIsOn && modeAllowed) { return "Armed by: ${location.mode} mode + vacation switch ON" } else if (switchIsOn) { return "Armed by: vacation switch override" } else if (modeAllowed) { return "Armed by: ${location.mode} mode" } return "" } private String activeLightsWarning() { if (!switches || !number_of_active_lights) return "" int n = (number_of_active_lights as Integer) int s = switches.size() if (n > s) { return "Note: Active lights per cycle (${n}) exceeds configured lights (${s}); app will clamp to ${s}." } if (n < 1) { return "Warning: Active lights per cycle must be at least 1." } return "" } /** * Explain why the app is not running / not armed based on current conditions. * Returns an HTML string with actionable hints after each reason. */ private String whyNotRunning() { def reasons = [] if (newMode && !modeOk && !vacationSwitchOn()) { def modeList = (newMode instanceof List) ? newMode.join(", ") : "${newMode}" reasons << "Current mode '${location.mode}' is not in allowed modes [${modeList}]. " + "Go to Setup to change allowed modes, or turn on the vacation switch to override." } // Vacation switch reasons depend on whether modes are configured. if (vacationSwitch && !vacationSwitchOn()) { def sw = vacationSwitch.currentSwitch ?: 'unknown' if (!hasModeRestriction()) { reasons << "Vacation switch '${vacationSwitch.displayName}' is ${sw} — turn it ON to arm the app." } else if (!modeOk) { reasons << "Mode '${location.mode}' is not allowed and vacation switch '${vacationSwitch.displayName}' is ${sw}. " + "Turning the switch ON would bypass the mode restriction." } } if (!daysOk && days) { def df = new java.text.SimpleDateFormat("EEEE") if (getTimeZone()) df.setTimeZone(getTimeZone()) def today = df.format(new Date()) def dayList = (days instanceof List) ? days.join(", ") : "${days}" reasons << "Today is ${today}, which is not in the allowed days [${dayList}]. " + "Check Settings → Advanced to adjust day restrictions." } if (!timeOk && !vacationSwitchOn() && (startTimeType || endTimeType || starting || ending)) { def win = timeIntervalLabel() def tz = getTimeZone() def fmt = new SimpleDateFormat("h:mm a") if (tz) fmt.setTimeZone(tz) def nowStr = fmt.format(new Date()) if (win) { reasons << "Current time (${nowStr}) is outside the configured window (${win}). " + "Go to Setup → Time window to adjust, or turn on the vacation switch to bypass." } else { reasons << "Current time (${nowStr}) is outside the configured time window. " + "Go to Setup → Time window to review." } } if (!reasons) { return "No specific blockers detected — check that lights are configured in Setup." } return reasons.join("
") } private String appStatus() { def modeName = location?.mode ?: "Unknown" def configured = (switches && (newMode || vacationSwitch)) // Check for TimeZone issue if (!location.timeZone) { return "⚠️ Warning: Hub TimeZone is not set. Time windows cannot be calculated, so the app will run 24/7 when armed.
Please set your TimeZone in Hubitat Settings." } // Counters def cycles = state?.cycles ?: 0 def lightsOn = state?.lightsOn ?: 0 def lightsOff= state?.lightsOff?: 0 List lastRand = (state.lastCycleRandomized ?: []) as List List lastAnch = (state.lastCycleAnchors ?: []) as List StringBuilder sb = new StringBuilder() if (!configured) { sb << "Status: 🔴 Not fully configured.
" sb << "Current mode: ${modeName}
" sb << "Configure modes and/or a vacation switch, plus your lights to enable vacation lighting." return sb.toString() } boolean armed = armOk boolean running = state?.Running ?: false boolean sched = state?.schedRunning ?: false boolean queued = (state?.lightSchedule instanceof Map) && !state.lightSchedule.isEmpty() boolean switchOverride = vacationSwitchOn() boolean inWindow = (timeOk || switchOverride) String icon String color String label if (running || sched || queued) { icon = "✅" color = "#008800" label = "Active (simulating occupancy)" } else if (armed && inWindow) { icon = "🟢" color = "#008800" label = "Armed (ready)" } else if (armed) { icon = "🟡" color = "#cc9900" label = "Armed (outside time window)" } else { icon = "🟡" color = "#cc9900" label = "Idle (waiting for trigger)" } sb << "Status: ${icon} ${label}
" sb << "Current mode: ${modeName}" if (vacationSwitch) { sb << "   Vacation switch: ${vacationSwitch.currentSwitch ?: 'unknown'}" } sb << "
" // Arming context — show what is keeping the app armed if (armed || running || sched || queued) { String ctx = armingContext() if (ctx) { sb << "${ctx}
" } } // Frequency/pool summary if (switches) { Integer freq = (frequency_minutes ?: 15) as Integer Integer numActive = (number_of_active_lights ?: 1) as Integer sb << "Cycling every ~${freq} min with up to ${numActive} light(s) from a pool of ${switches.size()}
" } // Last cycle info if (!lastRand.isEmpty()) { sb << "Last cycle randomized: ${lastRand.join(', ')}
" } if (!lastAnch.isEmpty()) { sb << "Anchor lights in last cycle: ${lastAnch.join(', ')}
" } // Time since last cycle + next cycle countdown String sinceStr = timeSinceLastCycle() String nextStr = nextCycleStatus() if (sinceStr) { sb << "Last cycle: ${sinceStr}. Next cycle: ${nextStr}
" } else { sb << "Next cycle: ${nextStr}
" } sb << "Since last summary: ${cycles} cycles, ${lightsOn} light-ons, ${lightsOff} light-offs.
" // What would trigger it when armed but outside window if (armed && !inWindow) { def winLabel = timeIntervalLabel() if (winLabel) { sb << "Will run: ${winLabel}
" } } def warn = activeLightsWarning() if (warn) { sb << "Notes: ${warn}
" } // Diagnostics when not actively cycling if (!running && !sched && !queued) { String diagLabel = armed ? "Why not cycling" : "Why not running" def debugLine = whyNotRunning() if (debugLine) { sb << "${diagLabel}: ${debugLine}" } } return sb.toString() } // --------- UI formatting helpers --------- private String getFormat(String type, String myText = "") { switch(type) { case "header-blue": // Blue bar with white bold text return "
${myText}
" case "line-blue": // Thin blue separator line return "
" default: return myText } } /** * Build a quick-glance configuration checklist for the main page. * Shows ✅/❌/⚠️ for each key setting so new users can see what's missing at a glance. */ private String configChecklist() { StringBuilder sb = new StringBuilder() sb << "
" // Arming boolean hasModes = hasModeRestriction() boolean hasSwitch = (vacationSwitch != null) if (hasModes && hasSwitch) { def modeList = (newMode instanceof List) ? newMode.join(", ") : "${newMode}" sb << "✅ Arming: ${modeList} + vacation switch
" } else if (hasModes) { def modeList = (newMode instanceof List) ? newMode.join(", ") : "${newMode}" sb << "✅ Arming: ${modeList} mode(s)
" } else if (hasSwitch) { sb << "✅ Arming: vacation switch only
" } else { sb << "❌ Arming: not configured — go to Setup to choose modes or a vacation switch
" } // Lights if (switches && switches.size() > 0) { int numActive = (number_of_active_lights ?: 1) as Integer int numSwitches = switches.size() if (numActive > numSwitches) { sb << "⚠️ Lights: ${numSwitches} switches, active count (${numActive}) exceeds pool — will clamp to ${numSwitches}
" } else { sb << "✅ Lights: ${numSwitches} switches, up to ${numActive} per cycle
" } } else { sb << "❌ Lights: no switches configured — go to Setup to add lights
" } // Time window def winLabel = timeIntervalLabel() if (winLabel) { sb << "✅ Time window: ${winLabel}
" } else { sb << "⚠️ Time window: not set — runs any time when armed
" } // Notifications if (summaryDevice) { sb << "✅ Notifications: configured
" } else { sb << "⚠️ Notifications: not configured
" } sb << "
" return sb.toString() } /** * Return a one-sentence summary of the effective arming condition based on current inputs. * Used in Setup() with submitOnChange so it re-renders as the user configures modes/switch. */ private String dynamicArmingSummary() { boolean hasModes = hasModeRestriction() boolean hasSwitch = (vacationSwitch != null) if (!hasModes && !hasSwitch) { return "⚠️ Nothing configured — app will not arm." } else if (hasModes && !hasSwitch) { def modeList = (newMode instanceof List) ? newMode.join(", ") : "${newMode}" return "App arms when mode is: ${modeList}" } else if (!hasModes && hasSwitch) { def swName = vacationSwitch?.displayName ?: "selected vacation switch" return "App arms only when ${swName} switch is ON" } else { def modeList = (newMode instanceof List) ? newMode.join(", ") : "${newMode}" def swName = vacationSwitch?.displayName ?: "selected vacation switch" return "App arms when mode is ${modeList}, OR when ${swName} switch is ON " + "(switch also bypasses time/day restrictions)" } } /** * Return a human-readable resolved time window for today using sunrise/sunset data. * Only returns a value when both start and end are configured. */ private String resolvedTimeWindow() { if (!startTimeType && !starting) return null if (!endTimeType && !ending) return null def start = timeWindowStart() def stop = timeWindowStop() if (!start || !stop) return null def tz = getTimeZone() if (!tz) return null def fmt = new SimpleDateFormat("h:mm a") fmt.setTimeZone(tz) return "Today's window: ${fmt.format(start)} → ${fmt.format(stop)}" } // --------- PAGES --------- // Show main page def pageSetup() { def pageProperties = [ name: "pageSetup", title: "", // remove automatic Hubitat title nextPage: null, install: true, uninstall: true ] return dynamicPage(pageProperties) { // --- VERSION + STATUS BLOCK --- section("") { paragraph "
${appVersion()}
" paragraph getFormat("header-blue", "Status") paragraph appStatus() } // --- CONFIGURATION CHECKLIST --- section("") { paragraph getFormat("header-blue", "Configuration") paragraph configChecklist() } section("") { paragraph "This app simulates occupancy when you are away. Use the sections below to configure when it runs and which lights it controls." } // --- SETUP BLOCK --- section("") { paragraph getFormat("header-blue", "🛠 Setup") href "Setup", title: "Define triggers, time window, and which lights to use", description: "" } // --- SETTINGS BLOCK --- section("") { paragraph getFormat("header-blue", "⚙️ Settings") href "Settings", title: "Configure delays, daily summary, days, and advanced options", description: "" } // --- LABEL --- section("") { label title: "Assign a name for this app (useful if you have multiple instances):", required: false } // --- TEST & DIAGNOSTICS (collapsed by default) --- section(title: "Test & Diagnostics", hideable: true, hidden: true) { paragraph "Run a one-off cycle to verify lights are configured correctly. " + "Anchor lights will stay on until the session ends naturally or you use the clear option below." if (state?.lastTestSummary) { paragraph "Last test: ${state.lastTestSummary}" } input "runTestNow", "bool", title: "Run a test cycle now", defaultValue: false, submitOnChange: false input "clearTestNow", "bool", title: "Clear test state (turn off anchor lights left on by test)", defaultValue: false, submitOnChange: false } } } // Show "Setup" page def Setup() { def newModeInput = [ name: "newMode", type: "mode", title: "Modes (e.g., Away/Vacation)", multiple: true, required: false, submitOnChange: true, description: "Optional. If left blank, Modes will not arm the app." ] def vacationSwitchInput = [ name: "vacationSwitch", type: "capability.switch", title: "Vacation switch (optional – ON enables app)", required: false, multiple: false, submitOnChange: true, description: "Optional. If set, the app runs when this switch is ON OR when an allowed Mode is active. Switch ON also bypasses mode/time restrictions as a manual override." ] def switchesInput = [ name: "switches", type: "capability.switch", title: "Switches to randomize", multiple: true, required: false, description: "Required for the app to actually run, but you can configure time window first." ] def frequencyInput = [ name: "frequency_minutes", type: "number", title: "Minutes between cycles (5–180)", range: "5..180", required: false, description: "Optional. Default is 15 minutes if left blank." ] def numberActiveInput = [ name: "number_of_active_lights", type: "number", title: "Number of active lights per cycle", range: "1..999", required: false, description: "Optional. Default is 1 light per cycle if left blank." ] def anchorLightsInput = [ name: "on_during_active_lights", type: "capability.switch", title: "Anchor lights (on during active times, not randomized)", multiple: true, required: false, description: "Optional. These stay on whenever the app is active." ] def pageProperties = [ name: "Setup", title: "Setup", nextPage: "pageSetup" ] return dynamicPage(pageProperties) { // When should this run? section("") { paragraph getFormat("header-blue", "When should this run?") paragraph "Choose how vacation lighting is armed. You can use Modes, a Vacation switch, or both." input newModeInput input vacationSwitchInput paragraph dynamicArmingSummary() } // Time window section("") { paragraph getFormat("header-blue", "Time window") paragraph "Optional but recommended. If you skip this, the app can run any time it is armed." href "timeIntervalPage", title: "Time window", description: timeIntervalLabel() ?: "Tap to set a time window (optional)." } // Which lights and how section("") { paragraph getFormat("header-blue", "Which lights and how?") input switchesInput input frequencyInput input numberActiveInput } // Anchor lights section("") { paragraph getFormat("header-blue", "Anchor lights") paragraph "Anchor lights are not randomized. They turn on whenever the app is active and off when the session ends." input anchorLightsInput } } } // Show "Settings" page def Settings() { def falseAlarmThresholdInput = [ name: "falseAlarmThreshold", type: "decimal", title: "Delay before first cycle (minutes)", required: false, description:"Optional. Default is 2 minutes if left blank." ] def daysInput = [ name: "days", type: "enum", title: "Only on certain days of the week", multiple: true, required: false, options: ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"], description:"Optional. If blank, runs on all days." ] def pageProperties = [ name: "Settings", title: "Settings", nextPage: "pageSetup" ] return dynamicPage(pageProperties) { // --- Basic behavior --- section("") { paragraph getFormat("header-blue", "Behavior") input falseAlarmThresholdInput } // --- Summary notifications --- section("") { paragraph getFormat("header-blue", "Summary notifications") paragraph "Get a summary of how many cycles and light changes ran." input "summaryDevice", "capability.notification", title: "Notification devices for summary", required: false, multiple: true, description: "Optional. Select one or more devices to receive summaries." input "summaryMode", "enum", title: "When to send summary", options: ["daily": "Daily at specified time", "session": "After each session ends"], defaultValue: "daily", required: false, submitOnChange: true, description: "Daily: send at a fixed time each day. Session: send when time window closes, switch turns off, or mode changes." if (summaryMode == null || summaryMode == "daily") { input "summaryTime", "time", title: "Time each day to send summary", required: false, description: "Optional. If not set, no daily summary will be sent." } } // --- Advanced header bar --- section("") { paragraph getFormat("header-blue", "Advanced settings") } // --- Advanced (collapsed by default) --- section(title: "Advanced settings", hideable: true, hidden: true) { paragraph "Days restrictions
If today is not in the allowed days list, the app will not run." input daysInput paragraph "Logging" input "descriptiveLogging", "bool", title: "Enable descriptive logging?", defaultValue: true, required: false, description: "Info-level status messages (recommended)." input "debugLogging", "bool", title: "Enable debug logging?", defaultValue: false, required: false, description: "Includes debug + trace details for troubleshooting." } } } def timeIntervalPage() { dynamicPage(name: "timeIntervalPage", title: "Only during a certain time") { section { input "startTimeType", "enum", title: "Starting at", options: [time: "A specific time", sunrise: "Sunrise", sunset: "Sunset"], submitOnChange: true, description: "Optional. Leave blank to allow starting at any time." if (startTimeType in ["sunrise","sunset"]) { input "startTimeOffset", "number", title: "Offset in minutes (+/-)", range: "*..*", required: false, description: "Optional. Positive = after, negative = before." } else { input "starting", "time", title: "Start time", required: false, description: "Optional. If blank, no specific start time." } } section { input "endTimeType", "enum", title: "Ending at", options: [time: "A specific time", sunrise: "Sunrise", sunset: "Sunset"], submitOnChange: true, description: "Optional. Leave blank to allow running indefinitely after start." if (endTimeType in ["sunrise","sunset"]) { input "endTimeOffset", "number", title: "Offset in minutes (+/-)", range: "*..*", required: false, description: "Optional. Positive = after, negative = before." } else { input "ending", "time", title: "End time", required: false, description: "Optional. If blank, no specific end time." } // Show resolved window for today when both start and end are configured def resolved = resolvedTimeWindow() if (resolved) { paragraph "${resolved}" } } } } // --------- LIFECYCLE --------- def installed() { state.Running = false state.schedRunning = false state.startendRunning = false state.cycles = 0 state.lightsOn = 0 state.lightsOff = 0 state.lightSchedule = [:] state.offTickScheduled = false state.nextCycleAtMs = null // Track which devices have been used for summaries state.summaryOnNames = [] state.summaryOffNames = [] // Track last cycle devices state.lastCycleRandomized = [] state.lastCycleAnchors = [] state.lastCycleAt = null initialize() } def updated() { // Snapshot flags before re-init (settings are read at render time) def doTest = runTestNow def doClear = clearTestNow unsubscribe() clearState(true, true) initialize() // Handle test AFTER normal initialization if (doTest) { runTestCycle() app.updateSetting("runTestNow", [value: "false", type: "bool"]) } // Clear test state: lights already turned off by clearState(true, true) above. // Reset lastTestSummary and toggle so the UI reflects the cleared state. if (doClear) { state.lastTestSummary = null app.updateSetting("clearTestNow", [value: "false", type: "bool"]) logInfo "[TEST] Test state cleared by user." } } /** * initialize(): * - Always subscribes to mode/vacationSwitch. * - Only schedules daily summary, start/end, and initCheck if armOk is true. */ def initialize() { if (hasModeRestriction()) { subscribe(location, "mode", modeChangeHandler) } if (vacationSwitch) { subscribe(vacationSwitch, "switch", vacationSwitchHandler) } if (armOk) { // Schedule daily summary only when armed scheduleSummary() // Only set up time window + scheduling if we are currently armed schedStartEnd() logInfo "Initialized while armed; scheduling checks. Settings: ${settings}" setSched() } else { state.schedRunning = false state.startendRunning = false logInfo "Initialized while NOT armed (mode='${location.mode}', vacationSwitch='${vacationSwitch ? vacationSwitch.currentSwitch : "n/a"}')." } } /** * Clear state and optionally turn off managed lights. * turnOff = true when we want a hard stop (wrong arm state, mode, etc.). */ def clearState(turnOff = false, boolean unscheduleStartEnd = false) { if (turnOff) { // Turn off any lights we have queued def schedule = state.lightSchedule ?: [:] schedule.each { devId, offTs -> def dev = switches?.find { it.id == devId } if (dev) { dev.off() state.lightsOff = (state.lightsOff ?: 0) + 1 } } state.lightSchedule = [:] state.offTickScheduled = false unschedule(offTick) // Turn off anchor lights if (on_during_active_lights) { on_during_active_lights.each { dev -> dev.off() state.lightsOff = (state.lightsOff ?: 0) + 1 } } logTrace "All OFF due to clearState(true)" } state.Running = false state.schedRunning = false state.lastUpdDt = null state.nextCycleAtMs = null // Always stop cycle engine timers unschedule(initCheck) unschedule(failsafe) unschedule(offTick) // Only remove daily start/end triggers when truly disarmed if (unscheduleStartEnd) { unschedule(startTimeCheck) unschedule(endTimeCheck) state.startendRunning = false } } /** * schedStartEnd(): * - Only schedules start/end time checks when armOk is true. */ def schedStartEnd() { if (!armOk) { logTrace "schedStartEnd(): not armed, not scheduling start/end." state.startendRunning = false return } state.startendRunning = false def nowDt = new Date() if (starting != null || startTimeType != null) { def start = timeWindowStart(true) if (start) { if (start.before(nowDt)) { start = new Date(start.time + 24L * 60L * 60L * 1000L) } runOnce(start, startTimeCheck) state.startendRunning = true } } if (ending != null || endTimeType != null) { def end = timeWindowStop(true) if (end) { if (end.before(nowDt)) { end = new Date(end.time + 24L * 60L * 60L * 1000L) } runOnce(end, endTimeCheck) state.startendRunning = true } } logTrace "schedStartEnd(): startendRunning = ${state.startendRunning}" } /** * Schedule initial check after a delay (falseAlarmThreshold). * Ensures we pass an Integer to runIn() and tracks nextCycleAtMs. */ def setSched() { state.schedRunning = true // Use default 2 minutes if not set def base = (falseAlarmThreshold != null && falseAlarmThreshold != "") ? falseAlarmThreshold : 2 Integer delaySec = ((base as BigDecimal) * 60) as Integer // Bounds checking if (delaySec < 1) { logDebug "setSched(): delay too small (${delaySec}), clamping to 1 second" delaySec = 1 } if (delaySec > 86400) { // Max 24 hours logDebug "setSched(): delay too large (${delaySec}), clamping to 86400 seconds (24 hours)" delaySec = 86400 } long nowMs = now() state.nextCycleAtMs = nowMs + (delaySec * 1000L) logTrace "setSched() scheduling initCheck in ${delaySec} seconds" runIn(delaySec, initCheck) } // --------- Handlers --------- def modeChangeHandler(evt) { logTrace "modeChangeHandler ${evt}, armOk=${armOk}" if (!armOk) { logDebug "modeChangeHandler: armOk=false (mode='${location.mode}', switch='${vacationSwitch ? vacationSwitch.currentSwitch : "n/a"}') - clearing and unscheduling." sendSessionSummary("mode changed to '${location.mode}'") boolean wasRunning = state.Running || state.schedRunning clearState(wasRunning, true) } else { logDebug "modeChangeHandler: armOk=true - scheduling vacation lighting." state.schedRunning = false state.startendRunning = false schedStartEnd() setSched() scheduleSummary() } } def vacationSwitchHandler(evt) { logTrace "vacationSwitchHandler ${evt}, armOk=${armOk}" if (!armOk) { logInfo "Vacation switch changed to ${evt.value}, armOk=false - clearing and unscheduling." sendSessionSummary("vacation switch turned off") clearState(true, true) } else { logInfo "Vacation switch changed to ${evt.value}, armOk=true - scheduling vacation lighting." state.schedRunning = false state.startendRunning = false schedStartEnd() setSched() scheduleSummary() } } def initCheck() { scheduleCheck(null) } def failsafe() { scheduleCheck(null) } /** * startTimeCheck(): * - Only schedules next cycle if still armed. * - Reschedules start/end triggers for the next day. */ def startTimeCheck() { logTrace "startTimeCheck" // Mark trigger as consumed so self-heal logic knows to reschedule state.startendRunning = false if (armOk && daysOk) { setSched() } else { logDebug "startTimeCheck(): not scheduling (armOk=${armOk}, daysOk=${daysOk})." } // Reschedule start/end triggers for tomorrow while still armed if (armOk) { schedStartEnd() } } /** * endTimeCheck(): * - If still armed, lets scheduleCheck handle time window. * - If not armed, ensures everything is cleared. * - Reschedules start/end triggers for the next day. */ def endTimeCheck() { logTrace "endTimeCheck" // Mark trigger as consumed so self-heal logic knows to reschedule state.startendRunning = false if (armOk) { // Send session summary before shutting down (if in session mode) sendSessionSummary("time window ended") // Let scheduleCheck handle shutting things down due to time window scheduleCheck(null) // Reschedule start/end triggers for tomorrow schedStartEnd() } else { logDebug "endTimeCheck(): armOk=false, ensuring everything is cleared." clearState(true, true) } } // --------- Time helpers --------- def getDtNow() { def now = new Date() return formatDt(now) } def formatDt(dt) { def tf = new SimpleDateFormat("E MMM dd HH:mm:ss z yyyy") if (getTimeZone()) { tf.setTimeZone(getTimeZone()) } else { log.warn "TimeZone is not found or is not set..." } return tf.format(dt) } def GetTimeDiffSeconds(lastDate) { if (lastDate?.contains("dtNow")) { return 10000 } def now = new Date() def lastDt = Date.parse("E MMM dd HH:mm:ss z yyyy", lastDate) def start = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(lastDt)).getTime() def stop = Date.parse("E MMM dd HH:mm:ss z yyyy", formatDt(now)).getTime() def diff = (int) (long) (stop - start) / 1000 return diff } def getTimeZone() { def tz = null if (location?.timeZone) { tz = location.timeZone } if (!tz) { log.warn "getTimeZone: TimeZone is not found or is not set..." } return tz } def getLastUpdSec() { return !state?.lastUpdDt ? 100000 : GetTimeDiffSeconds(state?.lastUpdDt).toInteger() } // --------- MAIN LOGIC: cycles + queue --------- private getAllOk() { armOk && daysOk && (timeOk || vacationSwitchOn()) } /** * Extracted core of a single cycle: * - increments cycle counter * - chooses random lights * - turns them on and queues them for off * - turns on anchor lights * Does NOT schedule next cycle or touch lastUpdDt / nextCycleAtMs. */ private void doCycleCore() { def eligible = switches if (!eligible || !eligible.size()) { log.warn "No switches configured or available" return } def random = new Random() state.cycles = (state.cycles ?: 0) + 1 Integer cycleNum = (state.cycles ?: 0) as Integer def numlight = (number_of_active_lights ?: 1) as Integer // Enforce lower bound if (numlight < 1) numlight = 1 // Enforce upper bound if (numlight > eligible.size()) numlight = eligible.size() Integer freq = (frequency_minutes ?: 15) as Integer if (freq < 5) freq = 5 if (freq > 180) freq = 180 logDebug "Running cycle #${cycleNum}: freq=${freq} min, mode='${location.mode}'" def usedIdx = [] def selectedNames = [] for (int i = 0; i < numlight; i++) { int idx = random.nextInt(eligible.size()) while (usedIdx.contains(idx) && usedIdx.size() < eligible.size()) { idx = random.nextInt(eligible.size()) } if (usedIdx.contains(idx)) { break } usedIdx << idx def dev = eligible[idx] def dn = dev.displayName ?: dev.name selectedNames << dn dev.on() state.lightsOn = (state.lightsOn ?: 0) + 1 // Track this device as having been turned on def onList = (state.summaryOnNames ?: []) as List if (!onList.contains(dn)) { onList << dn } state.summaryOnNames = onList scheduleLightOff(dev) logTrace "Cycle #${cycleNum}: turned ON '${dn}' (randomized, will auto-off later)" } // High-signal line: which lights were chosen this cycle if (selectedNames) { logTrace "[cycle=${cycleNum}] Starting: choosing ${numlight} of ${eligible.size()} lights " + "(mode='${location.mode}', freq=${freq} min) — ${selectedNames.join(', ')}" } else { logTrace "[cycle=${cycleNum}] Starting: choosing 0 of ${eligible.size()} lights " + "(mode='${location.mode}', freq=${freq} min) — none selected" } // Anchor lights stay on while simulation is running; not queued def anchorNames = [] if (on_during_active_lights) { on_during_active_lights.each { dev -> dev.on() state.lightsOn = (state.lightsOn ?: 0) + 1 def dn = dev.displayName ?: dev.name anchorNames << dn def onList = (state.summaryOnNames ?: []) as List if (!onList.contains(dn)) { onList << dn } state.summaryOnNames = onList logTrace "Cycle #${cycleNum}: turned ON anchor '${dn}' (stays on while app is active)" } } // Update "last cycle" info for Status UI state.lastCycleRandomized = selectedNames state.lastCycleAnchors = anchorNames state.lastCycleAt = new Date().time } def scheduleCheck(evt) { // normalize frequency to an Integer Integer freq = (frequency_minutes ?: 15) as Integer // Clamp to UI's valid range if (freq < 5) freq = 5 if (freq > 180) freq = 180 if (allOk && getLastUpdSec() > ((freq - 1) * 60)) { state.lastUpdDt = getDtNow() logDebug "Running scheduled vacation lighting cycle (freq=${freq} min, last run ${getLastUpdSec()} sec ago)" state.Running = true doCycleCore() // schedule next cycle & failsafe def random = new Random() def random_int = random.nextInt(14) logTrace "scheduleCheck(): scheduling next cycle in ${(freq + random_int)} minutes (freq=${freq}, jitter=${random_int})" Integer nextSec = ((freq + random_int) * 60) as Integer Integer failsafeSec = ((freq + random_int + 10) * 60) as Integer if (nextSec < 1) nextSec = 1 if (failsafeSec < 1) failsafeSec = 1 long nowMs = now() state.nextCycleAtMs = nowMs + (nextSec * 1000L) runIn(nextSec, initCheck, [overwrite: true]) runIn(failsafeSec, failsafe, [overwrite: true]) } else if (allOk && getLastUpdSec() <= ((freq - 1) * 60)) { Integer remaining = (freq * 60 - getLastUpdSec()) if (remaining < 1) remaining = 1 logTrace "scheduleCheck(): too soon since last cycle (${getLastUpdSec()} sec); will retry in ${remaining} sec" state.nextCycleAtMs = now() + (remaining * 1000L) runIn(remaining, initCheck, [overwrite: true]) } else if (!armOk) { if ((state?.Running ?: false) || (state?.schedRunning ?: false)) { logDebug "scheduleCheck(): disarmed - stopping Vacation Lights" clearState(true, true) } } else if (!daysOk) { if ((state?.Running ?: false) || (state?.schedRunning ?: false)) { logDebug "scheduleCheck(): wrong day - stopping Vacation Lights" clearState(true, false) } } else if (armOk && daysOk && !timeOk && !vacationSwitchOn()) { if ((state?.Running ?: false) || (state?.schedRunning ?: false)) { logDebug "scheduleCheck(): wrong time window - stopping Vacation Lights" clearState(true, false) } // Self-heal: if start/end checks were somehow lost, recreate them if (!(state.startendRunning ?: false)) { schedStartEnd() } } // Only (re)schedule start/end when armed if (armOk && !(state.startendRunning ?: false)) { schedStartEnd() } return true } // --- Test cycle (one-off) --- def runTestCycle() { logInfo "[TEST] Test cycle requested" if (!switches || !switches.size()) { logInfo "[TEST] No switches configured, aborting test." return } logInfo "[TEST] Running one test cycle regardless of mode/time/day. " + "Current mode='${location.mode}', vacationSwitch='${vacationSwitch ? vacationSwitch.currentSwitch : "n/a"}'." // // We do *not* increment or manipulate state.cycles here yet — doCycleCore() will do that. // We also do NOT snapshot before/after values, because tests always run exactly 1 cycle, // // Force a cycle (ignoring restrictions) state.Running = true doCycleCore() // // Build the list of lights used in this *test* cycle. // List testRandom = (state.lastCycleRandomized ?: []) as List List testAnchors = (state.lastCycleAnchors ?: []) as List List testOnNames = (testRandom + testAnchors) as List // dCycles = always 1 for test Integer dCycles = 1 // dOn counts number of lights turned on in this cycle Integer dOn = testOnNames.size() // dOff is 0 at test completion (offs will happen asynchronously later) Integer dOff = 0 // Store a short summary for display on the main page def tz = getTimeZone() def timeStr = tz ? new Date().format("MMM d, h:mm a", tz) : new Date().toString() def lightsStr = testOnNames ? testOnNames.join(", ") : "none" state.lastTestSummary = "Ran at ${timeStr}: ${dCycles} cycle, ${dOn} light(s) on — ${lightsStr}. " + "Anchor lights stay on until cleared or session ends." sendTestSummary(dCycles, dOn, dOff, testOnNames) // Test cycle is done. // We do NOT clear queued offs — those must continue running. // But we DO stop reporting "Active" in the UI. state.Running = false state.schedRunning = false } private void sendTestSummary(Integer dCycles, Integer dOn, Integer dOff, List testOnNames) { if (!summaryDevice) { logDebug "[TEST] Test summary not sent — no summaryDevice configured" return } // Build unified formatted message (using our shared formatter) String msg = buildSummaryMessage( "test", dCycles, dOn, dOff, testOnNames, [] // no test-off names; offs happen later ) logDebug "[TEST] Sending test summary: ${msg}" summaryDevice*.deviceNotification(msg) } // --- Queue-based per-light off scheduling --- /** * Determine how long each light should stay on (minutes). * Style B: base on frequency_minutes ± ~20% jitter, clamped to 5–180 minutes. */ private Integer getLightOnDuration() { Integer base = (frequency_minutes ?: 15) as Integer if (base < 5) base = 5 if (base > 180) base = 180 // ±20% jitter around base Integer jitter = Math.round(base * 0.2) if (jitter < 1) jitter = 1 Integer min = base - jitter Integer max = base + jitter if (min < 1) min = 1 def rnd = new Random() Integer dur = rnd.nextInt(max - min + 1) + min logTrace "Light duration: base=${base} min, jitter=±${jitter} → range ${min}-${max} min, chosen=${dur} min" return dur } /** * Add a light to the off queue. */ private scheduleLightOff(dev) { def durationMins = getLightOnDuration() def now = new Date() long offAt = now.time + (durationMins * 60 * 1000L) def schedule = state.lightSchedule ?: [:] schedule[dev.id as String] = offAt state.lightSchedule = schedule Integer queueSize = schedule.size() def dn = dev.displayName ?: dev.name logTrace "Queueing off: '${dn}' in ${durationMins} min at ${new Date(offAt)} (queue size now ${queueSize})" ensureOffTickScheduled() } /** * Make sure the offTick loop is running if there are lights in the queue. */ private ensureOffTickScheduled() { if (!(state.offTickScheduled ?: false)) { state.offTickScheduled = true logTrace "ensureOffTickScheduled(): scheduling offTick in 60 seconds" runIn(60, "offTick", [overwrite: true]) } } /** * Periodic tick: check which lights should be turned off now. */ def offTick() { def schedule = state.lightSchedule ?: [:] if (!schedule || schedule.isEmpty()) { state.offTickScheduled = false logTrace "offTick(): queue empty, stopping off-tick loop (no more scheduled offs)" return } long nowMs = now() def toTurnOff = [] schedule.each { devId, offAt -> if ((offAt as Long) <= nowMs) { toTurnOff << devId } } if (!toTurnOff.isEmpty()) { logTrace "offTick(): ${toTurnOff.size()} light(s) reached their off time" } // Build map for O(1) lookup def switchMap = switches?.collectEntries { [(it.id as String): it] } ?: [:] toTurnOff.each { devId -> def dev = switchMap[devId] if (dev) { dev.off() state.lightsOff = (state.lightsOff ?: 0) + 1 // Track this device as having been turned off def offList = (state.summaryOffNames ?: []) as List def dn = dev.displayName ?: dev.name if (!offList.contains(dn)) { offList << dn } state.summaryOffNames = offList logTrace "offTick: turned OFF '${dn}' (duration expired)" } schedule.remove(devId) } state.lightSchedule = schedule if (!schedule.isEmpty()) { logTrace "offTick(): ${schedule.size()} light(s) still queued for future off; next check in 60 seconds" runIn(60, "offTick", [overwrite: true]) } else { logTrace "offTick(): all queued lights processed; not scheduling another offTick" state.offTickScheduled = false } } // --------- RESTRICTIONS --------- private getDaysOk() { def result = true if (days) { def df = new java.text.SimpleDateFormat("EEEE") if (getTimeZone()) { df.setTimeZone(getTimeZone()) } else { df.setTimeZone(TimeZone.getTimeZone("America/New_York")) } def day = df.format(new Date()) result = days.contains(day) } result } /** * Determine if the current time is within the configured window. * * Handles both "same-day" windows (e.g., 18:00 → 23:00) * and "overnight" windows that cross midnight (e.g., Sunset+X → Sunrise+Y). */ private getTimeOk() { def tz = getTimeZone() def start = timeWindowStart() def stop = timeWindowStop(false, true) // includes small end-time adjustment // Help debug any timing problems logDebug "For debuging timing issues: getTimeOk: start=${start}, stop=${stop}, now=${new Date()}, tz=${getTimeZone()}" // If no window is configured (or we can't compute it), treat as "no restriction". if (!start || !stop || !tz) { return true } Date now = new Date() // Normal same-day window: start < stop if (start.before(stop)) { return timeOfDayIsBetween(start, stop, now, tz) } // Degenerate case: start == stop → zero-length window, treat as closed if (start.equals(stop)) { return false } // Overnight window: start > stop (e.g., Sunset+X → Sunrise+Y). // Interpret this as: // from start time in the evening, through midnight, // and up until stop time in the morning. // // Trick: the complement of the "forbidden" region (stop → start). // If we're NOT between stop and start, then we ARE in the overnight window. return !timeOfDayIsBetween(stop, start, now, tz) } // --- Sunrise / Sunset & time window helpers adapted for Hubitat --- private timeWindowStart(usehhmm = false) { def result = null if (startTimeType == "sunrise") { def sunTimes = getSunriseAndSunset() result = sunTimes?.sunrise if (result && startTimeOffset) { result = new Date(result.time + Math.round(startTimeOffset * 60000)) } } else if (startTimeType == "sunset") { def sunTimes = getSunriseAndSunset() result = sunTimes?.sunset if (result && startTimeOffset) { result = new Date(result.time + Math.round(startTimeOffset * 60000)) } } else if (starting && getTimeZone()) { if (usehhmm) { result = timeToday(hhmm(starting), getTimeZone()) } else { result = timeToday(starting, getTimeZone()) } } result } private timeWindowStop(usehhmm = false, adj = false) { def result = null if (endTimeType == "sunrise") { def sunTimes = getSunriseAndSunset() result = sunTimes?.sunrise if (result && endTimeOffset) { result = new Date(result.time + Math.round(endTimeOffset * 60000)) } } else if (endTimeType == "sunset") { def sunTimes = getSunriseAndSunset() result = sunTimes?.sunset if (result && endTimeOffset) { result = new Date(result.time + Math.round(endTimeOffset * 60000)) } } else if (ending && getTimeZone()) { if (usehhmm) { result = timeToday(hhmm(ending), getTimeZone()) } else { result = timeToday(ending, getTimeZone()) } } if (result && adj) { def result1 = new Date(result.time - (2 * 60 * 1000)) logDebug "timeWindowStop = ${result} adjusted: ${result1}" result = result1 } return result } private hhmm(time, fmt = "HH:mm") { def t = timeToday(time, getTimeZone()) def f = new java.text.SimpleDateFormat(fmt) f.setTimeZone(getTimeZone()) f.format(t) } private timeIntervalLabel() { def start = "" switch (startTimeType) { case "time": if (starting) { start += hhmm(starting) } break case "sunrise": case "sunset": start += startTimeType[0].toUpperCase() + startTimeType[1..-1] if (startTimeOffset) { start += startTimeOffset > 0 ? "+${startTimeOffset} min" : "${startTimeOffset} min" } break } def finish = "" switch (endTimeType) { case "time": if (ending) { finish += hhmm(ending) } break case "sunrise": case "sunset": finish += endTimeType[0].toUpperCase() + endTimeType[1..-1] if (endTimeOffset) { finish += endTimeOffset > 0 ? "+${endTimeOffset} min" : "${endTimeOffset} min" } break } start && finish ? "${start} to ${finish}" : "" } // --------- DAILY SUMMARY --------- def scheduleSummary() { // cancel any existing dailySummary schedule only unschedule(dailySummary) // Only schedule daily summaries if in daily mode (or not set, for backward compat) if (summaryMode == "session") { logDebug "Summary mode is 'session', not scheduling daily summary" state.nextSummaryAtMs = null return } if (!summaryTime) { logDebug "No summaryTime configured, not scheduling daily summary" state.nextSummaryAtMs = null return } def tz = getTimeZone() def next = timeToday(summaryTime, tz) def now = new Date() // if today's time already passed, schedule for tomorrow if (next.before(now)) { next = new Date(next.time + 24L * 60L * 60L * 1000L) } logDebug "Scheduling daily summary for ${next}" state.nextSummaryAtMs = next.time runOnce(next, dailySummary) } def dailySummary() { Integer cycles = (state.cycles ?: 0) as Integer Integer lightsOn = (state.lightsOn ?: 0) as Integer Integer lightsOff = (state.lightsOff?: 0) as Integer List onNames = (state.summaryOnNames ?: []) as List List offNames = (state.summaryOffNames ?: []) as List // Only send summary if there was actual activity (cycles > 0) if (cycles > 0 && summaryDevice) { String msg = buildSummaryMessage("daily", cycles, lightsOn, lightsOff, onNames, offNames) logDebug "Sending daily summary: ${msg}" summaryDevice*.deviceNotification(msg) } else if (cycles == 0) { logDebug "Daily summary skipped: no cycles ran in the last 24 hours" } else { logDebug "Daily summary skipped: no summaryDevice configured" } // Reset counters and name lists for the next day state.cycles = 0 state.lightsOn = 0 state.lightsOff = 0 state.summaryOnNames = [] state.summaryOffNames= [] // Only reschedule if still armed; otherwise let arming trigger schedule it again if (armOk) { scheduleSummary() } else { state.nextSummaryAtMs = null logDebug "Daily summary not rescheduled: app is not armed" } } /** * Send a session summary if in session mode and there was activity. * Called when session ends (time window closes, switch off, mode change). */ private void sendSessionSummary(String reason) { // Only send if in session mode if (summaryMode != "session") { return } Integer cycles = (state.cycles ?: 0) as Integer Integer lightsOn = (state.lightsOn ?: 0) as Integer Integer lightsOff = (state.lightsOff?: 0) as Integer List onNames = (state.summaryOnNames ?: []) as List List offNames = (state.summaryOffNames ?: []) as List // Only send if there was activity if (cycles > 0 && summaryDevice) { String msg = buildSummaryMessage("session", cycles, lightsOn, lightsOff, onNames, offNames, reason) logDebug "Sending session summary (${reason}): ${msg}" summaryDevice*.deviceNotification(msg) } else if (cycles == 0) { logDebug "Session summary skipped (${reason}): no cycles ran this session" } else { logDebug "Session summary skipped (${reason}): no summaryDevice configured" } // Reset counters for next session state.cycles = 0 state.lightsOn = 0 state.lightsOff = 0 state.summaryOnNames = [] state.summaryOffNames= [] } /** * Build a unified summary message for daily, session, and test summaries. * * @param mode "daily", "session", or "test" * @param cycles Number of cycles in this summary window * @param lightsOn Number of lights turned on * @param lightsOff Number of lights turned off * @param onNames List of device names turned on during this window * @param offNames List of device names turned off during this window * @param reason (session only) Why the session ended */ private String buildSummaryMessage(String mode, Integer cycles, Integer lightsOn, Integer lightsOff, List onNames = null, List offNames = null, String reason = null) { cycles = (cycles ?: 0) as Integer lightsOn = (lightsOn ?: 0) as Integer lightsOff = (lightsOff ?: 0) as Integer onNames = (onNames ?: []) as List offNames = (offNames ?: []) as List boolean noActivity = (cycles == 0 && lightsOn == 0 && lightsOff == 0 && onNames.isEmpty() && offNames.isEmpty()) if (mode == "test") { if (noActivity) { return "🧪 Vacation Lighting Test Complete:\n" + "• No cycles or light actions were recorded\n" + "(Check your configuration.)" } else { String msg = "🧪 Vacation Lighting Test Complete:\n" + "• 🟢 ${cycles} cycle(s) simulated\n" + "• 💡 ${lightsOn} light(s) turned on\n" + "• 💤 Light offs will occur automatically as their random durations expire\n" if (!onNames.isEmpty()) { msg += "• 💡 Lights turned on: ${onNames.join(', ')}\n" } if (!offNames.isEmpty()) { msg += "• 💤 Lights turned off: ${offNames.join(', ')}\n" } msg += "\n🔁 This mirrors real vacation-mode behavior." return msg } } // Session mode if (mode == "session") { String msg = "🌙 Vacation Lighting Session Complete:\n" + "• 🟢 ${cycles} cycle(s) simulated\n" + "• 💡 ${lightsOn} light(s) turned on\n" + "• 💤 ${lightsOff} light(s) turned off\n" if (!onNames.isEmpty()) { msg += "• 💡 Lights used: ${onNames.join(', ')}\n" } if (reason) { msg += "\n🛑 Session ended: ${reason}" } return msg } // Default to "daily" semantics if (cycles == 0 && onNames.isEmpty() && offNames.isEmpty()) { return "📊 Vacation Lighting Daily Summary:\n" + "• No cycles ran in the last 24 hours\n" + "• Likely outside the time window, not armed, or not triggered" } else { String msg = "📊 Vacation Lighting Daily Summary:\n" + "• 🟢 ${cycles} cycle(s) simulated\n" + "• 💡 ${lightsOn} light(s) turned on\n" + "• 💤 ${lightsOff} light(s) turned off\n" if (!onNames.isEmpty()) { msg += "• 💡 Lights turned on: ${onNames.join(', ')}\n" } if (!offNames.isEmpty()) { msg += "• 💤 Lights turned off: ${offNames.join(', ')}\n" } msg += "• 🔁 Additional light offs may still occur (queued)\n\n" + "🏠 Summary covers the last 24 hours of vacation-mode behavior." return msg } } // --------- SETUP COMPLETENESS FLAGS --------- // sets complete/not complete for the setup section on the main dynamic page def greyedOut() { def result = "" if (switches) { result = "complete" } result } // sets complete/not complete for the settings section on the main dynamic page def greyedOutSettings() { def result = "" if (days || falseAlarmThreshold || summaryDevice || summaryTime || descriptiveLogging != null || debugLogging != null) { result = "complete" } result }