/**
* Local Ecobee Room Vent (child app)
*
* Per-room vent control following the local Ecobee HAP Thermostat.
* While the HVAC is heating/cooling, opens the room's vent(s) proportionally
* toward (thermostat setpoint + per-room offset), closing to a minimum floor
* once the room is satisfied. Fully offline.
*
* Child of: Local Ecobee Helpers (RamSet)
*
* Author: RamSet
* Version: 1.0.0 (2026-06-24)
* Version history:
* 1.0.0 - Initial release. Proportional vent control with adjustable periodic re-evaluation.
*
* DISCLAIMER: Provided as-is, without warranty of any kind. You are solely
* responsible for the safe operation of your HVAC system and connected devices.
* Closing too many vents can damage your system — use at your own risk.
*/
definition(
name: "Local Ecobee Room Vent",
namespace: "RamSet",
author: "RamSet",
description: "Per-room vent control following the local Ecobee HAP Thermostat.",
category: "Convenience",
parent: "RamSet:Local Ecobee Helpers",
iconUrl: "",
iconX2Url: "",
importUrl: "https://raw.githubusercontent.com/RamSet/hubitat/main/apps/ecobee-hap-helpers/ecobee-hap-room-vent-child.groovy"
)
preferences {
page(name: "mainPage")
}
def mainPage() {
dynamicPage(name: "mainPage", title: "Room Vent", install: true, uninstall: true) {
section("Naming & sensors") {
label title: "Name for this Room Vent", required: true
input "tempSensors", "capability.temperatureMeasurement", title: "Room temperature sensor(s)", multiple: true, required: true, submitOnChange: true
if (tempSensors) paragraph "Current room temperature: ${avgTemp()}°"
}
section("Vents") {
paragraph "Modulating vents (Keen / dimmer) are set to a % open. On/off switch vents are turned on while conditioning, off otherwise."
input "ventLevels", "capability.switchLevel", title: "Modulating vent(s) — Keen / dimmer", multiple: true, required: false, submitOnChange: true
input "ventSwitches", "capability.switch", title: "On/off vent switch(es)", multiple: true, required: false, submitOnChange: true
}
section("Targeting") {
input "heatOffset", "decimal", title: "Heating setpoint offset (added to thermostat heat setpoint)", defaultValue: 0.0, required: true
input "coolOffset", "decimal", title: "Cooling setpoint offset (added to thermostat cool setpoint)", defaultValue: 0.0, required: true
input "band", "decimal", title: "Proportional band — degrees from target at which the vent is fully open", defaultValue: 2.0, required: true
input "alwaysAdjust", "bool", title: "Always adjust (modulate on room temp even when the thermostat is idle)", defaultValue: false
input "reEvalMinutes", "number", title: "Re-evaluation interval (minutes) — periodic safety re-check even when sensors are quiet", defaultValue: 5, range: "1..120", required: true
}
section("Minimum open floor") {
input "floor", "number", title: "Per-vent minimum open % (0–100)", defaultValue: 10, range: "0..100", required: true, submitOnChange: true
if ((floor ?: 0) < 10) {
paragraph "WARNING: floor is set to ${floor ?: 0}% — below the 10% safe minimum. This can dangerously restrict airflow."
}
paragraph disclaimer()
}
}
}
private String disclaimer() {
"Airflow warning. Setting a vent below 10% (and especially to 0%) can dangerously restrict airflow. " +
"When cooling, this can freeze the evaporator coil and ice the lines; when heating, it can overheat the heat exchanger, " +
"trip the high-limit, and short-cycle; either way it strains the blower motor. Never let too many vents close at once. " +
"Ensuring adequate open airflow is your responsibility — use at your own risk."
}
def installed() { initialize() }
def updated() { unsubscribe(); unschedule(); initialize() }
def initialize() {
def t = parent?.getThermostat()
if (t) {
subscribe(t, "thermostatOperatingState", evtHandler)
subscribe(t, "heatingSetpoint", evtHandler)
subscribe(t, "coolingSetpoint", evtHandler)
subscribe(t, "thermostatMode", evtHandler)
}
subscribe(tempSensors, "temperature", evtHandler)
schedulePeriodic()
evaluateVent()
}
private void schedulePeriodic() {
int mins = Math.max(1, (reEvalMinutes ?: 5) as int)
runIn(mins * 60, periodicEval)
}
def periodicEval() {
schedulePeriodic() // reschedule first so an eval error can't break the chain
evaluateVent()
}
def evtHandler(evt) { evaluateVent() }
// reported to the parent for the system-wide airflow warning
Map ventReport() {
[name: app.label, floor: (floor ?: 0) as Integer,
ventCount: ((ventLevels?.size() ?: 0) + (ventSwitches?.size() ?: 0))]
}
private avgTemp() {
def temps = tempSensors?.collect { it.currentValue("temperature") }?.findAll { it != null }
if (!temps) return null
return (temps.sum() / temps.size())
}
def evaluateVent() {
def t = parent?.getThermostat()
if (!t) return
def room = avgTemp()
if (room == null) { log.warn "Room Vent '${app.label}': no room temperature yet"; return }
def opState = t.currentValue("thermostatOperatingState")
String mode = t.currentValue("thermostatMode")
double flr = (floor ?: 0) as double
double b = (band ?: 2.0) as double
if (b <= 0) b = 0.1
boolean heating = opState in ["heating", "pending heat"]
boolean cooling = opState in ["cooling", "pending cool"]
double level
boolean conditioning = heating || cooling
if (heating || (alwaysAdjust && mode == "heat")) {
double target = ((t.currentValue("heatingSetpoint") as double) + (heatOffset ?: 0.0))
level = scale(target - (room as double), b, flr) // room below target → open
conditioning = true
} else if (cooling || (alwaysAdjust && mode == "cool")) {
double target = ((t.currentValue("coolingSetpoint") as double) + (coolOffset ?: 0.0))
level = scale((room as double) - target, b, flr) // room above target → open
conditioning = true
} else {
level = flr // idle/off → close to floor
conditioning = false
}
applyLevel(level, conditioning)
}
private double scale(double delta, double band, double flr) {
if (delta <= 0) return flr
double frac = delta / band
if (frac > 1) frac = 1
return Math.round(flr + (100.0d - flr) * frac) as double
}
private applyLevel(double level, boolean conditioning) {
int lv = Math.max(0, Math.min(100, (int) Math.round(level)))
ventLevels?.each { it.setLevel(lv) }
ventSwitches?.each { (conditioning && lv > 0) ? it.on() : it.off() }
log.debug "Room Vent '${app.label}': vents → ${lv}% (conditioning=${conditioning})"
}