/**
* Advanced House Security
*
* Author: ShaneAllen
*/
definition(
name: "Advanced House Security",
namespace: "ShaneAllen",
author: "ShaneAllen",
description: "Defensive peace-of-mind engine for family safety. Granular Zooz routing, Curfew Zones, sensor debounce, left-open reminders, and smart arrival announcements.",
category: "Safety & Security",
iconUrl: "",
iconX2Url: "",
iconX3Url: ""
)
preferences {
page(name: "mainPage")
}
def mainPage() {
// Failsafe to convert old list-based alerts to the new Map structure upon page load
if (state.activeAlerts instanceof java.util.List) {
state.activeAlerts = [:]
}
dynamicPage(name: "mainPage", title: "Advanced House Security (Family Protection)", install: true, uninstall: true) {
section("Master Control & Live Dashboard") {
input "masterEnableSwitch", "capability.switch", title: "Master System Switch (If OFF, the entire security app is bypassed)", required: false, submitOnChange: true
paragraph "
"
input "btnRefresh", "button", title: "๐ Refresh Data"
paragraph "What it does: An 'Eye in the Sky' real-time view of your home's entire defensive perimeter, active alerts, and expected engine responses.
"
def statusExplanation = getHumanReadableStatus()
def alertColor = state.currentAlertLevel == "Critical" ? "#d9534f" : (state.currentAlertLevel == "Warning" ? "#f0ad4e" : "#007bff")
def sysEnabled = !(masterEnableSwitch && masterEnableSwitch.currentValue("switch") == "off")
if (!sysEnabled) alertColor = "#dc3545"
def currentMode = location.mode
def bypassStatus = (state.bypassEndTime && now() < state.bypassEndTime) ? "ACTIVE (${Math.round((state.bypassEndTime - now())/60000)} mins left)" : "Inactive"
// Global Status Header
def dashHTML = """
System Status: ${statusExplanation}
Current Mode: ${currentMode} | Manual Bypass: ${bypassStatus}
"""
// ACTIVE ALERTS TABLE
if (state.activeAlerts && state.activeAlerts.size() > 0) {
def alertHtml = """
| First Detected |
Last Detected |
Source Device |
Event Details |
Count |
Duration |
"""
state.activeAlerts.each { key, alert ->
alertHtml += ""
alertHtml += "| ${alert.time} | "
alertHtml += "${alert.lastTime} | "
alertHtml += "${alert.device} | "
alertHtml += "${alert.msg} | "
alertHtml += "${alert.count} | "
alertHtml += "${alert.duration} | "
alertHtml += "
"
}
alertHtml += "
"
dashHTML += alertHtml
}
// EYE IN THE SKY MATRIX
dashHTML += """
| Sensor Name |
Assigned Zone |
Position / State |
Engine Status |
Expected Engine Response |
"""
def processedIds = []
def buildRows = { devList, zoneName, stateAttr, badState, expectedAction, isZoneActive ->
if (devList) {
devList.each { dev ->
if (!processedIds.contains(dev.id)) {
def currentState = dev.currentValue(stateAttr)
def stateClass = (currentState == badState) ? "state-bad" : "state-good"
def finalStatus = ""
def isBypassed = (state.bypassEndTime && now() < state.bypassEndTime && bypassDoors?.find { it.id == dev.id })
if (!sysEnabled) finalStatus = "๐ด DISABLED"
else if (isBypassed) finalStatus = "๐ก BYPASSED"
else if (isZoneActive) finalStatus = "๐ข ARMED"
else finalStatus = "โช SLEEPING"
dashHTML += ""
dashHTML += "| ${dev.displayName} | "
dashHTML += "${zoneName} | "
dashHTML += "${currentState?.toString()?.toUpperCase() ?: 'UNKNOWN'} | "
dashHTML += "${finalStatus} | "
dashHTML += "${expectedAction} | "
dashHTML += "
"
processedIds << dev.id
}
}
}
}
def hrActive = isAdvancedConditionMet(enableHighRiskRules1, highRiskModes1, highRiskStartTimeType1, highRiskStartTime1, highRiskStartOffset1, highRiskEndTimeType1, highRiskEndTime1, highRiskEndOffset1, enableHighRiskRules2, highRiskModes2, highRiskStartTimeType2, highRiskStartTime2, highRiskStartOffset2, highRiskEndTimeType2, highRiskEndTime2, highRiskEndOffset2)
def curfActive = isAdvancedConditionMet(enableCurfewRules1, curfewModes1, curfewStartTimeType1, curfewStartTime1, curfewStartOffset1, curfewEndTimeType1, curfewEndTime1, curfewEndOffset1, enableCurfewRules2, curfewModes2, curfewStartTimeType2, curfewStartTime2, curfewStartOffset2, curfewEndTimeType2, curfewEndTime2, curfewEndOffset2)
def doorActive = isAdvancedConditionMet(enableDoorRules1, doorModes1, doorStartTimeType1, doorStartTime1, doorStartOffset1, doorEndTimeType1, doorEndTime1, doorEndOffset1, enableDoorRules2, doorModes2, doorStartTimeType2, doorStartTime2, doorStartOffset2, doorEndTimeType2, doorEndTime2, doorEndOffset2)
def leftActive = isAdvancedConditionMet(enableLeftOpenRules1, leftOpenModes1, leftOpenStartTimeType1, leftOpenStartTime1, leftOpenStartOffset1, leftOpenEndTimeType1, leftOpenEndTime1, leftOpenEndOffset1, enableLeftOpenRules2, leftOpenModes2, leftOpenStartTimeType2, leftOpenStartTime2, leftOpenStartOffset2, leftOpenEndTimeType2, leftOpenEndTime2, leftOpenEndOffset2)
def outActive = isAdvancedConditionMet(enableOutMotionRules1, outMotionModes1, outStartTimeType1, outMotionStartTime1, outStartOffset1, outEndTimeType1, outMotionEndTime1, outEndOffset1, enableOutMotionRules2, outMotionModes2, outStartTimeType2, outMotionStartTime2, outStartOffset2, outEndTimeType2, outMotionEndTime2, outEndOffset2, overcastSwitch)
def inActive = isAdvancedConditionMet(enableInMotionRules1, inMotionModes1, inStartTimeType1, inMotionStartTime1, inStartOffset1, inEndTimeType1, inMotionEndTime1, inEndOffset1, enableInMotionRules2, inMotionModes2, inStartTimeType2, inMotionStartTime2, inStartOffset2, inEndTimeType2, inMotionEndTime2, inEndOffset2)
buildRows(highRiskSensors, "High-Risk", "contact", "open", "โ ๏ธ Triggers Warning/TTS if rules met", hrActive)
buildRows(curfewDoors, "Curfew", "contact", "open", "๐จ Triggers Critical Alarm if rules met", curfActive)
buildRows(perimeterDoors, "Perimeter Door", "contact", "open", "๐ช Custom TTS/Chime + Push", doorActive)
buildRows(perimeterWindows, "Perimeter Window", "contact", "open", "๐ช Logs Event / Perimeter Breach", true)
buildRows(leftOpenSensors, "Left-Open Watch", "contact", "open", "โฑ๏ธ Delayed TTS Reminder (${leftOpenDelay ?: 15}m)", leftActive)
buildRows(outdoorMotion, "Outdoor Motion", "motion", "active", "๐ Motion TTS / Tracks Outbound", outActive)
buildRows(indoorMotion, "Indoor Motion", "motion", "active", "๐ Motion TTS / Tracks Intrusion", inActive)
buildRows(glassBreakSensors, "Glass Break", "sound.detected", "detected", "๐ฅ Critical Escalation if breached", true)
def allDetectors = (smokeDetectors ?: []) + (coDetectors ?: [])
allDetectors = allDetectors.unique { it.id }
if (allDetectors.size() > 0) {
allDetectors.each { dev ->
if (!processedIds.contains(dev.id)) {
def sState = dev.hasAttribute("smoke") ? dev.currentValue("smoke") : "unknown"
def cState = dev.hasAttribute("carbonMonoxide") ? dev.currentValue("carbonMonoxide") : "unknown"
def batt = dev.hasAttribute("battery") ? dev.currentValue("battery") : "--"
def statusColor = (sState == "detected" || cState == "detected") ? "state-bad" : "state-good"
def displayStatus = "CLEAR"
if (sState == "detected" && cState == "detected") displayStatus = "SMOKE & CO DETECTED"
else if (sState == "detected") displayStatus = "SMOKE DETECTED"
else if (cState == "detected") displayStatus = "CO DETECTED"
else if (sState == "tested" || cState == "tested") displayStatus = "TESTING"
def lifeStatus = !sysEnabled ? "๐ด DISABLED" : "๐ข 24/7 ARMED"
dashHTML += ""
dashHTML += "| ${dev.displayName} (Batt: ${batt}%) | "
dashHTML += "Life Safety | "
dashHTML += "${displayStatus} | "
dashHTML += "${lifeStatus} | "
dashHTML += "๐ฅ Evac Protocol / Overdrive Volume | "
dashHTML += "
"
processedIds << dev.id
}
}
}
dashHTML += "
"
paragraph dashHTML
if (state.activeAlerts && state.activeAlerts.size() > 0) {
input "clearAlertsBtn", "button", title: "๐งน Dismiss & Clear Active Alerts"
}
}
section("๐ Recent Context Engine Events (Last 25)") {
paragraph "What it does: A rolling timeline of house activity. Crucial for verifying when kids got home or identifying the source of an alert.
"
if (state.eventHistory) {
def historyStr = state.eventHistory.join("
")
paragraph "${historyStr}"
} else {
paragraph "No recent events tracked."
}
}
section("1. ๐ซ High-Risk Boundaries (Kids & Hazards)", hideable: true) {
paragraph "Dedicated protection for sensitive areas (Pool gates, gun safes).
"
input "enableHighRiskRules1", "bool", title: "Enable Rule Set 1", defaultValue: true, submitOnChange: true
if (enableHighRiskRules1) {
input "highRiskModes1", "mode", title: "Active Modes", multiple: true, required: false
input "highRiskStartTimeType1", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.highRiskStartTimeType1 in ["Sunrise", "Sunset"]) input "highRiskStartOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "highRiskStartTime1", "time", title: "Specific Start Time", required: false
input "highRiskEndTimeType1", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.highRiskEndTimeType1 in ["Sunrise", "Sunset"]) input "highRiskEndOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "highRiskEndTime1", "time", title: "Specific End Time", required: false
}
paragraph "
"
input "enableHighRiskRules2", "bool", title: "Enable Rule Set 2", defaultValue: false, submitOnChange: true
if (enableHighRiskRules2) {
input "highRiskModes2", "mode", title: "Active Modes", multiple: true, required: false
input "highRiskStartTimeType2", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.highRiskStartTimeType2 in ["Sunrise", "Sunset"]) input "highRiskStartOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "highRiskStartTime2", "time", title: "Specific Start Time", required: false
input "highRiskEndTimeType2", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.highRiskEndTimeType2 in ["Sunrise", "Sunset"]) input "highRiskEndOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "highRiskEndTime2", "time", title: "Specific End Time", required: false
}
input "highRiskSensors", "capability.contactSensor", title: "Select High-Risk Contact Sensors", required: false, multiple: true, submitOnChange: true
if (highRiskSensors) {
highRiskSensors.each { dev ->
paragraph "${dev.displayName} Granular Settings
"
input "highRiskMsg_${dev.id}", "text", title: "Message Text", required: false, defaultValue: "Warning. ${dev.displayName} has been opened."
input "highRiskTTS_${dev.id}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "highRiskPush_${dev.id}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "highRiskZooz_${dev.id}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["highRiskZooz_${dev.id}"]) {
settings["highRiskZooz_${dev.id}"].each { zDev ->
input "highRiskZoozFile_${dev.id}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_highRisk_${dev.id}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
}
}
}
section("2. ๐ Toddler / Wander Prevention (Curfew Zones)", hideable: true) {
input "enableCurfewRules1", "bool", title: "Enable Rule Set 1", defaultValue: true, submitOnChange: true
if (enableCurfewRules1) {
input "curfewModes1", "mode", title: "Active Modes", multiple: true, required: false
input "curfewStartTimeType1", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.curfewStartTimeType1 in ["Sunrise", "Sunset"]) input "curfewStartOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "curfewStartTime1", "time", title: "Specific Start Time", required: false
input "curfewEndTimeType1", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.curfewEndTimeType1 in ["Sunrise", "Sunset"]) input "curfewEndOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "curfewEndTime1", "time", title: "Specific End Time", required: false
}
paragraph "
"
input "enableCurfewRules2", "bool", title: "Enable Rule Set 2", defaultValue: false, submitOnChange: true
if (enableCurfewRules2) {
input "curfewModes2", "mode", title: "Active Modes", multiple: true, required: false
input "curfewStartTimeType2", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.curfewStartTimeType2 in ["Sunrise", "Sunset"]) input "curfewStartOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "curfewStartTime2", "time", title: "Specific Start Time", required: false
input "curfewEndTimeType2", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.curfewEndTimeType2 in ["Sunrise", "Sunset"]) input "curfewEndOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "curfewEndTime2", "time", title: "Specific End Time", required: false
}
input "curfewDoors", "capability.contactSensor", title: "Select Curfew Doors", required: false, multiple: true, submitOnChange: true
if (curfewDoors) {
curfewDoors.each { dev ->
paragraph "${dev.displayName} Granular Settings
"
input "curfewMsg_${dev.id}", "text", title: "Message Text", required: false, defaultValue: "Emergency. ${dev.displayName} was opened during curfew."
input "curfewTTS_${dev.id}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "curfewPush_${dev.id}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "curfewZooz_${dev.id}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["curfewZooz_${dev.id}"]) {
settings["curfewZooz_${dev.id}"].each { zDev ->
input "curfewZoozFile_${dev.id}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_curfew_${dev.id}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
}
}
}
section("3. ๐ช Perimeter Setup (Doors, Windows, Glass)", hideable: true) {
input "enableDoorRules1", "bool", title: "Enable Rule Set 1", defaultValue: true, submitOnChange: true
if (enableDoorRules1) {
input "doorModes1", "mode", title: "Active Modes", multiple: true, required: false
input "doorStartTimeType1", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.doorStartTimeType1 in ["Sunrise", "Sunset"]) input "doorStartOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "doorStartTime1", "time", title: "Specific Start Time", required: false
input "doorEndTimeType1", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.doorEndTimeType1 in ["Sunrise", "Sunset"]) input "doorEndOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "doorEndTime1", "time", title: "Specific End Time", required: false
}
paragraph "
"
input "enableDoorRules2", "bool", title: "Enable Rule Set 2", defaultValue: false, submitOnChange: true
if (enableDoorRules2) {
input "doorModes2", "mode", title: "Active Modes", multiple: true, required: false
input "doorStartTimeType2", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.doorStartTimeType2 in ["Sunrise", "Sunset"]) input "doorStartOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "doorStartTime2", "time", title: "Specific Start Time", required: false
input "doorEndTimeType2", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.doorEndTimeType2 in ["Sunrise", "Sunset"]) input "doorEndOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "doorEndTime2", "time", title: "Specific End Time", required: false
}
input "perimeterDoors", "capability.contactSensor", title: "Select Exterior Doors", required: false, multiple: true, submitOnChange: true
if (perimeterDoors) {
perimeterDoors.each { dev ->
paragraph "${dev.displayName} Granular Settings
"
input "doorMsg_${dev.id}", "text", title: "Message Text", required: false, defaultValue: "${dev.displayName} opened."
input "doorTTS_${dev.id}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "doorPush_${dev.id}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "doorZooz_${dev.id}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["doorZooz_${dev.id}"]) {
settings["doorZooz_${dev.id}"].each { zDev ->
input "doorZoozFile_${dev.id}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_door_${dev.id}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
}
}
paragraph "
"
paragraph "Mode Change Reminders"
input "perimeterCheckModes", "mode", title: "Modes that trigger an 'Open' warning", multiple: true, required: false
input "perimeterCheckPush", "capability.notification", title: "Who gets this reminder?", multiple: true, required: false
input "perimeterCheckTTS", "bool", title: "Announce on global TTS?", defaultValue: true
paragraph "
"
input "perimeterWindows", "capability.contactSensor", title: "Select Perimeter Windows", required: false, multiple: true
input "glassBreakSensors", "capability.sensor", title: "Select Glass Break Sensors", required: false, multiple: true
input "outboundGracePeriod", "number", title: "Outbound Grace Period (Seconds)", required: false, defaultValue: 30
}
section("4. โฐ Proactive 'Left Open' Reminders", hideable: true) {
input "enableLeftOpenRules1", "bool", title: "Enable Rule Set 1", defaultValue: true, submitOnChange: true
if (enableLeftOpenRules1) {
input "leftOpenModes1", "mode", title: "Active Modes", multiple: true, required: false
input "leftOpenStartTimeType1", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.leftOpenStartTimeType1 in ["Sunrise", "Sunset"]) input "leftOpenStartOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "leftOpenStartTime1", "time", title: "Specific Start Time", required: false
input "leftOpenEndTimeType1", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.leftOpenEndTimeType1 in ["Sunrise", "Sunset"]) input "leftOpenEndOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "leftOpenEndTime1", "time", title: "Specific End Time", required: false
}
paragraph "
"
input "enableLeftOpenRules2", "bool", title: "Enable Rule Set 2", defaultValue: false, submitOnChange: true
if (enableLeftOpenRules2) {
input "leftOpenModes2", "mode", title: "Active Modes", multiple: true, required: false
input "leftOpenStartTimeType2", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.leftOpenStartTimeType2 in ["Sunrise", "Sunset"]) input "leftOpenStartOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "leftOpenStartTime2", "time", title: "Specific Start Time", required: false
input "leftOpenEndTimeType2", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.leftOpenEndTimeType2 in ["Sunrise", "Sunset"]) input "leftOpenEndOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "leftOpenEndTime2", "time", title: "Specific End Time", required: false
}
input "leftOpenDelay", "number", title: "Minutes to wait before reminding", required: false, defaultValue: 15
input "leftOpenSensors", "capability.contactSensor", title: "Select Doors/Windows to Monitor", required: false, multiple: true, submitOnChange: true
if (leftOpenSensors) {
leftOpenSensors.each { dev ->
paragraph "${dev.displayName} Granular Settings
"
input "leftOpenMsg_${dev.id}", "text", title: "Message Text", required: false, defaultValue: "Reminder, the ${dev.displayName} is left open."
input "leftOpenTTS_${dev.id}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "leftOpenPush_${dev.id}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "leftOpenZooz_${dev.id}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["leftOpenZooz_${dev.id}"]) {
settings["leftOpenZooz_${dev.id}"].each { zDev ->
input "leftOpenZoozFile_${dev.id}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_leftOpen_${dev.id}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
}
}
}
section("5. ๐ Interior & Exterior Motion", hideable: true) {
paragraph "Outdoor Motion Config"
input "overcastSwitch", "capability.switch", title: "Overcast / Weather Override Switch (Triggers alerts regardless of time constraints)", required: false
input "enableOutMotionRules1", "bool", title: "Enable Rule Set 1", defaultValue: true, submitOnChange: true
if (enableOutMotionRules1) {
input "outMotionModes1", "mode", title: "Active Modes", multiple: true, required: false
input "outStartTimeType1", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.outStartTimeType1 in ["Sunrise", "Sunset"]) input "outStartOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "outMotionStartTime1", "time", title: "Specific Start Time", required: false
input "outEndTimeType1", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.outEndTimeType1 in ["Sunrise", "Sunset"]) input "outEndOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "outMotionEndTime1", "time", title: "Specific End Time", required: false
}
paragraph "
"
input "enableOutMotionRules2", "bool", title: "Enable Rule Set 2", defaultValue: false, submitOnChange: true
if (enableOutMotionRules2) {
input "outMotionModes2", "mode", title: "Active Modes", multiple: true, required: false
input "outStartTimeType2", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.outStartTimeType2 in ["Sunrise", "Sunset"]) input "outStartOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "outMotionStartTime2", "time", title: "Specific Start Time", required: false
input "outEndTimeType2", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.outEndTimeType2 in ["Sunrise", "Sunset"]) input "outEndOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "outMotionEndTime2", "time", title: "Specific End Time", required: false
}
input "outdoorMotion", "capability.motionSensor", title: "Select Outdoor Motion Sensors", required: false, multiple: true, submitOnChange: true
if (outdoorMotion) {
outdoorMotion.each { dev ->
paragraph "${dev.displayName} Granular Settings
"
input "outMotionMsg_${dev.id}", "text", title: "Message Text", required: false, defaultValue: "Motion detected at the ${dev.displayName}."
input "outMotionTTS_${dev.id}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "outMotionPush_${dev.id}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "outMotionZooz_${dev.id}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["outMotionZooz_${dev.id}"]) {
settings["outMotionZooz_${dev.id}"].each { zDev ->
input "outMotionZoozFile_${dev.id}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_outMotion_${dev.id}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
}
}
paragraph "
"
paragraph "Indoor Motion Config"
input "enableInMotionRules1", "bool", title: "Enable Rule Set 1", defaultValue: true, submitOnChange: true
if (enableInMotionRules1) {
input "inMotionModes1", "mode", title: "Active Modes", multiple: true, required: false
input "inStartTimeType1", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.inStartTimeType1 in ["Sunrise", "Sunset"]) input "inStartOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "inMotionStartTime1", "time", title: "Specific Start Time", required: false
input "inEndTimeType1", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.inEndTimeType1 in ["Sunrise", "Sunset"]) input "inEndOffset1", "number", title: "Offset (minutes)", defaultValue: 0
else input "inMotionEndTime1", "time", title: "Specific End Time", required: false
}
paragraph "
"
input "enableInMotionRules2", "bool", title: "Enable Rule Set 2", defaultValue: false, submitOnChange: true
if (enableInMotionRules2) {
input "inMotionModes2", "mode", title: "Active Modes", multiple: true, required: false
input "inStartTimeType2", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.inStartTimeType2 in ["Sunrise", "Sunset"]) input "inStartOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "inMotionStartTime2", "time", title: "Specific Start Time", required: false
input "inEndTimeType2", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.inEndTimeType2 in ["Sunrise", "Sunset"]) input "inEndOffset2", "number", title: "Offset (minutes)", defaultValue: 0
else input "inMotionEndTime2", "time", title: "Specific End Time", required: false
}
input "indoorMotion", "capability.motionSensor", title: "Select Indoor Motion Sensors", required: false, multiple: true, submitOnChange: true
if (indoorMotion) {
indoorMotion.each { dev ->
paragraph "${dev.displayName} Granular Settings
"
input "inMotionMsg_${dev.id}", "text", title: "Message Text", required: false, defaultValue: "Motion detected in the ${dev.displayName}."
input "inMotionTTS_${dev.id}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "inMotionPush_${dev.id}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "inMotionZooz_${dev.id}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["inMotionZooz_${dev.id}"]) {
settings["inMotionZooz_${dev.id}"].each { zDev ->
input "inMotionZoozFile_${dev.id}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_inMotion_${dev.id}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
}
}
}
section("6. ๐ Individual Presence & Safe Transit (Up to 4 People)", hideable: true) {
paragraph "Configure granular arrival and departure rules for up to 4 individuals. Customize TTS, push, and Zooz Chimes specifically for who arrived or departed.
"
for (int i = 1; i <= 4; i++) {
input "enablePerson${i}", "bool", title: "Enable Person / Slot ${i}", defaultValue: false, submitOnChange: true
if (settings["enablePerson${i}"]) {
input "personSensor${i}", "capability.presenceSensor", title: "Select Presence Sensor for Person ${i}", required: true
paragraph "Arrival Settings (Present)
"
input "arrivalModes${i}", "mode", title: "Active Modes for Arrival Event", multiple: true, required: false
input "arrStartTimeType${i}", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings["arrStartTimeType${i}"] in ["Sunrise", "Sunset"]) input "arrStartOffset${i}", "number", title: "Offset (minutes)", defaultValue: 0
else input "arrStartTime${i}", "time", title: "Specific Start Time", required: false
input "arrEndTimeType${i}", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings["arrEndTimeType${i}"] in ["Sunrise", "Sunset"]) input "arrEndOffset${i}", "number", title: "Offset (minutes)", defaultValue: 0
else input "arrEndTime${i}", "time", title: "Specific End Time", required: false
input "arrivalMsg${i}", "text", title: "Custom Arrival Message", required: false, defaultValue: "Welcome home."
input "arrivalTTS${i}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "arrivalPush${i}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "arrivalZooz${i}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["arrivalZooz${i}"]) {
settings["arrivalZooz${i}"].each { zDev ->
input "arrivalZoozFile${i}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_arrival_${i}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
paragraph "Departure Settings (Not Present)
"
input "departureModes${i}", "mode", title: "Active Modes for Departure Event", multiple: true, required: false
input "depStartTimeType${i}", "enum", title: "Start Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings["depStartTimeType${i}"] in ["Sunrise", "Sunset"]) input "depStartOffset${i}", "number", title: "Offset (minutes)", defaultValue: 0
else input "depStartTime${i}", "time", title: "Specific Start Time", required: false
input "depEndTimeType${i}", "enum", title: "End Time Type", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings["depEndTimeType${i}"] in ["Sunrise", "Sunset"]) input "depEndOffset${i}", "number", title: "Offset (minutes)", defaultValue: 0
else input "depEndTime${i}", "time", title: "Specific End Time", required: false
input "departureMsg${i}", "text", title: "Custom Departure Message", required: false, defaultValue: "Safe travels."
input "departureTTS${i}", "capability.speechSynthesis", title: "โบ Target TTS Devices", multiple: true, required: false
input "departurePush${i}", "capability.notification", title: "โบ Target Push Notifications", multiple: true, required: false
input "departureZooz${i}", "capability.chime", title: "โบ Target Zooz Devices", multiple: true, submitOnChange: true
if (settings["departureZooz${i}"]) {
settings["departureZooz${i}"].each { zDev ->
input "departureZoozFile${i}_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_departure_${i}_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
paragraph "
"
}
}
}
section("7. ๐จ Life Safety & Panic Response", hideable: true) {
input "smokeDetectors", "capability.smokeDetector", title: "Select Smoke Detectors", required: false, multiple: true
input "coDetectors", "capability.carbonMonoxideDetector", title: "Select CO Detectors", required: false, multiple: true
input "panicButtons", "capability.pushableButton", title: "Select Panic / Duress Buttons", required: false, multiple: true
paragraph "Emergency Evacuation Automations"
input "emergencySwitches", "capability.switch", title: "Turn ON Standard Lights/Switches", required: false, multiple: true
input "emergencyColoredLights", "capability.colorControl", title: "Turn ON & Change Color of RGB Lights", required: false, multiple: true
if (settings.emergencyColoredLights) {
input "emergencyLightColor", "enum", title: "Select RGB Emergency Color", options: ["Red", "White", "Blue", "Green", "Yellow"], required: false, defaultValue: "Red"
}
input "emergencyLocks", "capability.lock", title: "Unlock Doors", required: false, multiple: true
paragraph "Emergency Audio Override"
input "emergencyTTSMessage", "text", title: "Custom TTS Evacuation Message", required: false, defaultValue: "Emergency. Evacuation protocol initiated. Please exit the house immediately."
input "emergencyTTS", "capability.speechSynthesis", title: "โบ Target Global TTS Devices for Emergencies", required: false, multiple: true
input "emergencyZooz", "capability.chime", title: "โบ Target Zooz Devices for Critical Sirens", multiple: true, submitOnChange: true
if (emergencyZooz) {
emergencyZooz.each { zDev ->
input "emergencyZoozFile_${zDev.id}", "number", title: " โณ ${zDev.displayName} File #", required: false
input "testBtn_emergency_0_${zDev.id}", "button", title: "๐ Test Speaker"
}
}
}
section("8. ๐ Dynamic Audio Routing & Debounce", hideable: true) {
paragraph "Controls notification fatigue and sets smart volume scaling based on time of day.
"
paragraph "Dynamic TTS Volume Scaling"
input "dayVolume", "number", title: "Standard Day Volume (0-100)", required: false, defaultValue: 70
input "quietVolume", "number", title: "Whisper/Quiet Volume (0-100)", required: false, defaultValue: 20
input "emergencyVolume", "number", title: "Emergency Overdrive Volume (0-100)", required: false, defaultValue: 100
paragraph "When should the system use Whisper/Quiet Volume?"
input "quietVolumeModes", "mode", title: "Quiet Volume Modes", multiple: true, required: false
input "quietStartTimeType", "enum", title: "Quiet Volume Start", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.quietStartTimeType in ["Sunrise", "Sunset"]) input "quietStartOffset", "number", title: "Offset (minutes)", defaultValue: 0
else input "quietVolumeStartTime", "time", title: "Specific Start Time", required: false
input "quietEndTimeType", "enum", title: "Quiet Volume End", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.quietEndTimeType in ["Sunrise", "Sunset"]) input "quietEndOffset", "number", title: "Offset (minutes)", defaultValue: 0
else input "quietVolumeEndTime", "time", title: "Specific End Time", required: false
paragraph "
"
paragraph "Sensor Debounce (Spam Prevention)"
input "motionDebounce", "number", title: "Motion Cooldown (Minutes)", required: false, defaultValue: 5
input "doorDebounce", "number", title: "Door/Window Cooldown (Minutes)", required: false, defaultValue: 1
paragraph "
"
paragraph "Quiet Hours (Granular Routing Rules)"
paragraph "Configure exactly what happens when the house is in a Quiet Mode. (Life-Safety, Curfews, and High-Risk Boundaries will still override this and sound alarms).
"
input "quietModes", "mode", title: "Select Quiet Modes (e.g., Night, Sleeping)", multiple: true, required: false
paragraph "1. Allowed Event Categories in Quiet Mode:"
input "qmAllowDoors", "bool", title: "Allow Perimeter Door Alerts", defaultValue: false
input "qmAllowOutMotion", "bool", title: "Allow Outdoor Motion Alerts", defaultValue: false
input "qmAllowInMotion", "bool", title: "Allow Indoor Motion Alerts", defaultValue: false
input "qmAllowLeftOpen", "bool", title: "Allow Left-Open Reminders", defaultValue: false
input "qmAllowPresence", "bool", title: "Allow Arrival/Departure Announcements", defaultValue: false
paragraph "2. Allowed Output Methods in Quiet Mode (for the events selected above):"
input "qmAllowPush", "bool", title: "Send Push Notifications", defaultValue: true
input "qmAllowTTS", "bool", title: "Play TTS Announcements (Uses Whisper/Quiet Volume)", defaultValue: false
input "qmAllowZooz", "bool", title: "Play Zooz Chimes", defaultValue: false
paragraph "
"
input "enableCustomAnnouncements", "bool", title: "Master Audio Toggle: Enable Day-Time Routine Announcements", defaultValue: true
}
section("9. ๐ฒ Global Alert Notifications", hideable: true) {
paragraph "These are for engine-level escalations (e.g., Glass Break + Motion tracking), separate from routine granular push events.
"
input "pushCritical", "capability.notification", title: "Who receives Critical Alerts?", multiple: true, required: false
input "pushWarnings", "capability.notification", title: "Who receives Security Warnings?", multiple: true, required: false
input "warningModes", "mode", title: "Modes to ALLOW Warning notifications (Leave blank for 24/7)", multiple: true, required: false
input "warnStartTimeType", "enum", title: "Warning Allowed Start", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.warnStartTimeType in ["Sunrise", "Sunset"]) input "warnStartOffset", "number", title: "Offset (minutes)", defaultValue: 0
else input "warningStartTime", "time", title: "Specific Start Time", required: false
input "warnEndTimeType", "enum", title: "Warning Allowed End", options: ["Specific Time", "Sunrise", "Sunset"], defaultValue: "Specific Time", submitOnChange: true
if (settings.warnEndTimeType in ["Sunrise", "Sunset"]) input "warnEndOffset", "number", title: "Offset (minutes)", defaultValue: 0
else input "warningEndTime", "time", title: "Specific End Time", required: false
input "enableWatchdog", "bool", title: "Enable Daily Sensor Health Watchdog", defaultValue: true
input "pushHealth", "capability.notification", title: "Who receives Maintenance alerts?", multiple: true, required: false
}
section("10. ๐ Quality of Life (Temporary Bypasses)", hideable: true) {
paragraph "Use a button push or virtual switch to temporarily mute warnings for specific doors (like letting the dog out at 2 AM) before automatically re-arming.
"
input "bypassButton", "capability.pushableButton", title: "Bypass Button", required: false
input "bypassButtonNum", "number", title: "Button Number to trigger bypass", required: false, defaultValue: 1
input "bypassSwitch", "capability.switch", title: "Bypass Switch", required: false
input "bypassSwitchAutoOff", "bool", title: "Automatically turn Bypass Switch back off?", defaultValue: true
paragraph "
"
input "bypassDoors", "capability.contactSensor", title: "Which doors/sensors should be ignored?", required: false, multiple: true
input "bypassDuration", "number", title: "Bypass Duration (Minutes)", required: false, defaultValue: 5
input "bypassTTS", "capability.speechSynthesis", title: "Acknowledge Bypass via TTS?", required: false, multiple: true
}
section("11. ๐ก System Integration & Information Output", hideable: true) {
paragraph "Writes the real-time activity and context events to a Virtual Child Device so other Apps/Dashboards can easily read the system state.
"
input "enableChildDevice", "bool", title: "Create Information Child Device", defaultValue: false, submitOnChange: true
}
}
}
// ==============================================================================
// INTERNAL LOGIC ENGINE
// ==============================================================================
def installed() { logInfo("Installed"); initialize() }
def updated() { logInfo("Updated"); unsubscribe(); unschedule(); initialize() }
def initialize() {
if (!state.eventHistory) state.eventHistory = []
state.activeAlerts = [:] // Formatted as a Map for table deduplication
if (!state.leftOpenAlertSent) state.leftOpenAlertSent = [:]
state.currentAlertLevel = "Normal"
state.lastDoorOpenTime = 0; state.lastGlassBreakTime = 0
state.bypassEndTime = 0
// Child Device Creation
if (enableChildDevice) {
def childId = "ahs_info_${app.id}"
def child = getChildDevice(childId)
if (!child) {
try {
addChildDevice("ShaneAllen", "Advanced House Security Information Device", childId, null, [name: "AHS Information", label: "AHS Information Output"])
logAction("Created Information Child Device.")
} catch (e) {
log.error "Could not create child device. Please ensure the custom driver is installed. Error: ${e}"
}
}
}
subscribe(location, "mode", modeChangeHandler)
if (masterEnableSwitch) subscribe(masterEnableSwitch, "switch", masterSwitchHandler)
if (perimeterDoors) subscribe(perimeterDoors, "contact", contactHandler)
if (perimeterWindows) subscribe(perimeterWindows, "contact", contactHandler)
if (highRiskSensors) subscribe(highRiskSensors, "contact", highRiskHandler)
if (curfewDoors) subscribe(curfewDoors, "contact", curfewHandler)
if (leftOpenSensors) subscribe(leftOpenSensors, "contact", leftOpenHandler)
if (outdoorMotion) {
subscribe(outdoorMotion, "motion.active", outdoorMotionHandler)
subscribe(outdoorMotion, "motion.inactive", motionInactiveHandler)
}
if (indoorMotion) {
subscribe(indoorMotion, "motion.active", indoorMotionHandler)
subscribe(indoorMotion, "motion.inactive", motionInactiveHandler)
}
// Subscribe to individual presence slots
for (int i = 1; i <= 4; i++) {
if (settings["enablePerson${i}"] && settings["personSensor${i}"]) {
subscribe(settings["personSensor${i}"], "presence", individualPresenceHandler)
}
}
if (glassBreakSensors) subscribe(glassBreakSensors, "sound.detected", glassBreakHandler)
if (smokeDetectors) subscribe(smokeDetectors, "smoke", lifeSafetyHandler)
if (coDetectors) subscribe(coDetectors, "carbonMonoxide", lifeSafetyHandler)
if (panicButtons) subscribe(panicButtons, "pushed", panicHandler)
if (bypassButton) subscribe(bypassButton, "pushed", bypassHandler)
if (bypassSwitch) subscribe(bypassSwitch, "switch.on", bypassHandler)
if (enableWatchdog) schedule("0 0 12 * * ?", dailyHealthCheck)
logAction("Advanced House Security Initialized.")
}
def isSystemEnabled() {
if (masterEnableSwitch && masterEnableSwitch.currentValue("switch") == "off") return false
return true
}
// Child Device Output Helper
def updateChildDevice(msg) {
if (enableChildDevice) {
def child = getChildDevice("ahs_info_${app.id}")
if (child) {
child.sendEvent(name: "variable", value: msg, descriptionText: "AHS Context Update")
}
}
}
// Advanced Sunrise/Sunset helper
def resolveTime(type, specificTime, offset) {
if (type == "Sunrise" || type == "Sunset") {
def sun = getSunriseAndSunset()
if (!sun) return null
def baseTime = (type == "Sunrise") ? sun.sunrise.time : sun.sunset.time
return new Date(baseTime + ((offset ?: 0) * 60000))
} else {
return specificTime ? timeToday(specificTime, location.timeZone) : null
}
}
// Master condition evaluation for all dual-rule sections
def isAdvancedConditionMet(en1, modes1, stType1, sTime1, sOff1, etType1, eTime1, eOff1, en2, modes2, stType2, sTime2, sOff2, etType2, eTime2, eOff2, overrideSwitch = null) {
if (overrideSwitch && overrideSwitch.currentValue("switch") == "on") return true
if (!en1 && !en2) return false
def match1 = false
if (en1) {
def modeOk = !modes1 || (modes1 as List).contains(location.mode)
def timeOk = true
def s = resolveTime(stType1, sTime1, sOff1)
def e = resolveTime(etType1, eTime1, eOff1)
if (s && e) {
def n = new Date()
timeOk = (s < e) ? (n >= s && n <= e) : (n >= s || n <= e)
}
match1 = modeOk && timeOk
}
def match2 = false
if (en2) {
def modeOk = !modes2 || (modes2 as List).contains(location.mode)
def timeOk = true
def s = resolveTime(stType2, sTime2, sOff2)
def e = resolveTime(etType2, eTime2, eOff2)
if (s && e) {
def n = new Date()
timeOk = (s < e) ? (n >= s && n <= e) : (n >= s || n <= e)
}
match2 = modeOk && timeOk
}
return match1 || match2
}
// --- DYNAMIC VOLUME HELPER ---
def applyTTSVolume(devices, isCritical=false) {
if (!devices) return
def targetVol = settings.dayVolume ?: 70
if (isCritical) {
targetVol = settings.emergencyVolume ?: 100
} else {
def isQuietMode = settings.quietVolumeModes && (settings.quietVolumeModes as List).contains(location.mode)
def isQuietTime = false
def s = resolveTime(settings.quietStartTimeType, settings.quietVolumeStartTime, settings.quietStartOffset)
def e = resolveTime(settings.quietEndTimeType, settings.quietVolumeEndTime, settings.quietEndOffset)
if (s && e) {
def n = new Date()
isQuietTime = (s < e) ? (n >= s && n <= e) : (n >= s || n <= e)
}
if (isQuietMode || isQuietTime) targetVol = settings.quietVolume ?: 20
}
[devices].flatten().findAll{it}.each { dev ->
if (dev.hasCommand("setVolume")) dev.setVolume(targetVol as Integer)
}
}
// --- MANUAL BYPASS ENGINE ---
def bypassHandler(evt) {
if (!isSystemEnabled()) return
if (evt.name == "pushed" && evt.value.toInteger() != (settings.bypassButtonNum ?: 1)) return
def duration = settings.bypassDuration ?: 5
state.bypassEndTime = now() + (duration * 60000)
logContextEvent("System Bypass Activated for ${duration} minutes.")
if (settings.bypassTTS) {
applyTTSVolume(settings.bypassTTS, false)
settings.bypassTTS.speak("Security bypass activated for ${duration} minutes.")
}
if (evt.name == "switch" && settings.bypassSwitchAutoOff) {
runIn(2, "turnOffBypassSwitch")
}
}
def turnOffBypassSwitch() {
if (bypassSwitch) bypassSwitch.off()
}
def masterSwitchHandler(evt) {
if (evt.value == "off") {
logAction("MASTER SWITCH OFF: Entire security system disabled.")
state.currentAlertLevel = "Normal"; state.activeAlerts = [:]
} else logAction("MASTER SWITCH ON: Security system armed and monitoring.")
}
String getHumanReadableStatus() {
if (!isSystemEnabled()) return "SYSTEM DISABLED"
if (state.currentAlertLevel == "Normal") return "All Clear."
def levelHtml = state.currentAlertLevel == "Critical" ? "CRITICAL ALERT" : "WARNING"
return "${levelHtml}: Active security events detected. See Active Alerts table below."
}
def appButtonHandler(btn) {
if (btn == "clearAlertsBtn") {
state.activeAlerts = [:]
state.currentAlertLevel = "Normal"
logAction("User manually cleared alerts.")
} else if (btn.startsWith("testBtn_")) {
def parts = btn.split("_")
if (parts.size() >= 4) {
def prefix = parts[1]
def devId = parts[2]
def zDevId = parts[3]
def zDevs
def fileNum
if (prefix == "emergency") {
zDevs = settings["emergencyZooz"]
fileNum = settings["emergencyZoozFile_${zDevId}"]
} else if (prefix == "arrival" || prefix == "departure") {
zDevs = settings["${prefix}Zooz${devId}"]
fileNum = settings["${prefix}ZoozFile${devId}_${zDevId}"]
} else {
zDevs = settings["${prefix}Zooz_${devId}"]
fileNum = settings["${prefix}ZoozFile_${devId}_${zDevId}"]
}
def zDev = [zDevs].flatten().findAll{it}.find { it.id == zDevId }
if (zDev && fileNum) {
playZoozSound(zDev, fileNum)
logAction("Test Speaker: Played file ${fileNum} on ${zDev.displayName}")
}
}
}
}
// --- TODDLER / WANDER PREVENTION (CURFEW ZONES) ---
def curfewHandler(evt) {
if (!isSystemEnabled()) return
if (state.bypassEndTime && now() < state.bypassEndTime) {
if (settings.bypassDoors?.find { it.id == evt.device.id }) {
logContextEvent("Ignored ${evt.device.displayName} (Bypass Active)")
return
}
}
if (evt.value == "open") {
def conditionMet = isAdvancedConditionMet(
enableCurfewRules1, curfewModes1, curfewStartTimeType1, curfewStartTime1, curfewStartOffset1, curfewEndTimeType1, curfewEndTime1, curfewEndOffset1,
enableCurfewRules2, curfewModes2, curfewStartTimeType2, curfewStartTime2, curfewStartOffset2, curfewEndTimeType2, curfewEndTime2, curfewEndOffset2
)
if (conditionMet) {
logContextEvent("CURFEW BREACH: ${evt.device.displayName} Opened")
def msg = settings["curfewMsg_${evt.device.id}"] ?: "Emergency. ${evt.device.displayName} was opened during curfew hours."
def tts = settings["curfewTTS_${evt.device.id}"]
def push = settings["curfewPush_${evt.device.id}"]
if (tts) { applyTTSVolume(tts, true); tts.speak(msg) }
if (push) push.deviceNotification(msg)
playGranularZooz("curfewZooz", evt.device)
triggerAlert("Critical", "Curfew Zone Breached: ${evt.device.displayName} opened during restricted hours!", evt.device.id, evt.device.displayName)
}
}
}
// --- PROACTIVE LEFT OPEN REMINDERS ---
def leftOpenHandler(evt) {
if (!isSystemEnabled()) return
def id = evt.device.id
if (evt.value == "open") {
def delay = leftOpenDelay ?: 15
state.leftOpenAlertSent[id] = false
runIn(delay * 60, "executeLeftOpenAlert", [data: [devId: id], overwrite: false])
} else {
state.leftOpenAlertSent[id] = false
}
}
def executeLeftOpenAlert(data) {
def dev = leftOpenSensors?.find { it.id == data.devId }
if (dev && dev.currentValue("contact") == "open" && !state.leftOpenAlertSent[dev.id]) {
def conditionMet = isAdvancedConditionMet(
enableLeftOpenRules1, leftOpenModes1, leftOpenStartTimeType1, leftOpenStartTime1, leftOpenStartOffset1, leftOpenEndTimeType1, leftOpenEndTime1, leftOpenEndOffset1,
enableLeftOpenRules2, leftOpenModes2, leftOpenStartTimeType2, leftOpenStartTime2, leftOpenStartOffset2, leftOpenEndTimeType2, leftOpenEndTime2, leftOpenEndOffset2
)
if (conditionMet) {
def isQuiet = quietModes && (quietModes as List).contains(location.mode)
if (isQuiet && !settings.qmAllowLeftOpen) return // Blocked by Quiet Mode settings
def msg = settings["leftOpenMsg_${dev.id}"] ?: "Just a reminder, the ${dev.displayName} has been left open."
logContextEvent("REMINDER: ${dev.displayName} left open too long.")
def tts = settings["leftOpenTTS_${dev.id}"]
def push = settings["leftOpenPush_${dev.id}"]
if (tts && (!isQuiet || settings.qmAllowTTS)) { applyTTSVolume(tts, false); tts.speak(msg) }
if (push && (!isQuiet || settings.qmAllowPush)) push.deviceNotification(msg)
if (!isQuiet || settings.qmAllowZooz) playGranularZooz("leftOpenZooz", dev)
state.leftOpenAlertSent[dev.id] = true
}
}
}
// --- PERIMETER MODE WATCHDOG ---
def modeChangeHandler(evt) {
if (!isSystemEnabled()) return
if (perimeterCheckModes && (perimeterCheckModes as List).contains(evt.value)) {
def openSensors = []
if (perimeterDoors) openSensors += perimeterDoors.findAll { it.currentValue("contact") == "open" }.collect { it.displayName }
if (perimeterWindows) openSensors += perimeterWindows.findAll { it.currentValue("contact") == "open" }.collect { it.displayName }
if (openSensors.size() > 0) {
def msg = "Warning. The house is now in ${evt.value} mode, but the following are still open: ${openSensors.join(', ')}."
logAction("Perimeter check failed on mode change: ${msg}")
if (perimeterCheckPush) perimeterCheckPush.deviceNotification(msg)
if (perimeterCheckTTS && emergencyTTS) { applyTTSVolume(emergencyTTS, false); emergencyTTS.speak(msg) }
}
}
}
// --- SYSTEM HEALTH WATCHDOG ---
def dailyHealthCheck() {
if (!isSystemEnabled()) return
def lowBatteryDevices = []
def allSensors = (perimeterDoors ?: []) + (perimeterWindows ?: []) + (highRiskSensors ?: []) + (curfewDoors ?: []) + (outdoorMotion ?: []) + (indoorMotion ?: []) + (smokeDetectors ?: []) + (coDetectors ?: []) + (glassBreakSensors ?: [])
allSensors = allSensors.unique { it.id }
allSensors.each { dev ->
if (dev.hasAttribute("battery")) {
def battVal = dev.currentValue("battery")
if (battVal != null && battVal.toInteger() < 15) lowBatteryDevices.add("${dev.displayName} (${battVal}%)")
}
}
if (lowBatteryDevices.size() > 0 && pushHealth) {
pushHealth.deviceNotification("System Health Alert: Low batteries detected on: ${lowBatteryDevices.join(', ')}")
logContextEvent("System Health: Low battery warning sent.")
}
}
// --- GRANULAR ZOOZ + TTS/PUSH AUDIO ROUTING WITH DEBOUNCE ---
def playDeviceCustomAlert(device, type, conditionMet) {
if (!settings.enableCustomAnnouncements) return
if (!conditionMet) return
def isQuiet = quietModes && (quietModes as List).contains(location.mode)
// Evaluate if this specific event category is permitted during Quiet Mode
if (isQuiet) {
def allowed = false
if (type == "door" && settings.qmAllowDoors) allowed = true
if (type == "outMotion" && settings.qmAllowOutMotion) allowed = true
if (type == "inMotion" && settings.qmAllowInMotion) allowed = true
if (!allowed) return // Blocked entirely by Quiet Mode Rules
}
def mDebounce = motionDebounce != null ? motionDebounce : 5
def dDebounce = doorDebounce != null ? doorDebounce : 1
def cooldownMs = (type.contains("Motion")) ? (mDebounce * 60000) : (dDebounce * 60000)
def lastPlayed = state."lastAlert_${device.id}" ?: 0
if ((now() - lastPlayed) < cooldownMs) return
def played = false
def prefix = type
def msg = settings["${prefix}Msg_${device.id}"]
def tts = settings["${prefix}TTS_${device.id}"]
def push = settings["${prefix}Push_${device.id}"]
// Evaluate if output methods are permitted
if (msg) {
if (tts && (!isQuiet || settings.qmAllowTTS)) { applyTTSVolume(tts, false); tts.speak(msg); played = true }
if (push && (!isQuiet || settings.qmAllowPush)) { push.deviceNotification(msg); played = true }
}
if (!isQuiet || settings.qmAllowZooz) {
if (playGranularZooz("${prefix}Zooz", device)) played = true
}
if (played) state."lastAlert_${device.id}" = now()
}
def playGranularZooz(settingPrefix, sourceDevice) {
def played = false
def zDevs = settings["${settingPrefix}_${sourceDevice.id}"]
// Flatten ensures that even if only one item is selected (returning an Object instead of a List), it iterates safely
[zDevs].flatten().findAll{it}.eachWithIndex { zDev, index ->
if (index > 0) pauseExecution(1000)
def fileNum = settings["${settingPrefix}File_${sourceDevice.id}_${zDev.id}"]
if (fileNum) {
if (playZoozSound(zDev, fileNum)) played = true
}
}
return played
}
// --- ROBUST ZOOZ PLAYBACK FIX ---
def playZoozSound(zDev, soundNum) {
if (!zDev || soundNum == null) return false
def played = false
def isNumeric = soundNum.toString().isNumber()
def trackNum = isNumeric ? soundNum.toString().toInteger() : null
try {
if (zDev.hasCommand("playSound") && trackNum != null) {
zDev.playSound(trackNum)
played = true
} else if (zDev.hasCommand("playTrack")) {
zDev.playTrack(soundNum.toString())
played = true
} else if (zDev.hasCommand("chime") && trackNum != null) {
zDev.chime(trackNum)
played = true
} else {
log.error "${zDev.displayName} does not support standard audio/siren commands (playSound, playTrack, or chime)."
}
} catch (e) {
log.error "Failed to play sound on Zooz device ${zDev.displayName}: ${e}"
}
return played
}
// --- SENSOR HANDLERS ---
def individualPresenceHandler(evt) {
if (!isSystemEnabled()) return
def devId = evt.device.id
def isArrival = (evt.value == "present")
def slot = null
for (int i = 1; i <= 4; i++) {
if (settings["enablePerson${i}"] && settings["personSensor${i}"]?.id == devId) {
slot = i
break
}
}
if (!slot) return
logContextEvent("${evt.device.displayName} ${isArrival ? 'arrived' : 'departed'}.")
def modeSet = isArrival ? settings["arrivalModes${slot}"] : settings["departureModes${slot}"]
def timeTypeStart = isArrival ? settings["arrStartTimeType${slot}"] : settings["depStartTimeType${slot}"]
def timeStart = isArrival ? settings["arrStartTime${slot}"] : settings["depStartTime${slot}"]
def timeOffStart = isArrival ? settings["arrStartOffset${slot}"] : settings["depStartOffset${slot}"]
def timeTypeEnd = isArrival ? settings["arrEndTimeType${slot}"] : settings["depEndTimeType${slot}"]
def timeEnd = isArrival ? settings["arrEndTime${slot}"] : settings["depEndTime${slot}"]
def timeOffEnd = isArrival ? settings["arrEndOffset${slot}"] : settings["depEndOffset${slot}"]
def conditionMet = isAdvancedConditionMet(
true, modeSet,
timeTypeStart, timeStart, timeOffStart,
timeTypeEnd, timeEnd, timeOffEnd,
false, null, null, null, null, null, null, null
)
if (conditionMet) {
def isQuiet = quietModes && (quietModes as List).contains(location.mode)
if (isQuiet && !settings.qmAllowPresence) return // Blocked by Quiet Mode settings
def msg = isArrival ? settings["arrivalMsg${slot}"] : settings["departureMsg${slot}"]
def tts = isArrival ? settings["arrivalTTS${slot}"] : settings["departureTTS${slot}"]
def push = isArrival ? settings["arrivalPush${slot}"] : settings["departurePush${slot}"]
def zooz = isArrival ? settings["arrivalZooz${slot}"] : settings["departureZooz${slot}"]
if (msg) {
if (tts && (!isQuiet || settings.qmAllowTTS)) {
applyTTSVolume(tts, false)
tts.speak(msg)
}
if (push && (!isQuiet || settings.qmAllowPush)) push.deviceNotification(msg)
}
if (!isQuiet || settings.qmAllowZooz) {
[zooz].flatten().findAll{it}.eachWithIndex { zDev, index ->
if (index > 0) pauseExecution(1000)
def fileNum = isArrival ? settings["arrivalZoozFile${slot}_${zDev.id}"] : settings["departureZoozFile${slot}_${zDev.id}"]
if (fileNum) playZoozSound(zDev, fileNum)
}
}
}
}
def highRiskHandler(evt) {
if (!isSystemEnabled()) return
if (state.bypassEndTime && now() < state.bypassEndTime) {
if (settings.bypassDoors?.find { it.id == evt.device.id }) {
logContextEvent("Ignored ${evt.device.displayName} (Bypass Active)")
return
}
}
if (evt.value == "open") {
logContextEvent("HIGH RISK: ${evt.device.displayName} Opened")
def conditionMet = isAdvancedConditionMet(
enableHighRiskRules1, highRiskModes1, highRiskStartTimeType1, highRiskStartTime1, highRiskStartOffset1, highRiskEndTimeType1, highRiskEndTime1, highRiskEndOffset1,
enableHighRiskRules2, highRiskModes2, highRiskStartTimeType2, highRiskStartTime2, highRiskStartOffset2, highRiskEndTimeType2, highRiskEndTime2, highRiskEndOffset2
)
if (conditionMet) {
def msg = settings["highRiskMsg_${evt.device.id}"] ?: "Warning. ${evt.device.displayName} has been opened."
def tts = settings["highRiskTTS_${evt.device.id}"]
def push = settings["highRiskPush_${evt.device.id}"]
if (tts) { applyTTSVolume(tts, true); tts.speak(msg) }
if (push) push.deviceNotification(msg)
playGranularZooz("highRiskZooz", evt.device)
triggerAlert("Warning", "High-Risk Boundary Breached: ${evt.device.displayName}", evt.device.id, evt.device.displayName)
}
}
}
def panicHandler(evt) {
if (!isSystemEnabled()) return
logContextEvent("PANIC BUTTON PUSHED: ${evt.device.displayName}")
executeEmergencyProtocol("Panic button activated by ${evt.device.displayName}!", evt.device.id, evt.device.displayName)
}
def lifeSafetyHandler(evt) {
if (!isSystemEnabled()) return
if (evt.value == "detected") {
logContextEvent("LIFE SAFETY EMERGENCY: ${evt.name.toUpperCase()} at ${evt.device.displayName}")
executeEmergencyProtocol("Life Safety Alarm: ${evt.name.toUpperCase()} detected by ${evt.device.displayName}!", evt.device.id, evt.device.displayName)
}
}
def executeEmergencyProtocol(alertReason, deviceId = "system", deviceName = "System Engine") {
if (emergencySwitches) emergencySwitches.on()
if (emergencyColoredLights) setEmergencyColor(emergencyColoredLights, emergencyLightColor ?: "Red")
if (emergencyLocks) emergencyLocks.unlock()
if (emergencyTTS) applyTTSVolume(emergencyTTS, true)
def msg = emergencyTTSMessage ?: "Emergency. Evacuation protocol initiated. Please exit the house immediately."
if (emergencyTTS) emergencyTTS.speak(msg)
[emergencyZooz].flatten().findAll{it}.eachWithIndex { zDev, index ->
if (index > 0) pauseExecution(1000)
def fileNum = settings["emergencyZoozFile_${zDev.id}"]
if (fileNum) playZoozSound(zDev, fileNum)
}
triggerAlert("Critical", alertReason, deviceId, deviceName)
}
def setEmergencyColor(devices, colorName) {
def hueColor = 0; def saturation = 100
switch(colorName) { case "White": hueColor=0; saturation=0; break; case "Red": hueColor=0; break; case "Yellow": hueColor=16; break; case "Green": hueColor=33; break; case "Blue": hueColor=66; break; }
devices.setColor([hue: hueColor, saturation: saturation, level: 100])
}
def contactHandler(evt) {
if (!isSystemEnabled()) return
if (state.bypassEndTime && now() < state.bypassEndTime) {
if (settings.bypassDoors?.find { it.id == evt.device.id }) {
logContextEvent("Ignored ${evt.device.displayName} (Bypass Active)")
return
}
}
if (evt.value == "open") {
state.lastDoorOpenTime = now()
def isWindow = perimeterWindows?.find { it.id == evt.device.id } != null
def isDoor = perimeterDoors?.find { it.id == evt.device.id } != null
def isCurfewDoor = curfewDoors?.find { it.id == evt.device.id } != null
def curfewMet = isCurfewDoor && isAdvancedConditionMet(
enableCurfewRules1, curfewModes1, curfewStartTimeType1, curfewStartTime1, curfewStartOffset1, curfewEndTimeType1, curfewEndTime1, curfewEndOffset1,
enableCurfewRules2, curfewModes2, curfewStartTimeType2, curfewStartTime2, curfewStartOffset2, curfewEndTimeType2, curfewEndTime2, curfewEndOffset2
)
if (curfewMet) return
logContextEvent("${evt.device.displayName} Opened")
if (isDoor) {
def conditionMet = isAdvancedConditionMet(
enableDoorRules1, doorModes1, doorStartTimeType1, doorStartTime1, doorStartOffset1, doorEndTimeType1, doorEndTime1, doorEndOffset1,
enableDoorRules2, doorModes2, doorStartTimeType2, doorStartTime2, doorStartOffset2, doorEndTimeType2, doorEndTime2, doorEndOffset2
)
playDeviceCustomAlert(evt.device, "door", conditionMet)
}
if (state.currentAlertLevel == "Warning" && state.lastGlassBreakTime > (now() - 60000)) triggerAlert("Critical", "Glass break followed by perimeter breach at ${evt.device.displayName}!", evt.device.id, evt.device.displayName)
else if (isWindow) triggerAlert("Warning", "${evt.device.displayName} was opened.", evt.device.id, evt.device.displayName)
} else {
logContextEvent("${evt.device.displayName} Closed")
}
}
// MOTION INACTIVE TRACKER FOR DURATION
def motionInactiveHandler(evt) {
def start = state."motionStart_${evt.device.id}"
if (start) {
def durationSec = Math.round((now() - start) / 1000)
logContextEvent("${evt.device.displayName} motion ended (Duration: ${durationSec}s)")
updateAlertDuration(evt.device.id, durationSec)
state.remove("motionStart_${evt.device.id}")
}
}
def outdoorMotionHandler(evt) {
if (!isSystemEnabled()) return
state."motionStart_${evt.device.id}" = now()
def conditionMet = isAdvancedConditionMet(
enableOutMotionRules1, outMotionModes1, outStartTimeType1, outMotionStartTime1, outStartOffset1, outEndTimeType1, outMotionEndTime1, outEndOffset1,
enableOutMotionRules2, outMotionModes2, outStartTimeType2, outMotionStartTime2, outStartOffset2, outEndTimeType2, outMotionEndTime2, outEndOffset2,
overcastSwitch
)
if (conditionMet) {
playDeviceCustomAlert(evt.device, "outMotion", true)
def graceMs = (outboundGracePeriod ?: 30) * 1000
if ((now() - (state.lastDoorOpenTime ?: 0)) <= graceMs) {
logContextEvent("Ignored ${evt.device.displayName} (Resident outbound logic)")
} else {
logContextEvent("Unknown motion at ${evt.device.displayName}")
triggerAlert("Warning", "Motion detected at ${evt.device.displayName} without door opening.", evt.device.id, evt.device.displayName)
playGranularZooz("outMotionZooz", evt.device) // Force play to bypass debounce/quiet modes on a real warning
}
}
}
def indoorMotionHandler(evt) {
if (!isSystemEnabled()) return
state."motionStart_${evt.device.id}" = now()
def conditionMet = isAdvancedConditionMet(
enableInMotionRules1, inMotionModes1, inStartTimeType1, inMotionStartTime1, inStartOffset1, inEndTimeType1, inMotionEndTime1, inEndOffset1,
enableInMotionRules2, inMotionModes2, inStartTimeType2, inMotionStartTime2, inStartOffset2, inEndTimeType2, inMotionEndTime2, inEndOffset2
)
if (conditionMet) {
logContextEvent("Indoor motion at ${evt.device.displayName}")
playDeviceCustomAlert(evt.device, "inMotion", true)
if (state.currentAlertLevel == "Warning" || state.lastGlassBreakTime > (now() - 120000)) {
triggerAlert("Critical", "Intruder tracked to ${evt.device.displayName}!", evt.device.id, evt.device.displayName)
playGranularZooz("inMotionZooz", evt.device) // Force play to bypass debounce/quiet modes on a real warning
}
}
}
def glassBreakHandler(evt) {
if (!isSystemEnabled()) return
state.lastGlassBreakTime = now()
logContextEvent("GLASS BREAK detected at ${evt.device.displayName}")
triggerAlert("Warning", "Possible glass break detected at ${evt.device.displayName}.", evt.device.id, evt.device.displayName)
}
def triggerAlert(level, message, deviceId = "system", deviceName = "System Engine") {
if (state.currentAlertLevel == "Critical" && level == "Warning") return
state.currentAlertLevel = level
// Core structural change from List to Map for deduplication
def alertKey = "${deviceId}_${level}"
def alerts = state.activeAlerts ?: [:]
if (alerts[alertKey]) {
alerts[alertKey].count = (alerts[alertKey].count ?: 1) + 1
alerts[alertKey].lastTime = new Date().format("MM/dd hh:mm:ss a", location.timeZone)
} else {
alerts[alertKey] = [
time: new Date().format("MM/dd hh:mm:ss a", location.timeZone),
lastTime: new Date().format("MM/dd hh:mm:ss a", location.timeZone),
device: deviceName,
msg: message,
level: level,
count: 1,
duration: "Ongoing"
]
}
state.activeAlerts = alerts
logAction("ALERT TRIGGERED [${level}]: ${message}")
if (level == "Critical") {
if (pushCritical) pushCritical.deviceNotification("Security CRITICAL: ${message}")
} else if (level == "Warning") {
def n = new Date()
def s = resolveTime(settings.warnStartTimeType, settings.warningStartTime, settings.warnStartOffset)
def e = resolveTime(settings.warnEndTimeType, settings.warningEndTime, settings.warnEndOffset)
def timeAllowed = (!s || !e) || (s < e ? (n >= s && n <= e) : (n >= s || n <= e))
if (pushWarnings && (!warningModes || (warningModes as List).contains(location.mode)) && timeAllowed) pushWarnings.deviceNotification("Security Warning: ${message}")
}
// Write Alert to Child Device
updateChildDevice("ALERT [${level}]: ${message}")
}
def updateAlertDuration(deviceId, durationSec) {
def alerts = state.activeAlerts ?: [:]
alerts.each { key, alert ->
if (key.startsWith("${deviceId}_") && alert.duration == "Ongoing") {
def mins = Math.floor(durationSec / 60).toInteger()
def secs = durationSec % 60
alert.duration = mins > 0 ? "${mins}m ${secs}s" : "${secs}s"
}
}
state.activeAlerts = alerts
}
def logContextEvent(msg) {
def h = state.eventHistory ?: [];
h.add(0, "[${new Date().format("MM/dd hh:mm:ss a", location.timeZone)}] ${msg}")
if (h.size() > 25) h = h[0..24];
state.eventHistory = h
// Write Event to Child Device
updateChildDevice(msg)
}
def logAction(msg) { log.info "${app.label}: ${msg}" }
def logInfo(msg) { log.info "${app.label}: ${msg}" }