/**
* Local Ecobee Open-Contact Pause (child app)
*
* Pauses the local Ecobee HAP Thermostat (sets mode to off) whenever ANY
* monitored contact sensor is open, and restores the previous mode once all
* are closed. Whole-system pause — any open contact anywhere stops the HVAC.
* Fully offline.
*
* Child of: Local Ecobee Helpers (RamSet)
*
* Author: RamSet
* Version: 1.1.1 (2026-07-01)
* Version history:
* 1.1.1 - No more silent failures: if a contact is open but the app can't pause because no thermostat
* is selected in the parent, it now says so on the Status page and logs a warning (was silent).
* 1.1.0 - Reliability + visibility. Now tracks each contact's state from its EVENT value (authoritative)
* instead of re-reading currentValue inside the handler — that read can lag the just-fired event,
* so the app could miss an open and skip pausing (worst with a 0-minute delay). Added a 5-minute
* re-sync that recovers any missed event, and a live per-contact status list on the app page.
* 1.0.0 - Initial release. Minute-based delays, humanized duration, optional pause/resume notifications.
*
* DISCLAIMER: Provided as-is, without warranty of any kind. You are solely
* responsible for the safe operation of your HVAC system and connected devices.
* Use at your own risk.
*/
definition(
name: "Local Ecobee Open-Contact Pause",
namespace: "RamSet",
author: "RamSet",
description: "Pauses the local Ecobee HAP Thermostat while any monitored contact is open.",
category: "Convenience",
parent: "RamSet:Local Ecobee Helpers",
iconUrl: "",
iconX2Url: "",
importUrl: "https://raw.githubusercontent.com/RamSet/hubitat/main/apps/ecobee-hap-helpers/ecobee-hap-contact-pause-child.groovy"
)
preferences {
page(name: "mainPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "Open-Contact Pause", install: true, uninstall: true) {
section("Contacts") {
label title: "Name for this Open-Contact Pause", required: true
input "contacts", "capability.contactSensor", title: "Window/door contact sensor(s)", multiple: true, required: true
}
section("Behavior") {
input "openDelay", "decimal", title: "Delay before pausing after an open (minutes)", defaultValue: 0, range: "0..240", required: true
input "closeDelay", "decimal", title: "Delay before resuming after all are closed (minutes)", defaultValue: 0, range: "0..240", required: true
}
section("Notifications") {
input "notifier", "capability.notification", title: "Send notifications to (optional)", multiple: true, required: false
}
section("Status") {
def m = state.contactOpen ?: [:]
boolean anyO = false
def lines = contacts?.collect { c ->
boolean open = m.containsKey(c.id as String) ? m[c.id as String] : (c.currentValue("contact") == "open")
if (open) anyO = true
(open ? "🔴 OPEN" : "🟢 closed") + " — ${c.displayName}"
}
paragraph (lines ? lines.join("
") : "No contacts selected yet.")
if (state.paused) {
paragraph "HVAC PAUSED (was: ${state.priorMode})."
} else if (anyO) {
boolean haveT = (parent?.getThermostat() != null)
paragraph "A contact is open but the HVAC is NOT paused. " +
(haveT ? "Press Done below to re-arm the app." :
"No thermostat is selected in the parent Local Ecobee Helpers app — select it there, then press Done.") +
""
} else {
paragraph "HVAC running (not paused)."
}
}
section {
paragraph "When any contact opens, the thermostat's current mode is remembered and it is set to off. " +
"When all contacts close, the previous mode is restored. Any open contact anywhere pauses the entire HVAC."
}
}
}
def installed() { initialize() }
def updated() { unsubscribe(); unschedule(); initialize() }
def initialize() {
subscribe(contacts, "contact", contactHandler)
seedContactStates() // seed from live values on (re)install
runEvery5Minutes("resync") // self-heal: recover if a contact event is ever missed
evaluatePause() // sync to current state now
}
// Track each contact's open/closed from its EVENT value (authoritative). Re-reading currentValue inside
// the handler can lag the just-fired event, which made the app miss an open and skip pausing.
private void seedContactStates() {
def m = [:]
contacts?.each { m[it.id as String] = (it.currentValue("contact") == "open") }
state.contactOpen = m
}
def contactHandler(evt) {
if (evt?.deviceId != null) {
def m = state.contactOpen ?: [:]
m[evt.deviceId as String] = (evt.value == "open")
state.contactOpen = m
}
evaluatePause()
}
// periodic safety net: re-read live values and re-evaluate, so a missed event still gets corrected
def resync() { seedContactStates(); evaluatePause() }
private boolean anyOpen() {
def m = state.contactOpen ?: [:]
if (m.values().any { it }) return true // tracked state says something is open
return contacts?.any { it.currentValue("contact") == "open" } // safety net for any not-yet-tracked device
}
private void evaluatePause() {
if (anyOpen()) {
unschedule(doResume)
int os = toSeconds(openDelay)
if (os > 0) runIn(os, doPause) else doPause()
} else {
unschedule(doPause)
int cs = toSeconds(closeDelay)
if (cs > 0) runIn(cs, doResume) else doResume()
}
}
def doPause() {
if (!anyOpen()) return
if (state.paused) return
def t = parent?.getThermostat()
if (!t) { log.warn "Open-Contact Pause '${app.label}': ${openContactNames()} open but CANNOT pause — no thermostat is selected in the parent 'Local Ecobee Helpers' app"; return }
state.priorMode = t.currentValue("thermostatMode")
state.paused = true
t.off()
log.info "Open-Contact Pause '${app.label}': contact open → HVAC off (was ${state.priorMode})"
String dur = toSeconds(openDelay) > 0 ? " for ${humanDelay(openDelay)}" : ""
sendNote("HVAC paused: ${openContactNames()} open${dur}. Thermostat is now OFF (was ${state.priorMode}).")
}
def doResume() {
if (anyOpen()) return
if (!state.paused) return
def t = parent?.getThermostat()
if (!t) return
def m = state.priorMode ?: "auto"
state.paused = false
t.setThermostatMode(m)
log.info "Open-Contact Pause '${app.label}': all closed → restored ${m}"
sendNote("All contacts closed. Thermostat is back ON (${m}).")
}
private int toSeconds(mins) {
Math.round(((mins ?: 0) as double) * 60.0d) as int
}
// "45s" under a minute, otherwise "N min" (one decimal if needed)
private String humanDelay(mins) {
int secs = toSeconds(mins)
if (secs < 60) return "${secs}s"
double m = secs / 60.0d
return (m == Math.floor(m)) ? "${m as int} min" : "${Math.round(m * 10.0d) / 10.0d} min"
}
private String openContactNames() {
def m = state.contactOpen ?: [:]
def names = contacts?.findAll { m.containsKey(it.id as String) ? m[it.id as String] : (it.currentValue("contact") == "open") }?.collect { it.displayName }
return names ? names.join(", ") : "contact(s)"
}
private void sendNote(String msg) {
notifier?.each { it.deviceNotification(msg) }
log.info "Open-Contact Pause '${app.label}': notify → ${msg}"
}