/** * Advanced Sleep Metrics * * Author: ShaneAllen * * Version: 2.0 */ definition( name: "Advanced Sleep Metrics", namespace: "ShaneAllen", author: "ShaneAllen", description: "Clinical-grade sleep orchestrator with EWMA tracking, environmental correlation, and Predictive Wake Triggers.", category: "Health & Wellness", iconUrl: "", iconX2Url: "" ) preferences { page(name: "mainPage") page(name: "roomPage") } def mainPage() { dynamicPage(name: "mainPage", title: "", install: true, uninstall: true) { section("") { input "btnRefresh", "button", title: "🔄 Force Manual Refresh", description: "Manually ping all child devices and recalculate current states immediately." if (numRooms > 0) { def statusText = """ """ def now = new Date().time def watchdogMillis = (sensorWatchdogHours != null ? sensorWatchdogHours.toInteger() : 48) * 3600000 def cal = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault()) def isWkndGlobal = (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) for (int i = 1; i <= (numRooms as Integer); i++) { def isMaster = (i == 1) def numUsers = isMaster ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" def uName = settings["userName_${uId}"] ?: (isMaster ? (u == 1 ? "Left Side" : "Right Side") : "Occupant") def vSensor = settings["vibrationSensor_${uId}"] def pMat = settings["pressureMat_${uId}"] if (!vSensor && !pMat) continue def vSensState = vSensor ? (vSensor.currentValue("acceleration") ?: "inactive") : "none" def pMatState = pMat ? (pMat.currentValue("contact") ?: pMat.currentValue("presence") ?: "open") : "none" def vIndicator = vSensState == "active" ? "ACTIVE 📳" : "INACTIVE" def pIndicator = (pMatState == "closed" || pMatState == "present") ? "CLOSED 🛏️" : "OPEN" def lastVibGlobal = state.lastVibrationTime?."${uId}" ?: 0 def isOffline = (lastVibGlobal > 0 && (now - lastVibGlobal) > watchdogMillis) def cState = state.sleepState?."${uId}" ?: "EMPTY" def score = calculateEfficiencyScore(uId) def scoreColor = score >= 85 ? "#27ae60" : (score >= 70 ? "#f39c12" : "#c0392b") def mlEnabled = (settings["enableML_${uId}"] != false) def aiStats = "" def aiTimes = "" def tripMins = state.bathroomDuration?."${uId}" ?: 0 def trips = state.bathroomTrips?."${uId}" ?: 0 def averages = calculateUserAverages(uId) if (mlEnabled) { def learningDisplay = averages.daysLearned >= 14 ? "🧠 Learned" : "🧠 Learning (${averages.daysLearned}/14)" aiStats = "${learningDisplay} | 7D Avg: ${averages.avgScore7 ?: "--"}%" def todayAvgIn = isWkndGlobal ? averages.avgInWe : averages.avgInWd def todayAvgOut = isWkndGlobal ? averages.avgOutWe : averages.avgOutWd aiTimes = "Expected In: ${formatMinutesFromNoon(todayAvgIn)} | Out: ${formatMinutesFromNoon(todayAvgOut)}" } else { aiStats = "🧠 AI Disabled" aiTimes = "Predictions Disabled" } def inBedTimeStr = state.inBedTime?."${uId}" ? formatTimestamp(state.inBedTime."${uId}") : "--:--" def exitTimeStr = state.lastExitTime?."${uId}" ? formatTimestamp(state.lastExitTime."${uId}") : "--:--" def liveInBed = 0 def liveAsleep = 0 if (cState == "IN BED" || cState == "SLEEPING" || cState == "PENDING ENTRY" || cState == "BATHROOM TRIP") { if (state.inBedTime?."${uId}") liveInBed = ((now - state.inBedTime."${uId}") / 60000).toInteger() if (state.asleepTime?."${uId}") liveAsleep = ((now - state.asleepTime."${uId}") / 60000).toInteger() } else { liveInBed = state.lastSessionInBed?."${uId}" ?: 0 liveAsleep = state.lastSessionAsleep?."${uId}" ?: 0 } def deepMins = state.deepSleepDuration?."${uId}" ?: 0 if (cState == "SLEEPING") { def stillStart = state.lastStillStartTime["${uId}"] ?: state.asleepTime["${uId}"] ?: now def gap = now - stillStart if (gap >= 2700000) deepMins += (gap / 60000).toInteger() } def lightMins = Math.max(0, liveAsleep - deepMins) def awakeMins = Math.max(0, liveInBed - liveAsleep) def totalMins = liveInBed > 0 ? liveInBed : 1 def moves = state.movements?."${uId}" ?: 0 def liveAsleepForIndex = cState == "SLEEPING" ? liveAsleep : (state.lastSessionAsleep?."${uId}" ?: 0) def rIndex = liveAsleepForIndex > 0 ? (Math.round((moves / (liveAsleepForIndex / 60.0)) * 10) / 10.0) : 0.0 def rIndexFmt = rIndex as Double def eff = 0 if (totalMins >= 30 && liveAsleep > 0) eff = Math.min(100, ((liveAsleep / totalMins) * 100) as Integer) def rawPen = state.weightedMovementPenalty?."${uId}" != null ? state.weightedMovementPenalty["${uId}"] : (moves * 0.25) if (rawPen > moves) rawPen = moves * 0.25 def movPenFmt = Math.min(30.0, rawPen as Double) def currentEnv = "" if (state.envStats?."${uId}"?.tCnt > 0) { def curT = Math.round((state.envStats["${uId}"].tSum / state.envStats["${uId}"].tCnt) * 10) / 10.0 def curH = Math.round((state.envStats["${uId}"].hSum / state.envStats["${uId}"].hCnt) * 10) / 10.0 currentEnv = "Temp: ${curT}° | Hum: ${curH}%" } else { currentEnv = "N/A" } def advText = "" if (settings["enableCircadianScaling_${uId}"]) advText += " 🌖 Circadian movement scaling active." if (settings["enableClinicalScoring_${uId}"]) advText += "
⚕️ Clinical Scoring: Active (Goal: ${settings["targetSleepHours_${uId}"] ?: 7.5}h)." if (settings["enableAdvancedStages_${uId}"] && cState == "SLEEPING") { def stage = state.currentSleepStage?."${uId}" ?: "DEEP" def ewmaVal = state.ewmaMovement?."${uId}" ?: 0.0 def sColor = stage == "LIGHT" ? "#3498db" : "#2980b9" advText += "
📈 Real-Time EWMA Stage: ${stage} (Index: ${String.format("%.2f", ewmaVal as Double)})" } if (settings["enableEnvCorrelation_${uId}"] && averages.optimalTemp != null) { def oH = averages.optimalHumid != null ? "at ${averages.optimalHumid}% Hum" : "" advText += "
🌡️ Learned Optimal Env: ${averages.optimalTemp}° ${oH} yields highest scores." } def timers = [] if (isOffline) { timers << "⚠️ Sensor Stale" } else { def deafenedUntil = state.deafenedUntil?."${uId}" ?: 0 if (now < deafenedUntil) timers << "🛡️ Kinetic Shield" def lastMove = state.lastVibrationTime?."${uId}" ?: 0 if (cState == "IN BED" && (now - lastMove) < ((fallAsleepThreshold ?: 15) * 60000)) { def rem = (((fallAsleepThreshold ?: 15) * 60000) - (now - lastMove)) / 60000 timers << "Settling: ${rem.toInteger()}m" } if (isSettlingLockActive(uId)) { def inBed = Math.max((state.inBedTime?."${uId}" ?: 0) as Long, (state.sessionResumedTime?."${uId}" ?: 0) as Long) if (inBed == 0) inBed = now def rem = (((settlingLockTime ?: 30) * 60000) - (now - inBed)) / 60000 timers << "🔒 Locked: ${Math.max(0, rem.toInteger())}m" } } def timerStr = timers ? timers.join(" | ") : "Monitored Active" def insightText = "Based on telemetry from your designated sensors, you established a base sleep efficiency of ${eff}% (Time Asleep vs. Time In Bed). " if (moves > 0) insightText += "Sensors recorded ${moves} distinct restlessness events (-${String.format("%.1f", movPenFmt)} point penalty). " else insightText += "Your sleep was incredibly still, with 0 recorded restlessness events. " if (trips > 0) insightText += "You registered ${trips} away/bathroom trip(s), spending ${tripMins} minutes out of bed. " insightText += "Your final calculated BMS Sleep Score is ${score}%." if (advText != "") insightText += "

${advText}" def headerStyle = "background: linear-gradient(135deg, #2c3e50, #34495e); color: white;" if (cState == "SLEEPING") headerStyle = "background: linear-gradient(135deg, #2980b9, #2ecc71); color: white;" else if (cState == "IN BED") headerStyle = "background: linear-gradient(135deg, #8e44ad, #9b59b6); color: white;" else if (cState == "BATHROOM TRIP") headerStyle = "background: linear-gradient(135deg, #d35400, #f39c12); color: white;" statusText += """
${uName} (${getRoomName(i)}) - ${isOffline ? "OFFLINE" : cState.toUpperCase()} BMS Score: ${score}%
SESSION TIMELINE
In: ${inBedTimeStr}
Out: ${exitTimeStr}
Latency: ${state.sleepLatency?."${uId}" ?: "--"}m
SLEEP STAGE PROFILES
Deep: ${formatDuration(deepMins)}
Light: ${formatDuration(lightMins)} | Awake: ${formatDuration(awakeMins)}
Movements: ${moves} (${String.format("%.1f", rIndexFmt)}/hr)
HARDWARE & ENVIRONMENT
Vib: ${vIndicator}
Mat: ${pIndicator}
Env: ${currentEnv}
SYSTEM INSIGHTS & AI
${insightText}
AI: ${aiStats} | ${aiTimes}
Active Timers: ${timerStr}
""" } } def globalStatus = (masterEnableSwitch && masterEnableSwitch.currentValue("switch") == "off") ? "PAUSED" : (isTrackingAllowed() ? "ACTIVE TRACKING" : "RESTRICTED (Out of Bounds)") statusText += "
" statusText += "Master System Tracking: ${globalStatus}" statusText += "
" paragraph statusText } } section("System Event History", hideable: true, hidden: true) { if (state.historyLog && state.historyLog.size() > 0) { def histHtml = "
" state.historyLog.each { logEntry -> histHtml += "
${logEntry}
" } histHtml += "
" paragraph histHtml } else { paragraph "No history logged yet." } } section("Global Restrictions (Time & Mode)", hideable: true, hidden: true) { input "activeSleepModes", "mode", title: "Allowed Sleep Modes", multiple: true, required: false, description: "Only track sleep when Hubitat is in one of these selected modes (e.g., Night, Evening). If left blank, it tracks in all modes." input "sleepStartTime", "time", title: "Tracking Start Time", required: false, description: "Absolute earliest time the system will register someone getting into bed. Prevents daytime naps from overwriting night data." input "sleepEndTime", "time", title: "Tracking End Time", required: false, description: "Time when tracking completely shuts off, forcing all occupants to an EMPTY state." } section("Global BMS Controls", hideable: true, hidden: true) { input "masterEnableSwitch", "capability.switch", title: "Master System Enable Switch", required: false, description: "Select a virtual switch to act as a global kill-switch. When OFF, all sleep tracking pauses instantly." input "numRooms", "number", title: "Number of Bedrooms (1-3)", required: true, defaultValue: 1, range: "1..3", submitOnChange: true, description: "Defines how many independent rooms this app instance will manage. Room 1 supports two occupants; others support one." paragraph "Smart Logic Configuration" input "vibrationsToEnterBed", "number", title: "Vibrations Required to Enter Bed", defaultValue: 1, required: true, description: "If NOT using a pressure mat, how many kinetic bumps are needed to register a user as 'In Bed'." input "fallAsleepThreshold", "number", title: "Fall Asleep Duration (Minutes)", defaultValue: 15, description: "How long a user must lie completely still (no kinetic vibration) before the system transitions them from IN BED to SLEEPING." input "exitBedThreshold", "number", title: "Bed Exit Delay (Minutes)", defaultValue: 5, description: "How long the pressure mat must be open (or kinetic movement absent) before officially ending the session or logging a bathroom trip." input "stitchingWindow", "number", title: "Standard Stitching Window (Minutes)", defaultValue: 15, description: "If a user exits bed and returns within this time frame, it is logged as a bathroom trip rather than ending their night." paragraph "Advanced Signal Processing" input "enableSettlingLock", "bool", title: "🔒 Enable Settling Lock?", defaultValue: true, submitOnChange: true, description: "Completely locks the user IN BED for a set time after entry to prevent tossing and adjusting covers from causing false bed-exits." if (enableSettlingLock) { input "settlingLockTime", "number", title: "Settling Lock Duration (Mins)", defaultValue: 30, description: "The length of the settling lockout period." } input "enableGhostFilter", "bool", title: "👻 Enable Pre-Emptive Presence Lockout?", defaultValue: true, description: "Ignores bed exits if the room motion sensor was already active BEFORE the bed vibration stopped (blocks parents checking on kids or pets jumping off)." paragraph "False Positive & Pet Rejection" input "enableTeleportFilter", "bool", title: "🚫 Enable Teleportation Filter?", defaultValue: true, description: "Blocks bed entries if there has been no room motion in the last X minutes (People don't teleport into bed; this stops random cat jumps)." input "teleportWindow", "number", title: "Teleportation Window (Mins)", defaultValue: 10, description: "How far back to look for room motion prior to the bed sensor triggering." input "enableAntiBounce", "bool", title: "🛏️ Enable Sustained Entry Verification?", defaultValue: true, submitOnChange: true, description: "Wait a few minutes after bed entry to ensure the user isn't just folding laundry or sitting on the edge before committing to an IN BED state." if (enableAntiBounce) { input "antiBounceWait", "number", title: "Verification Wait Time (Mins)", defaultValue: 3, description: "How long to wait and observe room activity before committing the sleep session." input "antiBounceMaxMotions", "number", title: "Max Allowed Room Motions During Wait", defaultValue: 2, description: "If room motion exceeds this number during the wait time, the entry is aborted." } paragraph "Dynamic Stitching (Time-of-Night Context)" input "deepSleepStart", "time", title: "Deep Sleep Window Start", required: false, description: "Start time for when you expect to be deeply asleep (e.g., 1:00 AM)." input "deepSleepEnd", "time", title: "Deep Sleep Window End", required: false, description: "End time for deep sleep (e.g., 5:00 AM)." input "deepSleepStitchWindow", "number", title: "Deep Sleep Stitching Window (Mins)", defaultValue: 45, description: "During the hours defined above, extends the stitching window. Allows much longer bathroom/baby feeding trips during the dead of night without breaking the sleep session." paragraph "Quiet House Auto-Recovery" input "enableQuietHouseReturn", "bool", title: "🤫 Enable Quiet House Auto-Return?", defaultValue: true, submitOnChange: true, description: "If a user is stuck in a Bathroom Trip but the house goes completely quiet, the system assumes they sneaked back into bed without triggering the sensors and quietly stitches them back in." if (enableQuietHouseReturn) { input "globalMotionSensors", "capability.motionSensor", title: "Global House Motion Sensors", multiple: true, required: true, description: "Select high-traffic motion sensors outside the bedroom to monitor for 'Quiet House' status." input "quietHouseThreshold", "number", title: "Quiet House Threshold (Mins)", defaultValue: 20, description: "How long the global sensors must remain inactive to trigger the auto-return." } paragraph "Cross-Talk Cancellation (The Partner Shield)" input "enableCrossTalk", "bool", title: "Enable Multi-User Kinetic Shielding?", defaultValue: true, submitOnChange: true, description: "In a shared bed, when Partner A moves, temporarily deafen Partner B's sensor to prevent Partner A's movement from penalizing Partner B's sleep score." if (enableCrossTalk) { input "crossTalkDeafenTime", "number", title: "Shield Duration (Seconds)", defaultValue: 60, required: true, description: "How long to deafen the partner's sensor after the primary movement." } } section("Advanced Features", hideable: true, hidden: true) { input "sensorWatchdogHours", "number", title: "Sensor Watchdog (Hours)", defaultValue: 48, description: "Marks a user's hardware as 'OFFLINE' on the dashboard if no telemetry is received in this timeframe." input "enableTelemetryTracking", "bool", title: "Enable Telemetry Dashboard?", defaultValue: true, description: "Logs detailed background stats on how many false positives were blocked by the ghost filter, settling lock, etc." input "btnForceReset", "button", title: "🔄 Force Reset All Beds to EMPTY", description: "Use in emergencies if state machines get stuck. Wipes all current sessions." } if (numRooms > 0) { for (int i = 1; i <= (numRooms as Integer); i++) { section("${getRoomName(i)} Configuration", hideable: true, hidden: true) { href(name: "roomHref${i}", page: "roomPage", params: [roomNum: i], title: "Configure ${getRoomName(i)} Devices & Users", description: "Set up hardware and AI parameters for this room.") } } } } } def roomPage(params) { def rNum = params?.roomNum ?: state.currentRoom ?: 1 state.currentRoom = rNum def isMaster = (rNum == 1) dynamicPage(name: "roomPage", title: "Room Setup", install: false, uninstall: false, previousPage: "mainPage") { section("Identification", hideable: true, hidden: false) { input "roomName_${rNum}", "text", title: "Custom Room Name", defaultValue: (isMaster ? "Master Bedroom" : "Bedroom ${rNum}"), submitOnChange: true, description: "Display name for this room on the dashboard." } section("Room Orchestrator & Controls", hideable: true, hidden: true) { input "goodNightSwitch_${rNum}", "capability.switch", title: "Room Good Night Switch", required: false, description: "A virtual or physical switch. When turned ON, it forces all users in this room into bed immediately. When turned OFF, it forces them awake." input "enableOrchestrator_${rNum}", "bool", title: "Enable Room Orchestrator?", defaultValue: false, submitOnChange: true, description: "Allows this app to automatically turn the Good Night switch ON when all occupants have fallen asleep, and OFF when the room is empty in the morning." if (settings["enableOrchestrator_${rNum}"]) { input "goodNightDelay_${rNum}", "number", title: "In-Bed Time to Trigger Switch ON (Mins)", defaultValue: 30, description: "How long all users must be in bed before automatically triggering the Good Night switch." } input "wakeDelay_${rNum}", "number", title: "Wake Confirmation Timeout (Mins)", defaultValue: 45, description: "How long the room must be completely empty before automatically turning the Good Night switch OFF." } section("Room Environment Diagnostics (Optional)", hideable: true, hidden: true) { paragraph "Provides Environmental AI correlation mapping and Real-Time external disturbance disruption tracking." input "tempSensor_${rNum}", "capability.temperatureMeasurement", title: "Room Temperature Sensor", required: false, description: "Tracks optimal sleep temperature." input "humidSensor_${rNum}", "capability.relativeHumidityMeasurement", title: "Room Humidity Sensor", required: false, description: "Tracks optimal sleep humidity." input "luxSensor_${rNum}", "capability.illuminanceMeasurement", title: "Room Lux/Light Sensor", required: false, description: "Detects light bleed events that cause restlessness." input "noiseSensor_${rNum}", "capability.soundPressureLevel", title: "Room Decibel/Noise Sensor", required: false, description: "Detects noise spikes that cause restlessness." } section("Fallback Room Sensors", hideable: true, hidden: true) { input "motionSensor_${rNum}", "capability.motionSensor", title: "Fallback Room Motion Sensor", required: true, description: "Used by the Ghost and Teleport filters to verify physical presence in the room." input "bathroomMotion_${rNum}", "capability.motionSensor", title: "Fallback En-Suite Bathroom Motion", required: false, description: "Instantly logs a bathroom trip if motion is detected here while a user is in bed." } def users = isMaster ? 2 : 1 for (int u = 1; u <= users; u++) { section("User ${u} Settings", hideable: true, hidden: true) { input "userName_${rNum}_${u}", "text", title: "Name", defaultValue: (isMaster ? (u == 1 ? "Shane" : "Christy") : "User"), submitOnChange: true, description: "Dashboard display name for this side of the bed." input "usePressureMat_${rNum}_${u}", "bool", title: "🛏️ Use Tuya Pressure Mat for Primary Presence?", defaultValue: false, submitOnChange: true, description: "Check this if you have a pressure mat under the mattress." if (settings["usePressureMat_${rNum}_${u}"]) { input "pressureMat_${rNum}_${u}", "capability.contactSensor", title: "Primary Bed Pressure Mat (Contact)", required: true, description: "When selected, vibration sensors are ONLY used for restlessness and sleep staging. The mat acts as the absolute gatekeeper for presence." input "matExitDelay_${rNum}_${u}", "number", title: "Mat Open Exit Force Delay (Mins)", defaultValue: 10, description: "If the mat remains open for this long, bypass all motion checks and absolutely force an exit (prevents stuck states)." } input "vibrationSensor_${rNum}_${u}", "capability.accelerationSensor", title: "Primary Vibration Sensor (Mattress)", required: true, description: "The kinetic sensor taped to the mattress slats for tracking restlessness." input "vibrationSensor2_${rNum}_${u}", "capability.accelerationSensor", title: "Secondary Vibration Sensor (Headboard)", required: false, description: "Optional secondary kinetic sensor." input "enableML_${rNum}_${u}", "bool", title: "🧠 Enable AI Behavioral Learning?", defaultValue: true, description: "Builds a 14-day rolling profile to predict usual sleep/wake times and accelerate logic based on habits. Turn OFF for Guest Rooms." input "btnClearAI_${rNum}_${u}", "button", title: "🗑️ Clear AI Data for ${settings["userName_${rNum}_${u}"] ?: "User"}", description: "Wipes the 14-day learning history." def dni = "ASM_INFO_${rNum}_${u}" if (getChildDevice(dni)) { paragraph "✅ Dashboard Info Device Linked." } else { input "btnCreateInfo_${rNum}_${u}", "button", title: "➕ Create Dashboard Info Device for ${settings["userName_${rNum}_${u}"]}", description: "Creates the virtual child device that generates the HTML tile for the dashboard." } def dniSw = "ASM_SW_${rNum}_${u}" if (getChildDevice(dniSw)) { paragraph "✅ User Virtual Switch Linked." } else { input "btnCreateSwitch_${rNum}_${u}", "button", title: "➕ Create User Virtual Switch", description: "Creates a virtual switch that turns ON when the user is in bed, useful for triggering per-user automations (e.g., turning off their bedside lamp)." } } section("User ${u} Next-Gen Analytics & Triggers", hideable: true, hidden: true) { input "enableClinicalScoring_${rNum}_${u}", "bool", title: "⚕️ Enable Clinical Scoring Model?", defaultValue: false, submitOnChange: true, description: "Overhauls base math to factor in Total Sleep Duration vs Goal, Latency efficiency, and Bedtime Regularity." if (settings["enableClinicalScoring_${rNum}_${u}"]) { input "targetSleepHours_${rNum}_${u}", "decimal", title: "Target Sleep Duration Goal (Hours)", defaultValue: 7.5, description: "How many hours of sleep this user strives for." } input "enableAdvancedStages_${rNum}_${u}", "bool", title: "📊 Enable EWMA Sleep Stage Tracking?", defaultValue: false, submitOnChange: true, description: "Replaces standard timer gaps with an Exponentially Weighted Moving Average engine to detect true Light vs Deep sleep phases based on movement clusters." input "enableCircadianScaling_${rNum}_${u}", "bool", title: "🌖 Enable Circadian Movement Scaling?", defaultValue: false, description: "Dynamically halves the score penalty for restless twitches if they occur during typical early morning REM hours (3 AM - 8 AM)." input "enableEnvCorrelation_${rNum}_${u}", "bool", title: "🌡️ Enable Environmental Correlation?", defaultValue: false, description: "Analyzes historical data natively to find your optimal sleep temperature and humidity zones for maximum BMS scores." input "enableSmartAlarm_${rNum}_${u}", "bool", title: "⏰ Enable Predictive Smart Alarm?", defaultValue: false, submitOnChange: true, description: "Triggers a virtual switch if kinetic movement or LIGHT sleep is detected while inside your established Expected Wake Window, allowing you to run natural wake-up automations." if (settings["enableSmartAlarm_${rNum}_${u}"]) { input "smartAlarmSwitch_${rNum}_${u}", "capability.switch", title: "Smart Alarm Trigger Switch", required: true, description: "The switch to turn ON when the smart alarm condition is met." } } section("User ${u} Advanced Wake Tracking", hideable: true, hidden: true) { input "parentalGuard_${rNum}_${u}", "bool", title: "🛡️ Enable Parental Guard (Strict Exit)?", defaultValue: false, description: "If enabled, room motion alone will NEVER wake the user. They must follow a strict exit sequence path." input "kineticDelaySeconds_${rNum}_${u}", "number", title: "⏱️ Kinetic Speed Limit (Seconds)", defaultValue: 3, description: "Minimum physically possible time it takes to walk from Step 1 (Bedside) to Step 2 (Hallway). Filters out fast false positives." input "wakeMotion1_${rNum}_${u}", "capability.motionSensor", title: "Sequence Step 1: Bedside Motion", required: false, description: "The first sensor hit when stepping out of bed." input "wakeMotion2_${rNum}_${u}", "capability.motionSensor", title: "Sequence Step 2: Room Exit or Hallway Motion", required: false, description: "The second sensor hit when leaving the bedroom." input "bathroomPathMotion_${rNum}_${u}", "capability.motionSensor", title: "Alternative Path: Bathroom Motion", required: false, description: "Alternative sequence path to log a bathroom trip." input "showerMotion_${rNum}_${u}", "capability.motionSensor", title: "Terminal Wake: Shower Motion Sensor", required: false, description: "If triggered within 10 mins of bed exit or bathroom trip, confirms final terminal wake-up." paragraph "Expected Wake Windows" input "weekdayWakeStart_${rNum}_${u}", "time", title: "Weekday Wake Start", required: false, description: "The earliest time a user usually wakes up on a weekday (used for AI acceleration and Smart Alarms)." input "weekdayWakeEnd_${rNum}_${u}", "time", title: "Weekday Wake End", required: false, description: "The latest time a user usually wakes up on a weekday." input "weekendWakeStart_${rNum}_${u}", "time", title: "Weekend Wake Start", required: false, description: "The earliest time a user usually wakes up on a weekend." input "weekendWakeEnd_${rNum}_${u}", "time", title: "Weekend Wake End", required: false, description: "The latest time a user usually wakes up on a weekend." paragraph "Strict Wake Lockout (Safety Net)" input "enableWakeLockout_${rNum}_${u}", "bool", title: "🔒 Enable Strict Wake Lockout?", defaultValue: false, submitOnChange: true, description: "Prevents terminal wake-ups during these hours entirely. Exits are logged as extended bathroom trips until the window ends." if (settings["enableWakeLockout_${rNum}_${u}"]) { input "lockoutStart_${rNum}_${u}", "time", title: "Lockout Start Time", required: true, description: "Start of the forbidden wake period." input "lockoutEnd_${rNum}_${u}", "time", title: "Lockout End Time", required: true, description: "End of the forbidden wake period." } } } } } def installed() { log.info "Advanced Sleep Metrics Installed." initialize() } def updated() { log.info "Advanced Sleep Metrics Updated." unsubscribe() unschedule() initialize() } // --- SELF-HEALING ENGINE --- def ensureStateMaps() { if (state.sleepState == null) state.sleepState = [:] if (state.inBedTime == null) state.inBedTime = [:] if (state.asleepTime == null) state.asleepTime = [:] if (state.lastSessionInBed == null) state.lastSessionInBed = [:] if (state.lastSessionAsleep == null) state.lastSessionAsleep = [:] if (state.lastVibrationTime == null) state.lastVibrationTime = [:] if (state.lastRoomMotionTime == null) state.lastRoomMotionTime = [:] if (state.lastGlobalMotionTime == null) state.lastGlobalMotionTime = new Date().time if (state.lastValidExitMotion == null) state.lastValidExitMotion = [:] if (state.lastExitTime == null) state.lastExitTime = [:] if (state.pendingExit == null) state.pendingExit = [:] if (state.exitSequenceProgress == null) state.exitSequenceProgress = [:] if (state.sequenceStep1Time == null) state.sequenceStep1Time = [:] if (state.movements == null) state.movements = [:] if (state.roomEmptyTime == null) state.roomEmptyTime = [:] if (state.deafenedUntil == null) state.deafenedUntil = [:] if (state.bathroomTrips == null) state.bathroomTrips = [:] if (state.bathroomDuration == null) state.bathroomDuration = [:] if (state.telemetry == null) state.telemetry = [:] if (state.historyLog == null) state.historyLog = [] if (state.entryVibrationCount == null) state.entryVibrationCount = [:] if (state.roomGoodNightTriggered == null) state.roomGoodNightTriggered = [:] if (state.pendingEntryTime == null) state.pendingEntryTime = [:] if (state.pendingRoomMotions == null) state.pendingRoomMotions = [:] if (state.pendingAntiBounceWait == null) state.pendingAntiBounceWait = [:] if (state.dailyStats == null) state.dailyStats = [:] if (state.sessionResumedTime == null) state.sessionResumedTime = [:] if (state.sessionStartTime == null) state.sessionStartTime = [:] if (state.envStats == null) state.envStats = [:] if (state.deepSleepDuration == null) state.deepSleepDuration = [:] if (state.lastStillStartTime == null) state.lastStillStartTime = [:] if (state.sleepLatency == null) state.sleepLatency = [:] if (state.weightedMovementPenalty == null) state.weightedMovementPenalty = [:] if (state.ewmaMovement == null) state.ewmaMovement = [:] if (state.lastMoveTimeForEwma == null) state.lastMoveTimeForEwma = [:] if (state.currentSleepStage == null) state.currentSleepStage = [:] if (state.smartAlarmTriggeredDate == null) state.smartAlarmTriggeredDate = [:] if (state.lastEnvDisturbanceTime == null) state.lastEnvDisturbanceTime = [:] if (state.lastEnvDisturbanceType == null) state.lastEnvDisturbanceType = [:] } def initialize() { ensureStateMaps() if (settings["globalMotionSensors"]) { subscribe(settings["globalMotionSensors"], "motion", globalMotionHandler) } for (int i = 1; i <= 3; i++) { if (!state.roomEmptyTime["${i}"]) state.roomEmptyTime["${i}"] = 0 if (!state.lastRoomMotionTime["${i}"]) state.lastRoomMotionTime["${i}"] = 0 if (!state.roomGoodNightTriggered["${i}"]) state.roomGoodNightTriggered["${i}"] = false if (settings["tempSensor_${i}"]) subscribe(settings["tempSensor_${i}"], "temperature", envHandler) if (settings["humidSensor_${i}"]) subscribe(settings["humidSensor_${i}"], "humidity", envHandler) if (settings["luxSensor_${i}"]) subscribe(settings["luxSensor_${i}"], "illuminance", noiseLuxHandler) if (settings["noiseSensor_${i}"]) subscribe(settings["noiseSensor_${i}"], "soundPressureLevel", noiseLuxHandler) def numUsers = (i == 1) ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" if (!state.sleepState["${uId}"]) state.sleepState["${uId}"] = "EMPTY" if (!state.lastValidExitMotion["${uId}"]) state.lastValidExitMotion["${uId}"] = 0 if (!state.deafenedUntil["${uId}"]) state.deafenedUntil["${uId}"] = 0 if (!state.bathroomTrips["${uId}"]) state.bathroomTrips["${uId}"] = 0 if (!state.bathroomDuration["${uId}"]) state.bathroomDuration["${uId}"] = 0 if (!state.entryVibrationCount["${uId}"]) state.entryVibrationCount["${uId}"] = 0 if (!state.exitSequenceProgress["${uId}"]) state.exitSequenceProgress["${uId}"] = 0 if (!state.sequenceStep1Time["${uId}"]) state.sequenceStep1Time["${uId}"] = 0 if (!state.pendingRoomMotions["${uId}"]) state.pendingRoomMotions["${uId}"] = 0 if (!state.pendingAntiBounceWait["${uId}"]) state.pendingAntiBounceWait["${uId}"] = 0 if (!state.sessionResumedTime["${uId}"]) state.sessionResumedTime["${uId}"] = 0 if (!state.dailyStats["${uId}"]) state.dailyStats["${uId}"] = [] if (!state.envStats["${uId}"]) state.envStats["${uId}"] = [tSum: 0.0, tCnt: 0, hSum: 0.0, hCnt: 0] if (!state.deepSleepDuration["${uId}"]) state.deepSleepDuration["${uId}"] = 0 if (!state.lastStillStartTime["${uId}"]) state.lastStillStartTime["${uId}"] = 0 if (!state.sleepLatency["${uId}"]) state.sleepLatency["${uId}"] = 0 if (!state.weightedMovementPenalty["${uId}"]) state.weightedMovementPenalty["${uId}"] = 0.0 if (!state.ewmaMovement["${uId}"]) state.ewmaMovement["${uId}"] = 0.0 if (!state.lastMoveTimeForEwma["${uId}"]) state.lastMoveTimeForEwma["${uId}"] = 0 if (!state.currentSleepStage["${uId}"]) state.currentSleepStage["${uId}"] = "DEEP" if (!state.smartAlarmTriggeredDate["${uId}"]) state.smartAlarmTriggeredDate["${uId}"] = "" if (!state.telemetry["${uId}"] || !state.telemetry["${uId}"].today) { state.telemetry["${uId}"] = [today: [vibrations: 0, falseExits: 0, crossTalkAvoided: 0, inBedMotionsIgnored: 0, ghostBlocks: 0, settlingLockBlocks: 0], overall: [vibrations: 0, falseExits: 0, crossTalkAvoided: 0, inBedMotionsIgnored: 0, ghostBlocks: 0, settlingLockBlocks: 0]] } if (settings["wakeMotion1_${uId}"]) subscribe(settings["wakeMotion1_${uId}"], "motion", sequenceMotionHandler) if (settings["wakeMotion2_${uId}"]) subscribe(settings["wakeMotion2_${uId}"], "motion", sequenceMotionHandler) if (settings["bathroomPathMotion_${uId}"]) subscribe(settings["bathroomPathMotion_${uId}"], "motion", sequenceMotionHandler) if (settings["showerMotion_${uId}"]) subscribe(settings["showerMotion_${uId}"], "motion", sequenceMotionHandler) } } schedule("0 0 12 * * ?", "middayReset") schedule("0 0/5 * * * ?", "orchestrateRooms") for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["motionSensor_${i}"]) subscribe(settings["motionSensor_${i}"], "motion", fallbackMotionHandler) if (settings["bathroomMotion_${i}"]) subscribe(settings["bathroomMotion_${i}"], "motion", fallbackBathroomMotionHandler) if (settings["goodNightSwitch_${i}"]) { subscribe(settings["goodNightSwitch_${i}"], "switch.on", goodNightOnHandler) subscribe(settings["goodNightSwitch_${i}"], "switch.off", goodNightOffHandler) } def numUsers = (i == 1) ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" if (settings["usePressureMat_${uId}"] && settings["pressureMat_${uId}"]) { subscribe(settings["pressureMat_${uId}"], "contact", pressureMatHandler) subscribe(settings["pressureMat_${uId}"], "presence", pressureMatHandler) } def vSensor = settings["vibrationSensor_${uId}"] if (vSensor) subscribe(vSensor, "acceleration", vibrationHandler) def vSensor2 = settings["vibrationSensor2_${uId}"] if (vSensor2) subscribe(vSensor2, "acceleration", vibrationHandler) } } } def globalMotionHandler(evt) { if (evt.value == "active") { state.lastGlobalMotionTime = new Date().time } } def noiseLuxHandler(evt) { if (isSystemPaused()) return def rNum = getRoomNumFromDevice(evt.device.id, evt.name == "illuminance" ? "luxSensor" : "noiseSensor") if (!rNum) return def val = evt.value as Double def isSpike = false def typeStr = "" if (evt.name == "illuminance" && val > 10) { isSpike = true typeStr = "Light Bleed (${val} lx)" } else if (evt.name == "soundPressureLevel" && val > 55) { isSpike = true typeStr = "Noise Spike (${val} dB)" } if (isSpike) { ensureStateMaps() state.lastEnvDisturbanceTime["${rNum}"] = new Date().time state.lastEnvDisturbanceType["${rNum}"] = typeStr } } def envHandler(evt) { ensureStateMaps() if (isSystemPaused() || !evt.value) return def type = evt.name == "temperature" ? "tempSensor" : "humidSensor" def rNum = getRoomNumFromDevice(evt.device.id, type) if (!rNum) return def val = evt.value as Double def numUsers = (rNum == "1" || rNum == 1) ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${rNum}_${u}" if (state.sleepState["${uId}"] == "SLEEPING") { if (!state.envStats["${uId}"]) state.envStats["${uId}"] = [tSum: 0.0, tCnt: 0, hSum: 0.0, hCnt: 0] if (evt.name == "temperature") { state.envStats["${uId}"].tSum += val state.envStats["${uId}"].tCnt += 1 } else { state.envStats["${uId}"].hSum += val state.envStats["${uId}"].hCnt += 1 } updateInfoDevice(uId) } } } def calculateRobustAverage(list) { if (!list || list.size() == 0) return null if (list.size() < 4) return (list.sum() / list.size()).toInteger() def sorted = list.collect() sorted.sort() def trimmed = sorted[1..-2] return (trimmed.sum() / trimmed.size()).toInteger() } def calculateUserAverages(uId) { ensureStateMaps() def stats = state.dailyStats?."${uId}" ?: [] def days = stats.size() if (days == 0) return [daysLearned: 0, avgInWd: null, avgInWe: null, avgOutWd: null, avgOutWe: null, avgScore7: null, avgTrips: null, optimalTemp: null, optimalHumid: null] def inBedWdList = [] def inBedWeList = [] def outWdList = [] def outWeList = [] def totalTrips = 0 def tempScores = [:] def humidScores = [:] stats.each { s -> totalTrips += (s.trips ?: 0) if (s.isWeekend) { if (s.inBedMins != null) inBedWeList << s.inBedMins if (s.outBedMins != null) outWeList << s.outBedMins } else { if (s.inBedMins != null) inBedWdList << s.inBedMins if (s.outBedMins != null) outWdList << s.outBedMins } if (settings["enableEnvCorrelation_${uId}"] && s.score != null && s.score > 0) { if (s.avgTemp != null) { def tRound = Math.round(s.avgTemp) if (!tempScores[tRound]) tempScores[tRound] = [sum: 0, cnt: 0] tempScores[tRound].sum += s.score tempScores[tRound].cnt += 1 } if (s.avgHumid != null) { def hRound = Math.round(s.avgHumid / 5) * 5 if (!humidScores[hRound]) humidScores[hRound] = [sum: 0, cnt: 0] humidScores[hRound].sum += s.score humidScores[hRound].cnt += 1 } } } def score7 = 0 def score7Count = 0 def recent7 = stats.reverse().take(7) recent7.each { s -> score7 += (s.score ?: 0) score7Count++ } def optTemp = null def maxAvgScoreT = 0 tempScores.each { k, v -> def avgForTemp = v.sum / v.cnt if (v.cnt >= 2 && avgForTemp > maxAvgScoreT) { maxAvgScoreT = avgForTemp optTemp = k } } def optHumid = null def maxAvgScoreH = 0 humidScores.each { k, v -> def avgForHumid = v.sum / v.cnt if (v.cnt >= 2 && avgForHumid > maxAvgScoreH) { maxAvgScoreH = avgForHumid optHumid = k } } return [ daysLearned: days, avgInWd: calculateRobustAverage(inBedWdList), avgInWe: calculateRobustAverage(inBedWeList), avgOutWd: calculateRobustAverage(outWdList), avgOutWe: calculateRobustAverage(outWeList), avgScore7: score7Count > 0 ? (score7 / score7Count).toInteger() : 0, avgTrips: Math.round((totalTrips / days) * 10) / 10.0, optimalTemp: optTemp, optimalHumid: optHumid ] } def getMinutesFromNoon(timestamp) { if (!timestamp) return 0 def cal = Calendar.getInstance(location.timeZone) cal.setTimeInMillis(timestamp as Long) def hours = cal.get(Calendar.HOUR_OF_DAY) def mins = cal.get(Calendar.MINUTE) def shiftedHours = (hours + 12) % 24 return (shiftedHours * 60) + mins } def formatMinutesFromNoon(mins) { if (mins == null) return "--:--" def unshiftedHours = (mins.toInteger() / 60).toInteger() def actualHours = (unshiftedHours - 12 + 24) % 24 def actualMins = mins.toInteger() % 60 def ampm = actualHours >= 12 ? "PM" : "AM" def displayHours = actualHours % 12 if (displayHours == 0) displayHours = 12 return "${displayHours}:${actualMins.toString().padLeft(2, '0')} ${ampm}" } def isWithinWakeLockout(uId) { if (!settings["enableWakeLockout_${uId}"]) return false def startTime = settings["lockoutStart_${uId}"] def endTime = settings["lockoutEnd_${uId}"] if (!startTime || !endTime) return false def tz = location.timeZone ?: TimeZone.getDefault() def start = timeToday(startTime, tz) def end = timeToday(endTime, tz) def nowTime = new Date().time if (start.time > end.time) { return (nowTime >= start.time || nowTime <= end.time) } else { return (nowTime >= start.time && nowTime <= end.time) } } def getLockoutEndTimeMillis(uId) { def startTime = settings["lockoutStart_${uId}"] def endTime = settings["lockoutEnd_${uId}"] def tz = location.timeZone ?: TimeZone.getDefault() def start = timeToday(startTime, tz) def end = timeToday(endTime, tz) def nowTime = new Date().time if (start.time > end.time) { if (nowTime >= start.time) return end.time + 86400000 else return end.time } else { if (nowTime > end.time) return end.time + 86400000 return end.time } } def processUserMovement(uId, rNum) { def now = new Date().time state.movements["${uId}"] = (state.movements["${uId}"] ?: 0) + 1 // Environmental Check def lastDisturbTime = state.lastEnvDisturbanceTime?."${rNum}" ?: 0 if (lastDisturbTime > 0 && (now - lastDisturbTime) <= 180000) { def dType = state.lastEnvDisturbanceType?."${rNum}" ?: "Disturbance" addToHistory("ENVIRONMENTAL DISTURBANCE: ${getUserName(uId)} movement likely caused by ${dType}.") state.lastEnvDisturbanceTime["${rNum}"] = 0 } def penalty = 0.25 if (settings["enableCircadianScaling_${uId}"]) { def hour = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault()).get(Calendar.HOUR_OF_DAY) if (hour >= 3 && hour <= 8) penalty = 0.10 } state.weightedMovementPenalty["${uId}"] = (state.weightedMovementPenalty["${uId}"] ?: 0.0) + penalty if (settings["enableAdvancedStages_${uId}"]) { def lastT = state.lastMoveTimeForEwma["${uId}"] ?: now def diffMins = (now - lastT) / 60000.0 def decay = Math.pow(0.5, diffMins / 15.0) state.ewmaMovement["${uId}"] = (state.ewmaMovement["${uId}"] ?: 0.0) * decay + 1.0 state.lastMoveTimeForEwma["${uId}"] = now def newStage = state.ewmaMovement["${uId}"] > 1.5 ? "LIGHT" : "DEEP" if (state.currentSleepStage["${uId}"] != newStage) { state.currentSleepStage["${uId}"] = newStage } } if (settings["enableSmartAlarm_${uId}"] && settings["smartAlarmSwitch_${uId}"]) { def todayStr = new Date().format("yyyy-MM-dd", location.timeZone ?: TimeZone.getDefault()) if (state.smartAlarmTriggeredDate["${uId}"] != todayStr && isWithinWakeWindow(uId)) { def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "SLEEPING" || cState == "IN BED") { settings["smartAlarmSwitch_${uId}"].on() state.smartAlarmTriggeredDate["${uId}"] = todayStr addToHistory("⏰ SMART ALARM: ${getUserName(uId)} registered movement/LIGHT sleep inside Wake Window. Fired smart alarm trigger.") } } } def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "SLEEPING") { def stillStart = state.lastStillStartTime["${uId}"] ?: state.asleepTime["${uId}"] ?: now def gap = now - stillStart if (gap >= 2700000) { state.deepSleepDuration["${uId}"] = (state.deepSleepDuration["${uId}"] ?: 0) + (gap / 60000).toInteger() } state.lastStillStartTime["${uId}"] = now } else if (cState == "IN BED") { runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) } } def pressureMatHandler(evt) { ensureStateMaps() if (isSystemPaused()) return def uId = getUserIdFromDevice(evt.device.id, "pressureMat") if (!uId) return def now = new Date().time def rNum = uId.split('_')[0] def cState = state.sleepState["${uId}"] ?: "EMPTY" def uName = getUserName(uId) if (evt.value == "closed" || evt.value == "present") { state.pendingExit["${uId}"] = 0 state.exitSequenceProgress["${uId}"] = 0 if (cState == "EMPTY" || cState == "BATHROOM TRIP") { if (cState == "EMPTY" && !isTrackingAllowed()) return if (settings["enableTeleportFilter"] != false && cState == "EMPTY") { def lastRoomMot = state.lastRoomMotionTime["${rNum}"] ?: 0 def teleWindowMillis = (settings["teleportWindow"] != null ? settings["teleportWindow"].toInteger() : 10) * 60000 if (lastRoomMot == 0 || (now - lastRoomMot) > teleWindowMillis) { log.warn "BMS: Blocked Mat Entry for ${uName}. No room motion detected in the last ${(teleWindowMillis/60000).toInteger()} minutes." return } } if (cState == "EMPTY" && !state.sessionStartTime["${uId}"]) { state.sessionStartTime["${uId}"] = now } def lastExit = state.lastExitTime?."${uId}" ?: 0 def stitchMillis = getDynamicStitchMillis() if ((lastExit > 0 && (now - lastExit) < stitchMillis) || cState == "BATHROOM TRIP") { def awayMins = ((now - lastExit) / 60000).toInteger() if (cState == "BATHROOM TRIP") { state.bathroomTrips["${uId}"] = (state.bathroomTrips["${uId}"] ?: 0) + 1 state.bathroomDuration["${uId}"] = (state.bathroomDuration["${uId}"] ?: 0) + awayMins addToHistory("BATHROOM RETURN: ${uName} returned to bed via Mat (Away: ${awayMins}m). Session seamlessly stitched.") } else { addToHistory("SESSION STITCHED: ${uName} returned within window via Mat (Away: ${awayMins}m). Continuing previous session.") } state.sleepState["${uId}"] = "IN BED" state.sessionResumedTime["${uId}"] = now updateVirtualSwitch(uId, "on") } else { addToHistory("NEW SESSION: ${uName} entered bed via Mat. (Bypassing Anti-Bounce for absolute presence)") state.sleepState["${uId}"] = "IN BED" state.inBedTime["${uId}"] = now state.sessionResumedTime["${uId}"] = 0 state.movements["${uId}"] = 0 state.weightedMovementPenalty["${uId}"] = 0.0 state.asleepTime["${uId}"] = null state.bathroomTrips["${uId}"] = 0 state.bathroomDuration["${uId}"] = 0 updateVirtualSwitch(uId, "on") } state.roomEmptyTime["${rNum}"] = 0 if (settings["enableOrchestrator_${rNum}"] && !state.roomGoodNightTriggered["${rNum}"]) { runIn(10, "orchestrateRooms", [overwrite: true]) } } // As long as they are in bed, keep the evaluation timer running if (state.sleepState["${uId}"] == "IN BED") { runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) } updateInfoDevice(uId) runIn(300, "verifySustainedPressureMat", [data: [uId: uId, roomNum: rNum], overwrite: true]) } else if (evt.value == "open" || evt.value == "not present") { def forceDelay = settings["matExitDelay_${uId}"] != null ? settings["matExitDelay_${uId}"].toInteger() : 10 runIn(forceDelay * 60, "verifySustainedEmptyMat", [data: [uId: uId, roomNum: rNum], overwrite: true]) def rMotion = settings["motionSensor_${rNum}"]?.currentValue("motion") if (settings["enableGhostFilter"] && rMotion == "active") { logTelemetryEvent(uId, "ghostBlocks") addToHistory("GHOST FILTER ACTIVE: External presence detected while Mat opened. Exit sequence pre-emptively locked out.") } else { state.pendingExit["${uId}"] = now def actualThresh = exitBedThreshold != null ? exitBedThreshold.toInteger() : 5 def smartWake = false def mlEnabled = (settings["enableML_${uId}"] != false) if (mlEnabled) { def cal = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault()) def isWknd = (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) def averages = calculateUserAverages(uId) def targetAvgOut = isWknd ? averages.avgOutWe : averages.avgOutWd if (averages.daysLearned >= 14 && targetAvgOut != null) { def nowMins = getMinutesFromNoon(now) if (Math.abs(nowMins - targetAvgOut) <= 60) { actualThresh = Math.max(1, (actualThresh / 2).toInteger()) smartWake = true } } } if (settings["enableAdvancedStages_${uId}"]) { def stage = state.currentSleepStage["${uId}"] ?: "DEEP" if (stage == "LIGHT" && isWithinWakeWindow(uId)) { actualThresh = 0 smartWake = true addToHistory("STAGE EXIT: ${uName} exited while in LIGHT sleep during Expected Wake Window. Accelerating exit.") } else if (stage == "DEEP") { actualThresh = Math.max(actualThresh, 5) } } actualThresh = Math.max(1, actualThresh) runIn(actualThresh * 60, "evaluateBedExit", [data: [uId: uId, roomNum: rNum, thresh: actualThresh, ai: smartWake], overwrite: true]) } } } def verifySustainedEmptyMat(data) { ensureStateMaps() def uId = data.uId def rNum = data.roomNum def mat = settings["pressureMat_${uId}"] if (!mat) return def matState = mat.currentValue("contact") ?: mat.currentValue("presence") if (matState == "open" || matState == "not present") { def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState != "EMPTY" && cState != "BATHROOM TRIP") { addToHistory("SUSTAINED MAT EXIT: ${getUserName(uId)}'s mat has been open for continuous timeout. Bypassing motion checks and forcing bed exit.") forceBedExit(uId, rNum, true) } } } def verifySustainedPressureMat(data) { ensureStateMaps() def uId = data.uId def mat = settings["pressureMat_${uId}"] if (!mat) return def matState = mat.currentValue("contact") ?: mat.currentValue("presence") if (matState == "closed" || matState == "present") { def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "EMPTY" || cState == "PENDING ENTRY") { addToHistory("SUSTAINED PRESSURE OVERRIDE: ${getUserName(uId)}'s mat has been closed for 5 continuous minutes despite room motion. Forcing IN BED state.") state.sleepState["${uId}"] = "IN BED" if (!state.sessionStartTime["${uId}"]) state.sessionStartTime["${uId}"] = new Date().time state.inBedTime["${uId}"] = state.pendingEntryTime["${uId}"] ?: new Date().time state.movements["${uId}"] = 0 state.weightedMovementPenalty["${uId}"] = 0.0 state.asleepTime["${uId}"] = null updateVirtualSwitch(uId, "on") updateInfoDevice(uId) runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) } } } def vibrationHandler(evt) { ensureStateMaps() if (isSystemPaused()) return def uId = getUserIdFromDevice(evt.device.id, "vibrationSensor") if (!uId) return def now = new Date().time def rNum = uId.split('_')[0] def deafened = state.deafenedUntil?."${uId}" ?: 0 if (now < deafened) return def useMat = settings["usePressureMat_${uId}"] && settings["pressureMat_${uId}"] // Strict Input Filter: If the Mat is being used, ignore ALL vibrations if they are NOT on the mat. if (useMat) { def pMat = settings["pressureMat_${uId}"] if (pMat && (pMat.currentValue("contact") == "open" || pMat.currentValue("presence") == "not present")) { return } } if (evt.value == "active") { state.lastVibrationTime["${uId}"] = now if (useMat) { def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "SLEEPING" || cState == "IN BED") { processUserMovement(uId, rNum) if (state.pendingExit["${uId}"] > 0) { addToHistory("EDGE OF BED FUSION: ${getUserName(uId)} is off the mat but kinetic movement detected. Assuming roll-to-edge and canceling exit sequence.") state.pendingExit["${uId}"] = 0 } } updateInfoDevice(uId) return } state.pendingExit["${uId}"] = 0 state.exitSequenceProgress["${uId}"] = 0 def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "EMPTY" || cState == "BATHROOM TRIP") { if (cState == "EMPTY" && !isTrackingAllowed()) return if (settings["enableTeleportFilter"] != false && cState == "EMPTY") { def lastRoomMot = state.lastRoomMotionTime["${rNum}"] ?: 0 def teleWindowMillis = (settings["teleportWindow"] != null ? settings["teleportWindow"].toInteger() : 10) * 60000 if (lastRoomMot == 0 || (now - lastRoomMot) > teleWindowMillis) { log.warn "BMS: Blocked Kinetic Entry for ${getUserName(uId)}. No room motion detected in the last ${(teleWindowMillis/60000).toInteger()} minutes." return } } if (cState == "EMPTY" && !state.sessionStartTime["${uId}"]) { state.sessionStartTime["${uId}"] = now } def mlEnabled = (settings["enableML_${uId}"] != false) def reqVibs = settings.vibrationsToEnterBed != null ? settings.vibrationsToEnterBed.toInteger() : 1 if (reqVibs < 2 && mlEnabled) reqVibs = 2 def smartEntry = false if (mlEnabled) { def cal = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault()) def isWknd = (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) def averages = calculateUserAverages(uId) def targetAvgIn = isWknd ? averages.avgInWe : averages.avgInWd if (averages.daysLearned >= 14 && targetAvgIn != null) { def nowMins = getMinutesFromNoon(now) if (Math.abs(nowMins - targetAvgIn) <= 60) { reqVibs = Math.max(2, reqVibs - 1) smartEntry = true } } } state.entryVibrationCount["${uId}"] = (state.entryVibrationCount["${uId}"] ?: 0) + 1 if (state.entryVibrationCount["${uId}"] < reqVibs) { runIn(600, "clearEntryVibrationCount", [data: [uId: uId], overwrite: true]) return } state.entryVibrationCount["${uId}"] = 0 def lastExit = state.lastExitTime?."${uId}" ?: 0 def stitchMillis = getDynamicStitchMillis() def uName = getUserName(uId) if ((lastExit > 0 && (now - lastExit) < stitchMillis) || cState == "BATHROOM TRIP") { def awayMins = ((now - lastExit) / 60000).toInteger() if (cState == "BATHROOM TRIP") { state.bathroomTrips["${uId}"] = (state.bathroomTrips["${uId}"] ?: 0) + 1 state.bathroomDuration["${uId}"] = (state.bathroomDuration["${uId}"] ?: 0) + awayMins addToHistory("BATHROOM RETURN: ${uName} returned to bed (Away: ${awayMins}m). Session seamlessly stitched.") } else { addToHistory("SESSION STITCHED: ${uName} returned within window (Away: ${awayMins}m). Continuing previous session.") } state.sleepState["${uId}"] = "IN BED" state.sessionResumedTime["${uId}"] = now updateVirtualSwitch(uId, "on") } else { state.inBedTime["${uId}"] = now state.sessionResumedTime["${uId}"] = 0 state.movements["${uId}"] = 0 state.weightedMovementPenalty["${uId}"] = 0.0 state.asleepTime["${uId}"] = null state.bathroomTrips["${uId}"] = 0 state.bathroomDuration["${uId}"] = 0 if (settings["enableAntiBounce"]) { def abWait = settings.antiBounceWait != null ? settings.antiBounceWait.toInteger() : 3 if (smartEntry) { abWait = 1 addToHistory("🧠 AI PREDICTION: ${uName} activity matches learned bedtime. Fast-tracking Anti-Bounce verification.") } else { addToHistory("SUSTAINED ENTRY VERIFICATION: ${uName} met entry threshold. Verifying against room activity for ${abWait} minutes...") } state.sleepState["${uId}"] = "PENDING ENTRY" state.pendingEntryTime["${uId}"] = now state.pendingRoomMotions["${uId}"] = 0 state.pendingAntiBounceWait["${uId}"] = abWait runIn(abWait * 60, "verifyBedEntry", [data: [uId: uId, roomNum: rNum], overwrite: false]) } else { if (smartEntry) addToHistory("🧠 AI PREDICTION: ${uName} activity matches learned bedtime. Instant Entry applied.") else addToHistory("NEW SESSION: ${uName} entered bed.") state.sleepState["${uId}"] = "IN BED" updateVirtualSwitch(uId, "on") } } state.roomEmptyTime["${rNum}"] = 0 if (settings["enableOrchestrator_${rNum}"] && !state.roomGoodNightTriggered["${rNum}"]) { runIn(10, "orchestrateRooms", [overwrite: true]) } } else if (cState == "PENDING ENTRY") { def pStart = state.pendingEntryTime["${uId}"] ?: now def waitSecs = (state.pendingAntiBounceWait["${uId}"] ?: 3) * 60 if (((now - pStart) / 1000) > waitSecs) { log.warn "BMS: PENDING ENTRY timer was dropped for ${getUserName(uId)}. Forcing verification." verifyBedEntry([uId: uId, roomNum: rNum]) } } else if (cState == "SLEEPING") { processUserMovement(uId, rNum) } if (state.sleepState["${uId}"] == "IN BED") { runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) } updateInfoDevice(uId) } else { if (useMat) return def rMotion = settings["motionSensor_${rNum}"]?.currentValue("motion") if (settings["enableGhostFilter"] && rMotion == "active") { logTelemetryEvent(uId, "ghostBlocks") addToHistory("GHOST FILTER ACTIVE: External presence detected during ${getUserName(uId)}'s bed movement. Exit sequence pre-emptively locked out.") } else { state.pendingExit["${uId}"] = now def mlEnabled = (settings["enableML_${uId}"] != false) def actualThresh = exitBedThreshold != null ? exitBedThreshold.toInteger() : 5 def smartWake = false if (mlEnabled) { def cal = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault()) def isWknd = (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) def averages = calculateUserAverages(uId) def targetAvgOut = isWknd ? averages.avgOutWe : averages.avgOutWd if (averages.daysLearned >= 14 && targetAvgOut != null) { def nowMins = getMinutesFromNoon(now) if (Math.abs(nowMins - targetAvgOut) <= 60) { actualThresh = Math.max(1, (actualThresh / 2).toInteger()) smartWake = true } } } if (settings["enableAdvancedStages_${uId}"]) { def stage = state.currentSleepStage["${uId}"] ?: "DEEP" if (stage == "LIGHT" && isWithinWakeWindow(uId)) { actualThresh = 0 smartWake = true addToHistory("STAGE EXIT: ${getUserName(uId)} exited while in LIGHT sleep during Expected Wake Window. Accelerating exit.") } else if (stage == "DEEP") { actualThresh = Math.max(actualThresh, 5) } } actualThresh = Math.max(1, actualThresh) runIn(actualThresh * 60, "evaluateBedExit", [data: [uId: uId, roomNum: rNum, thresh: actualThresh, ai: smartWake], overwrite: true]) } } } def verifyBedEntry(data) { ensureStateMaps() def uId = data.uId def rNum = data.roomNum def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "PENDING ENTRY") { def motions = state.pendingRoomMotions["${uId}"] ?: 0 def maxMotions = settings["antiBounceMaxMotions"] != null ? settings["antiBounceMaxMotions"].toInteger() : 2 if (motions <= maxMotions) { addToHistory("VERIFIED ENTRY: Room settled down. Committing ${getUserName(uId)} to IN BED state retroactively.") state.sleepState["${uId}"] = "IN BED" updateVirtualSwitch(uId, "on") runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) } else { addToHistory("ENTRY ABORTED: Excessive room motion detected during Anti-Bounce wait. Assuming ${getUserName(uId)} was awake/active.") state.sleepState["${uId}"] = "EMPTY" state.inBedTime["${uId}"] = null } updateInfoDevice(uId) } } def isSettlingLockActive(uId) { if (!settings["enableSettlingLock"]) return false def inBed = state.inBedTime?."${uId}" ?: 0 def resumed = state.sessionResumedTime?."${uId}" ?: 0 def lockStart = Math.max(inBed as Long, resumed as Long) if (lockStart == 0) return false def lockMillis = (settings["settlingLockTime"] != null ? settings["settlingLockTime"].toInteger() : 30) * 60000 def now = new Date().time return (now - lockStart) < lockMillis } def getDynamicStitchMillis() { def baseWindow = (settings["stitchingWindow"] != null ? settings["stitchingWindow"].toInteger() : 15) * 60000 def deepWindow = (settings["deepSleepStitchWindow"] != null ? settings["deepSleepStitchWindow"].toInteger() : 45) * 60000 if (settings["deepSleepStart"] && settings["deepSleepEnd"]) { def tz = location.timeZone ?: TimeZone.getDefault() def start = timeToday(settings["deepSleepStart"], tz) def end = timeToday(settings["deepSleepEnd"], tz) def nowTime = new Date().time def inDeepSleep = false if (start.time > end.time) { inDeepSleep = (nowTime >= start.time || nowTime <= end.time) } else { inDeepSleep = (nowTime >= start.time && nowTime <= end.time) } return inDeepSleep ? deepWindow : baseWindow } return baseWindow } def evaluateBathroomTimeout(data) { ensureStateMaps() def uId = data.uId def now = new Date().time if (state.sleepState["${uId}"] == "BATHROOM TRIP") { if (isWithinWakeLockout(uId)) { addToHistory("WAKE LOCKOUT: Bathroom trip expired for ${getUserName(uId)}, but inside Lockout Window. Extending trip until window ends.") def lockoutEnd = getLockoutEndTimeMillis(uId) def delaySecs = ((lockoutEnd - now) / 1000).toInteger() + 10 runIn(delaySecs, "evaluateBathroomTimeout", [data: [uId: uId, roomNum: data.roomNum], overwrite: true]) return } if (settings["enableQuietHouseReturn"] && settings["globalMotionSensors"]) { def lastGlobal = state.lastGlobalMotionTime ?: 0 def quietMins = settings["quietHouseThreshold"] != null ? settings["quietHouseThreshold"].toInteger() : 20 def quietMillis = quietMins * 60000 def awayMillis = now - (state.lastExitTime["${uId}"] ?: now) if ((now - lastGlobal) >= quietMillis) { addToHistory("🤫 QUIET HOUSE OVERRIDE: House motion inactive for ${quietMins}m. Assuming ${getUserName(uId)} sneaked back into bed. Auto-stitching session.") state.sleepState["${uId}"] = "IN BED" state.bathroomTrips["${uId}"] = (state.bathroomTrips["${uId}"] ?: 0) + 1 state.bathroomDuration["${uId}"] = (state.bathroomDuration["${uId}"] ?: 0) + (awayMillis / 60000).toInteger() state.sessionResumedTime["${uId}"] = now updateVirtualSwitch(uId, "on") updateInfoDevice(uId) runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) return } else if (awayMillis < quietMillis) { def delaySecs = ((quietMillis - awayMillis) / 1000).toInteger() + 10 runIn(delaySecs, "evaluateBathroomTimeout", [data: [uId: uId, roomNum: data.roomNum], overwrite: true]) return } } addToHistory("BATHROOM TRIP EXPIRED: ${getUserName(uId)} did not return within the stitching window. Assuming terminal wake.") state.bathroomTrips["${uId}"] = Math.max(0, (state.bathroomTrips["${uId}"] ?: 1) - 1) forceBedExit(uId, data.roomNum, true) } } def sequenceMotionHandler(evt) { ensureStateMaps() if (isSystemPaused() || evt.value != "active") return def devId = evt.device.id def now = new Date().time for (int i = 1; i <= (numRooms as Integer); i++) { def numUsers = (i == 1) ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "EMPTY") continue if (isSettlingLockActive(uId)) { logTelemetryEvent(uId, "settlingLockBlocks") continue } def m1 = settings["wakeMotion1_${uId}"] def m2 = settings["wakeMotion2_${uId}"] def bPath = settings["bathroomPathMotion_${uId}"] def sMotion = settings["showerMotion_${uId}"] def pending = state.pendingExit["${uId}"] ?: 0 def progress = state.exitSequenceProgress["${uId}"] ?: 0 if (sMotion?.id == devId) { def isRecentExit = false if (pending > 0 && (now - pending) <= 600000) isRecentExit = true if (cState == "BATHROOM TRIP" && state.lastExitTime["${uId}"] && (now - state.lastExitTime["${uId}"]) <= 600000) isRecentExit = true if (isRecentExit) { if (isWithinWakeLockout(uId)) { addToHistory("WAKE LOCKOUT: Shower detected for ${getUserName(uId)}, but inside Lockout Window. Delaying wake confirmation.") if (cState != "BATHROOM TRIP") { state.sleepState["${uId}"] = "BATHROOM TRIP" state.lastExitTime["${uId}"] = pending > 0 ? pending : now updateVirtualSwitch(uId, "off") updateInfoDevice(uId) } def lockoutEnd = getLockoutEndTimeMillis(uId) def delaySecs = ((lockoutEnd - now) / 1000).toInteger() + 10 runIn(delaySecs, "evaluateBathroomTimeout", [data: [uId: uId, roomNum: i], overwrite: true]) } else { addToHistory("SHOWER WAKE CONFIRMED: ${getUserName(uId)} entered the shower. Terminal Wake applied.") if (cState == "BATHROOM TRIP") { state.bathroomTrips["${uId}"] = Math.max(0, (state.bathroomTrips["${uId}"] ?: 1) - 1) } forceBedExit(uId, "${i}") } continue } } if (pending > 0 && (now - pending) < 300000) { if (progress == 0 && m1?.id == devId) { state.exitSequenceProgress["${uId}"] = 1 state.sequenceStep1Time["${uId}"] = now addToHistory("SEQUENCE: ${getUserName(uId)} triggered Step 1 (${m1.displayName}). Monitoring path...") handlePartnerShield(uId, "${i}") } else if (progress == 1 && m2?.id == devId) { def step1Time = state.sequenceStep1Time["${uId}"] ?: 0 def kineticDelay = (settings["kineticDelaySeconds_${uId}"] ?: 3) * 1000 if ((now - step1Time) >= kineticDelay) { state.exitSequenceProgress["${uId}"] = 2 if (isWithinWakeLockout(uId)) { addToHistory("WAKE LOCKOUT: Sequence confirmed for ${getUserName(uId)}, but inside Strict Lockout Window. Converting to Bathroom Trip.") state.sleepState["${uId}"] = "BATHROOM TRIP" state.lastExitTime["${uId}"] = now updateVirtualSwitch(uId, "off") updateInfoDevice(uId) def lockoutEnd = getLockoutEndTimeMillis(uId) def delaySecs = ((lockoutEnd - now) / 1000).toInteger() + 10 runIn(delaySecs, "evaluateBathroomTimeout", [data: [uId: uId, roomNum: i], overwrite: true]) } else { addToHistory("SEQUENCE CONFIRMED: ${getUserName(uId)} triggered Step 2 (${m2.displayName}). WAKE PATH validated.") forceBedExit(uId, "${i}") } } else { addToHistory("KINETIC FILTER: Step 2 triggered impossibly fast (${now - step1Time}ms). Rejected as false trigger.") } } else if (progress == 1 && bPath?.id == devId) { state.exitSequenceProgress["${uId}"] = 3 state.lastExitTime["${uId}"] = now addToHistory("SEQUENCE CONFIRMED: ${getUserName(uId)} triggered Alternative (${bPath.displayName}). BATHROOM TRIP validated.") state.sleepState["${uId}"] = "BATHROOM TRIP" updateInfoDevice(uId) def stitchSecs = (getDynamicStitchMillis() / 1000).toInteger() runIn(stitchSecs, "evaluateBathroomTimeout", [data: [uId: uId, roomNum: i], overwrite: true]) } } } } } def fallbackBathroomMotionHandler(evt) { ensureStateMaps() if (isSystemPaused() || evt.value != "active") return def rNum = getRoomNumFromDevice(evt.device.id, "bathroomMotion") if (!rNum) return def now = new Date().time def users = (rNum == "1") ? 2 : 1 for (int u = 1; u <= users; u++) { def uId = "${rNum}_${u}" def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "IN BED" || cState == "SLEEPING") { def pending = state.pendingExit["${uId}"] ?: 0 if (pending > 0 && !isSettlingLockActive(uId)) { state.lastExitTime["${uId}"] = pending state.sleepState["${uId}"] = "BATHROOM TRIP" state.pendingExit["${uId}"] = 0 state.exitSequenceProgress["${uId}"] = 0 updateVirtualSwitch(uId, "off") addToHistory("BATHROOM MOTION DETECTED: ${getUserName(uId)} went straight to the bathroom. Logging trip.") updateInfoDevice(uId) def stitchSecs = (getDynamicStitchMillis() / 1000).toInteger() runIn(stitchSecs, "evaluateBathroomTimeout", [data: [uId: uId, roomNum: rNum], overwrite: true]) } } } } def fallbackMotionHandler(evt) { ensureStateMaps() if (isSystemPaused() || evt.value != "active") return def rNum = getRoomNumFromDevice(evt.device.id, "motionSensor") if (!rNum) return def now = new Date().time state.lastRoomMotionTime["${rNum}"] = now def users = (rNum == "1") ? 2 : 1 for (int u = 1; u <= users; u++) { def uId = "${rNum}_${u}" def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "PENDING ENTRY") { state.pendingRoomMotions["${uId}"] = (state.pendingRoomMotions["${uId}"] ?: 0) + 1 continue } if (cState == "EMPTY" || cState == "BATHROOM TRIP") continue if (isSettlingLockActive(uId)) { logTelemetryEvent(uId, "settlingLockBlocks") continue } if (settings["parentalGuard_${uId}"]) { logTelemetryEvent(uId, "inBedMotionsIgnored") continue } def useMat = settings["usePressureMat_${uId}"] && settings["pressureMat_${uId}"] def matIsClosed = false if (useMat) { def mState = settings["pressureMat_${uId}"].currentValue("contact") ?: settings["pressureMat_${uId}"].currentValue("presence") if (mState == "closed" || mState == "present") matIsClosed = true } if (matIsClosed && (cState == "IN BED" || cState == "SLEEPING")) { processUserMovement(uId, rNum) updateInfoDevice(uId) } def lastVib = state.lastVibrationTime["${uId}"] ?: 0 if ((now - lastVib) <= 90000) { state.lastValidExitMotion["${uId}"] = now handlePartnerShield(uId, rNum) def pending = state.pendingExit["${uId}"] ?: 0 if (pending > 0 && (now - pending) >= ((exitBedThreshold ?: 5) * 60000)) { runIn(1, "evaluateBedExit", [data: [uId: uId, roomNum: rNum], overwrite: true]) } } else { logTelemetryEvent(uId, "inBedMotionsIgnored") } } } def clearEntryVibrationCount(data) { def uId = data.uId if (state.sleepState["${uId}"] == "EMPTY" || state.sleepState["${uId}"] == "BATHROOM TRIP") { state.entryVibrationCount["${uId}"] = 0 } } def handlePartnerShield(uId, rNum) { def enableShield = settings["enableCrossTalk"] != null ? settings["enableCrossTalk"] : true if (!enableShield || rNum != "1") return def partnerId = (uId == "1_1") ? "1_2" : "1_1" def now = new Date().time def pState = state.sleepState["${partnerId}"] ?: "EMPTY" if (pState != "EMPTY") { state.deafenedUntil["${partnerId}"] = now + ((crossTalkDeafenTime ?: 60) * 1000) if (pState == "SLEEPING") { state.movements["${partnerId}"] = Math.max(0, (state.movements["${partnerId}"] ?: 1) - 1) logTelemetryEvent(partnerId, "crossTalkAvoided") } } } def forceBedExit(uId, rNum, keepExistingTime = false) { ensureStateMaps() def now = new Date().time def exitTime = (keepExistingTime && state.lastExitTime["${uId}"]) ? state.lastExitTime["${uId}"] : now state.lastExitTime["${uId}"] = exitTime def inBed = state.inBedTime["${uId}"] ?: exitTime def inBedMins = ((exitTime - inBed) / 60000).toInteger() state.lastSessionInBed["${uId}"] = inBedMins if (state.asleepTime["${uId}"]) { state.lastSessionAsleep["${uId}"] = ((exitTime - state.asleepTime["${uId}"]) / 60000).toInteger() def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "SLEEPING") { def stillStart = state.lastStillStartTime["${uId}"] ?: state.asleepTime["${uId}"] ?: exitTime def gap = exitTime - stillStart if (gap >= 2700000) { state.deepSleepDuration["${uId}"] = (state.deepSleepDuration["${uId}"] ?: 0) + (gap / 60000).toInteger() } } } def sleepScore = calculateEfficiencyScore(uId) addToHistory("BED EXIT: ${getUserName(uId)} verified out of bed. Session Duration: ${formatDuration(inBedMins)}. Sleep Score: ${sleepScore}%") state.sleepState["${uId}"] = "EMPTY" state.pendingExit["${uId}"] = 0 state.exitSequenceProgress["${uId}"] = 0 updateVirtualSwitch(uId, "off") updateInfoDevice(uId) checkRoomEmptyStatus(rNum) } def evaluateBedExit(data) { ensureStateMaps() def uId = data.uId def rNum = data.roomNum def thresh = data.thresh != null ? data.thresh.toInteger() : (exitBedThreshold != null ? exitBedThreshold.toInteger() : 5) def ai = data.ai ?: false def now = new Date().time def pending = state.pendingExit["${uId}"] ?: 0 def lastValid = state.lastValidExitMotion["${uId}"] ?: 0 def progress = state.exitSequenceProgress["${uId}"] ?: 0 if (state.sleepState["${uId}"] == "EMPTY") return if (isWithinWakeLockout(uId)) { if (pending > 0 && (now - pending) >= (thresh * 60000)) { if (state.sleepState["${uId}"] != "BATHROOM TRIP") { addToHistory("WAKE LOCKOUT: Wake condition met for ${getUserName(uId)}, but inside Strict Lockout Window. Converting to Bathroom Trip.") state.sleepState["${uId}"] = "BATHROOM TRIP" state.lastExitTime["${uId}"] = pending > 0 ? pending : now updateVirtualSwitch(uId, "off") updateInfoDevice(uId) def lockoutEnd = getLockoutEndTimeMillis(uId) def delaySecs = ((lockoutEnd - now) / 1000).toInteger() + 10 runIn(delaySecs, "evaluateBathroomTimeout", [data: [uId: uId, roomNum: rNum], overwrite: true]) } } return } if (pending > 0 && (now - pending) >= (thresh * 60000)) { if (lastValid > pending) { if (ai) addToHistory("🧠 AI PREDICTION: ${getUserName(uId)} learned wake pattern detected. Wake sequence accelerated.") forceBedExit(uId, rNum) } else if (progress == 1 && isWithinWakeWindow(uId)) { addToHistory("WINDOW EXIT: ${getUserName(uId)} triggered Step 1 inside Expected Wake Window. Assuming true wake.") forceBedExit(uId, rNum) } } } def isWithinWakeWindow(uId) { def tz = location.timeZone ?: TimeZone.getDefault() def cal = Calendar.getInstance(tz) def isWeekend = (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) def wStart = isWeekend ? settings["weekendWakeStart_${uId}"] : settings["weekdayWakeStart_${uId}"] def wEnd = isWeekend ? settings["weekendWakeEnd_${uId}"] : settings["weekdayWakeEnd_${uId}"] if (!wStart || !wEnd) return false def start = timeToday(wStart, tz) def end = timeToday(wEnd, tz) def nowTime = new Date().time if (start.time > end.time) { return (nowTime >= start.time || nowTime <= end.time) } else { return (nowTime >= start.time && nowTime <= end.time) } } def orchestrateRooms() { ensureStateMaps() if (isSystemPaused()) return def now = new Date().time def watchdogMillis = (sensorWatchdogHours ?: 48) * 3600000 for (int i = 1; i <= (numRooms as Integer); i++) { if (!settings["enableOrchestrator_${i}"]) continue def gnSwitch = settings["goodNightSwitch_${i}"] def numUsers = (i == 1) ? 2 : 1 def totalActiveUsers = 0 def totalUsersReady = 0 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" if (settings["vibrationSensor_${uId}"] || settings["vibrationSensor2_${uId}"] || settings["pressureMat_${uId}"]) { def lastVib = state.lastVibrationTime?."${uId}" ?: 0 if (lastVib > 0 && (now - lastVib) > watchdogMillis) continue totalActiveUsers++ def cState = state.sleepState["${uId}"] ?: "EMPTY" def inBedTime = state.inBedTime?."${uId}" ?: 0 if (cState == "BATHROOM TRIP") { if (settings["enableQuietHouseReturn"] && settings["globalMotionSensors"]) { def lastGlobal = state.lastGlobalMotionTime ?: 0 def quietMins = settings["quietHouseThreshold"] != null ? settings["quietHouseThreshold"].toInteger() : 20 def quietMillis = quietMins * 60000 if ((now - lastGlobal) >= quietMillis) { def exitTime = state.lastExitTime["${uId}"] ?: now if ((now - exitTime) >= quietMillis) { addToHistory("🤫 QUIET HOUSE OVERRIDE (Watchdog): House motion inactive for ${quietMins}m. Rescuing stuck bathroom trip for ${getUserName(uId)}.") state.sleepState["${uId}"] = "IN BED" state.bathroomTrips["${uId}"] = (state.bathroomTrips["${uId}"] ?: 0) + 1 state.bathroomDuration["${uId}"] = (state.bathroomDuration["${uId}"] ?: 0) + ((now - exitTime) / 60000).toInteger() state.sessionResumedTime["${uId}"] = now updateVirtualSwitch(uId, "on") updateInfoDevice(uId) runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) } } } } if (gnSwitch && !state.roomGoodNightTriggered["${i}"] && gnSwitch.currentValue("switch") != "on") { def requiredMillis = (settings["goodNightDelay_${i}"] ?: 30) * 60000 if (cState == "IN BED" || cState == "SLEEPING") { if (inBedTime > 0 && (now - inBedTime) >= requiredMillis) totalUsersReady++ } } } } if (gnSwitch && !state.roomGoodNightTriggered["${i}"] && gnSwitch.currentValue("switch") != "on") { if (totalActiveUsers > 0 && totalUsersReady >= totalActiveUsers) { addToHistory("ROOM ORCHESTRATOR: All active users in ${getRoomName(i)} met In-Bed threshold. Engaging Good Night Switch.") state.roomGoodNightTriggered["${i}"] = true gnSwitch.on() } } } } def goodNightOnHandler(evt) { ensureStateMaps() if (isSystemPaused()) return def deviceId = evt.device.id def now = new Date().time for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["goodNightSwitch_${i}"]?.id == deviceId) { state.roomEmptyTime["${i}"] = 0 state.roomGoodNightTriggered["${i}"] = true def numUsers = (i == 1) ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "EMPTY" || cState == "PENDING ENTRY") { addToHistory("GOOD NIGHT OVERRIDE: Forcing ${getUserName(uId)} to IN BED.") state.sleepState["${uId}"] = "IN BED" if (!state.sessionStartTime["${uId}"]) state.sessionStartTime["${uId}"] = now state.inBedTime["${uId}"] = now state.movements["${uId}"] = 0 state.weightedMovementPenalty["${uId}"] = 0.0 state.asleepTime["${uId}"] = null state.lastVibrationTime["${uId}"] = now state.pendingExit["${uId}"] = now updateVirtualSwitch(uId, "on") updateInfoDevice(uId) runIn((fallAsleepThreshold ?: 15) * 60, "evaluateSleepState", [data: [uId: uId], overwrite: true]) } } if (settings["enableOrchestrator_${i}"]) runIn(10, "orchestrateRooms", [overwrite: true]) } } } def goodNightOffHandler(evt) { ensureStateMaps() if (isSystemPaused()) return def deviceId = evt.device.id def now = new Date().time for (int i = 1; i <= (numRooms as Integer); i++) { if (settings["goodNightSwitch_${i}"]?.id == deviceId) { state.roomGoodNightTriggered["${i}"] = false def numUsers = (i == 1) ? 2 : 1 def externalOverrideTriggered = false for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState != "EMPTY") { externalOverrideTriggered = true forceBedExit(uId, "${i}") } } if (externalOverrideTriggered) { state.roomEmptyTime["${i}"] = now } } } } def isTrackingAllowed() { if (isSystemPaused()) return false def modeAllowed = true if (activeSleepModes && !activeSleepModes.contains(location.mode)) modeAllowed = false def timeAllowed = true if (sleepStartTime && sleepEndTime) { def tz = location.timeZone ?: TimeZone.getDefault() def start = timeToday(sleepStartTime, tz) def end = timeToday(sleepEndTime, tz) def now = new Date() if (start.time > end.time) { timeAllowed = (now.time >= start.time || now.time <= end.time) } else { timeAllowed = (now.time >= start.time && now.time <= end.time) } } if (activeSleepModes && !modeAllowed) return false if (sleepStartTime && sleepEndTime && !timeAllowed) return false return true } def evaluateSleepState(data) { ensureStateMaps() def uId = data.uId // STRICT GATE: Only evaluate if they are physically on the mat (if configured). if (settings["usePressureMat_${uId}"]) { def pMat = settings["pressureMat_${uId}"] if (pMat && (pMat.currentValue("contact") == "open" || pMat.currentValue("presence") == "not present")) { return // Abort. They aren't in bed. } } def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "IN BED") { def now = new Date().time def lastVib = state.lastVibrationTime["${uId}"] ?: 0 if ((now - lastVib) >= ((fallAsleepThreshold ?: 15) * 60000)) { def inBed = state.inBedTime["${uId}"] ?: now def actualSleepTime = (lastVib > inBed) ? lastVib : now state.sleepState["${uId}"] = "SLEEPING" state.asleepTime["${uId}"] = actualSleepTime state.lastStillStartTime["${uId}"] = actualSleepTime state.sleepLatency["${uId}"] = ((actualSleepTime - inBed) / 60000).toInteger() addToHistory("SLEEP DETECTED: ${getUserName(uId)} marking as SLEEPING retroactively to ${formatTimestamp(actualSleepTime)}. (Latency: ${state.sleepLatency["${uId}"]}m)") state.envStats["${uId}"] = [tSum: 0.0, tCnt: 0, hSum: 0.0, hCnt: 0] def rNum = uId.split('_')[0] def tSens = settings["tempSensor_${rNum}"] def hSens = settings["humidSensor_${rNum}"] if (tSens && tSens.currentValue("temperature") != null) { state.envStats["${uId}"].tSum += (tSens.currentValue("temperature") as Double) state.envStats["${uId}"].tCnt += 1 } if (hSens && hSens.currentValue("humidity") != null) { state.envStats["${uId}"].hSum += (hSens.currentValue("humidity") as Double) state.envStats["${uId}"].hCnt += 1 } updateInfoDevice(uId) } } } def checkRoomEmptyStatus(rNum) { ensureStateMaps() def numUsers = (rNum == "1" || rNum == 1) ? 2 : 1 def roomIsVacantForWake = true for (int u = 1; u <= numUsers; u++) { def uId = "${rNum}_${u}" def cState = state.sleepState["${uId}"] ?: "EMPTY" if ((settings["vibrationSensor_${uId}"] || settings["vibrationSensor2_${uId}"] || settings["pressureMat_${uId}"]) && cState != "EMPTY") { roomIsVacantForWake = false break } } if (roomIsVacantForWake) { state.roomEmptyTime["${rNum}"] = new Date().time def wakeMins = settings["wakeDelay_${rNum}"] != null ? settings["wakeDelay_${rNum}"].toInteger() : 45 addToHistory("ROOM EMPTY: ${getRoomName(rNum)} vacant. Starting ${wakeMins}m Wake Confirmation buffer.") runIn(wakeMins * 60, "evaluateRoomWake", [data: [roomNum: rNum], overwrite: true]) } } def evaluateRoomWake(data) { ensureStateMaps() if (isSystemPaused()) return def rNum = data.roomNum def emptyTime = state.roomEmptyTime["${rNum}"] ?: 0 if (emptyTime > 0) { def numUsers = (rNum == "1" || rNum == 1) ? 2 : 1 def roomIsVacantForWake = true for (int u = 1; u <= numUsers; u++) { def uId = "${rNum}_${u}" def cState = state.sleepState["${uId}"] ?: "EMPTY" if ((settings["vibrationSensor_${uId}"] || settings["vibrationSensor2_${uId}"] || settings["pressureMat_${uId}"]) && cState != "EMPTY") { roomIsVacantForWake = false break } } if (roomIsVacantForWake) { def gnSwitch = settings["goodNightSwitch_${rNum}"] if (gnSwitch && gnSwitch.currentValue("switch") == "on" && settings["enableOrchestrator_${rNum}"]) { gnSwitch.off() addToHistory("WAKE CONFIRMED: ${getRoomName(rNum)} remained empty for the timeout period. Turning OFF Good Night switch.") } else { addToHistory("WAKE CONFIRMED: ${getRoomName(rNum)} remained empty for the timeout period.") } state.roomGoodNightTriggered["${rNum}"] = false } } } // --- BMS MATH & SCORING --- def calculateEfficiencyScore(uId) { ensureStateMaps() def cState = state.sleepState["${uId}"] ?: "EMPTY" def inBed = cState == "EMPTY" ? (state.lastSessionInBed["${uId}"] ?: 0) : ((new Date().time - (state.inBedTime?."${uId}" ?: new Date().time)) / 60000) def asleep = cState == "EMPTY" ? (state.lastSessionAsleep["${uId}"] ?: 0) : ((new Date().time - (state.asleepTime?."${uId}" ?: new Date().time)) / 60000) if (!inBed || inBed < 30) return 0 def moves = state.movements?."${uId}" ?: 0 def rawPenalty = state.weightedMovementPenalty?."${uId}" != null ? state.weightedMovementPenalty["${uId}"].toDouble() : (moves * 0.25) // Backward compatibility fix for the 2.0 penalty math explosion if (rawPenalty > moves) rawPenalty = moves * 0.25 def movementPenalty = Math.min(30.0, rawPenalty) def efficiency = (asleep / inBed) * 100.0 def baseScore = efficiency - movementPenalty def finalScore = baseScore def clinicalEnabled = settings["enableClinicalScoring_${uId}"] if (clinicalEnabled) { // 1. Duration Score def targetHours = settings["targetSleepHours_${uId}"] != null ? settings["targetSleepHours_${uId}"].toDouble() : 7.5 def targetMins = targetHours * 60 def durationRatio = Math.min(1.0, asleep / targetMins) // 50% Efficiency/Restlessness, 50% Duration Goal finalScore = (baseScore * 0.5) + ((durationRatio * 100.0) * 0.5) // 2. Latency Modifiers def latency = state.sleepLatency["${uId}"] ?: 0 if (latency > 0 && latency < 5) finalScore -= 5.0 else if (latency >= 10 && latency <= 20) finalScore += 5.0 else if (latency >= 30 && latency < 45) finalScore -= 5.0 else if (latency >= 45) finalScore -= 10.0 // 3. Regularity Modifier (Social Jet Lag) def averages = calculateUserAverages(uId) def mlEnabled = (settings["enableML_${uId}"] != false) if (mlEnabled && averages.daysLearned >= 14) { def cal = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault()) def isWknd = (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) def targetAvgIn = isWknd ? averages.avgInWe : averages.avgInWd if (targetAvgIn != null && state.sessionStartTime["${uId}"]) { def startMins = getMinutesFromNoon(state.sessionStartTime["${uId}"]) def diff = Math.abs(startMins - targetAvgIn) if (diff > 60 && diff <= 120) finalScore -= 5.0 else if (diff > 120) finalScore -= 10.0 } } } return Math.max(0, Math.min(100, Math.round(finalScore).toInteger())) } def updateInfoDevice(uId) { ensureStateMaps() def dni = "ASM_INFO_${uId}" def dev = getChildDevice(dni) if (!dev) return def score = calculateEfficiencyScore(uId) def status = state.sleepState["${uId}"] ?: "EMPTY" dev.sendEvent(name: "status", value: status) dev.sendEvent(name: "sleepScore", value: score) dev.sendEvent(name: "html", value: generateHtmlTile(uId)) } def generateHtmlTile(uId) { ensureStateMaps() def uName = getUserName(uId) def status = state.sleepState?."${uId}" ?: "EMPTY" def score = calculateEfficiencyScore(uId) // Hardware Statuses def vSens = settings["vibrationSensor_${uId}"] def pMat = settings["pressureMat_${uId}"] def vSensState = vSens ? (vSens.currentValue("acceleration") ?: "inactive") : "none" def pMatState = pMat ? (pMat.currentValue("contact") ?: pMat.currentValue("presence") ?: "open") : "none" // Status Colors (Matching Trash App gradients) def headerStyle = "background: linear-gradient(135deg, #2c3e50, #34495e); color: white;" if (status == "SLEEPING") headerStyle = "background: linear-gradient(135deg, #2980b9, #2ecc71); color: white;" else if (status == "IN BED") headerStyle = "background: linear-gradient(135deg, #8e44ad, #9b59b6); color: white;" else if (status == "BATHROOM TRIP") headerStyle = "background: linear-gradient(135deg, #d35400, #f39c12); color: white;" def inBedTimeStr = state.inBedTime?."${uId}" ? formatTimestamp(state.inBedTime."${uId}") : "--:--" def exitTimeStr = state.lastExitTime?."${uId}" ? formatTimestamp(state.lastExitTime."${uId}") : "--:--" def now = new Date().time def liveInBed = 0 def liveAsleep = 0 if (status == "IN BED" || status == "SLEEPING" || status == "PENDING ENTRY" || status == "BATHROOM TRIP") { if (state.inBedTime?."${uId}") liveInBed = ((now - state.inBedTime."${uId}") / 60000).toInteger() if (state.asleepTime?."${uId}") liveAsleep = ((now - state.asleepTime."${uId}") / 60000).toInteger() } else { liveInBed = state.lastSessionInBed?."${uId}" ?: 0 liveAsleep = state.lastSessionAsleep?."${uId}" ?: 0 } def deepSleep = state.deepSleepDuration?."${uId}" ?: 0 if (status == "SLEEPING") { def stillStart = state.lastStillStartTime["${uId}"] ?: state.asleepTime["${uId}"] ?: now def gap = now - stillStart if (gap >= 2700000) deepSleep += (gap / 60000).toInteger() } def lightSleep = Math.max(0, liveAsleep - deepSleep) def awakeTime = Math.max(0, liveInBed - liveAsleep) return """
${uName} - ${status.toUpperCase()} BMS Score: ${score}%
SESSION TIMELINE
In: ${inBedTimeStr}
Out: ${exitTimeStr}
SLEEP STAGE PROFILES
Deep: ${formatDuration(deepSleep)}
Light: ${formatDuration(lightSleep)} | Awake: ${formatDuration(awakeTime)}
HARDWARE TELEMETRY
Vib: ${vSensState.toUpperCase()}
Mat: ${pMatState.toUpperCase()}
""" } def appButtonHandler(btn) { ensureStateMaps() def parts = btn.split("_") if (btn.startsWith("btnCreateInfo_")) { def rNum = parts[1] def uNum = parts[2] def uId = "${rNum}_${uNum}" def dni = "ASM_INFO_${uId}" def name = "Sleep Info - ${getUserName(uId)}" addChildDevice("ShaneAllen", "ASM Dashboard Device", dni, null, [name: name, label: name]) updateInfoDevice(uId) } else if (btn.startsWith("btnCreateSwitch_")) { def rNum = parts[1] def uNum = parts[2] def uId = "${rNum}_${uNum}" def dni = "ASM_SW_${uId}" def name = "(Virtual) ${getUserName(uId)} In Bed" addChildDevice("hubitat", "Virtual Switch", dni, null, [name: name, label: name]) } else if (btn.startsWith("btnClearAI_")) { def rNum = parts[1] def uNum = parts[2] def uId = "${rNum}_${uNum}" state.dailyStats["${uId}"] = [] addToHistory("AI Reset: Cleared learned data for ${getUserName(uId)}") } else if (btn == "btnForceReset") { forceResetAllBeds() } else if (btn == "btnRefresh") { log.info "BMS Refresh Triggered." } } def logTelemetryEvent(uId, eventType) { if (!enableTelemetryTracking) return ensureStateMaps() if (!state.telemetry["${uId}"]) { state.telemetry["${uId}"] = [today: [vibrations: 0, falseExits: 0, crossTalkAvoided: 0, inBedMotionsIgnored: 0, ghostBlocks: 0, settlingLockBlocks: 0], overall: [vibrations: 0, falseExits: 0, crossTalkAvoided: 0, inBedMotionsIgnored: 0, ghostBlocks: 0, settlingLockBlocks: 0]] } state.telemetry["${uId}"].today[eventType] = (state.telemetry["${uId}"].today[eventType] ?: 0) + 1 state.telemetry["${uId}"].overall[eventType] = (state.telemetry["${uId}"].overall[eventType] ?: 0) + 1 } def forceResetAllBeds() { ensureStateMaps() def numR = numRooms ? numRooms.toInteger() : 1 for (int i = 1; i <= numR; i++) { def numUsers = (i == 1) ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" state.sleepState["${uId}"] = "EMPTY" state.inBedTime["${uId}"] = null state.asleepTime["${uId}"] = null state.pendingExit["${uId}"] = 0 state.movements["${uId}"] = 0 state.lastValidExitMotion["${uId}"] = 0 state.bathroomTrips["${uId}"] = 0 state.bathroomDuration["${uId}"] = 0 state.entryVibrationCount["${uId}"] = 0 state.exitSequenceProgress["${uId}"] = 0 state.pendingEntryTime["${uId}"] = 0 state.pendingRoomMotions["${uId}"] = 0 state.pendingAntiBounceWait["${uId}"] = 0 state.sessionResumedTime["${uId}"] = 0 state.sessionStartTime["${uId}"] = null state.envStats["${uId}"] = [tSum: 0.0, tCnt: 0, hSum: 0.0, hCnt: 0] state.deepSleepDuration["${uId}"] = 0 state.lastStillStartTime["${uId}"] = 0 state.sleepLatency["${uId}"] = 0 state.weightedMovementPenalty["${uId}"] = 0.0 state.ewmaMovement["${uId}"] = 0.0 state.lastMoveTimeForEwma["${uId}"] = 0 state.currentSleepStage["${uId}"] = "DEEP" state.smartAlarmTriggeredDate["${uId}"] = "" updateVirtualSwitch(uId, "off") updateInfoDevice(uId) } state.roomEmptyTime["${i}"] = 0 state.roomGoodNightTriggered["${i}"] = false state.lastEnvDisturbanceTime["${i}"] = 0 state.lastEnvDisturbanceType["${i}"] = "" } addToHistory("SYSTEM: Forced all beds to EMPTY via manual button override.") } def middayReset() { ensureStateMaps() state.lastResetTime = new Date().format("MM/dd HH:mm", location.timeZone) for (int i = 1; i <= 3; i++) { state.roomEmptyTime["${i}"] = 0 state.roomGoodNightTriggered["${i}"] = false state.lastEnvDisturbanceTime["${i}"] = 0 state.lastEnvDisturbanceType["${i}"] = "" def numUsers = (i == 1) ? 2 : 1 for (int u = 1; u <= numUsers; u++) { def uId = "${i}_${u}" if (state.telemetry?."${uId}"?.today) { state.telemetry["${uId}"].today = [vibrations: 0, falseExits: 0, crossTalkAvoided: 0, inBedMotionsIgnored: 0, ghostBlocks: 0, settlingLockBlocks: 0] } def avgT = null def avgH = null if (state.envStats?."${uId}"?.tCnt > 0) avgT = Math.round((state.envStats["${uId}"].tSum / state.envStats["${uId}"].tCnt) * 10) / 10.0 if (state.envStats?."${uId}"?.hCnt > 0) avgH = Math.round((state.envStats["${uId}"].hSum / state.envStats["${uId}"].hCnt) * 10) / 10.0 def mlEnabled = (settings["enableML_${uId}"] != false) if (mlEnabled) { def inBed = state.sessionStartTime["${uId}"] def outBed = state.lastExitTime["${uId}"] if (inBed && outBed) { def cal = Calendar.getInstance(location.timeZone ?: TimeZone.getDefault()) def isWknd = (cal.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || cal.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) def sessionData = [ inBedMins: getMinutesFromNoon(inBed), outBedMins: getMinutesFromNoon(outBed), score: calculateEfficiencyScore(uId), trips: state.bathroomTrips["${uId}"] ?: 0, isWeekend: isWknd, avgTemp: avgT, avgHumid: avgH ] if (state.dailyStats["${uId}"] == null) state.dailyStats["${uId}"] = [] state.dailyStats["${uId}"].add(sessionData) while (state.dailyStats["${uId}"].size() > 14) { state.dailyStats["${uId}"].remove(0) } } } def cState = state.sleepState["${uId}"] ?: "EMPTY" if (cState == "EMPTY" || cState == "BATHROOM TRIP") { state.inBedTime["${uId}"] = null state.asleepTime["${uId}"] = null state.movements["${uId}"] = 0 state.deafenedUntil["${uId}"] = 0 state.lastValidExitMotion["${uId}"] = 0 state.bathroomTrips["${uId}"] = 0 state.bathroomDuration["${uId}"] = 0 state.entryVibrationCount["${uId}"] = 0 state.exitSequenceProgress["${uId}"] = 0 state.pendingEntryTime["${uId}"] = 0 state.pendingRoomMotions["${uId}"] = 0 state.pendingAntiBounceWait["${uId}"] = 0 state.sessionResumedTime["${uId}"] = 0 state.sessionStartTime["${uId}"] = null state.envStats["${uId}"] = [tSum: 0.0, tCnt: 0, hSum: 0.0, hCnt: 0] state.deepSleepDuration["${uId}"] = 0 state.lastStillStartTime["${uId}"] = 0 state.sleepLatency["${uId}"] = 0 state.weightedMovementPenalty["${uId}"] = 0.0 state.ewmaMovement["${uId}"] = 0.0 state.lastMoveTimeForEwma["${uId}"] = 0 state.currentSleepStage["${uId}"] = "DEEP" updateVirtualSwitch(uId, "off") state.sleepState["${uId}"] = "EMPTY" updateInfoDevice(uId) } } if (settings["goodNightSwitch_${i}"]?.currentValue("switch") == "on" && settings["enableOrchestrator_${i}"]) { settings["goodNightSwitch_${i}"].off() } } addToHistory("SYSTEM: Midday Reset completed. True Session boundaries logged to AI. Cleared daily ledgers.") } def getUserIdFromDevice(id, type = "vibrationSensor") { for (int i = 1; i <= 3; i++) { for (int u = 1; u <= 2; u++) { if (type == "vibrationSensor") { if (settings["vibrationSensor_${i}_${u}"]?.id == id) return "${i}_${u}" if (settings["vibrationSensor2_${i}_${u}"]?.id == id) return "${i}_${u}" } else if (type == "pressureMat") { if (settings["pressureMat_${i}_${u}"]?.id == id) return "${i}_${u}" } } } return null } def getRoomNumFromDevice(id, type) { for (int i = 1; i <= 3; i++) { if (type == "tempSensor" || type == "humidSensor") { if (settings["${type}_${i}"]?.id == id) return "${i}" } else { if (settings["${type}_${i}"]?.id == id) return "${i}" } } return null } def getUserName(uId) { return settings["userName_${uId}"] ?: "User" } def getRoomName(rNum) { return settings["roomName_${rNum}"] ?: "Room ${rNum}" } def isSystemPaused() { return masterEnableSwitch?.currentValue("switch") == "off" } def formatTimestamp(ms) { return ms ? new Date(ms as Long).format("h:mm a", location.timeZone) : "--:--" } def formatDuration(m) { return m >= 60 ? "${(m/60).toInteger()}h ${m%60}m" : "${m}m" } def updateVirtualSwitch(uId, val) { def dev = getChildDevice("ASM_SW_${uId}") if (dev) { if (val == "on") dev.on() else dev.off() } } def addToHistory(String msg) { ensureStateMaps() def timestamp = new Date().format("MM/dd HH:mm:ss", location.timeZone) state.historyLog.add(0, "[${timestamp}] ${msg}") if (state.historyLog.size() > 30) state.historyLog = state.historyLog.take(30) log.info "SLEEP HISTORY: [${timestamp}] ${msg}" }