There's only one instance of ${state.structureId}, but there are 5 instances of structureId
Going to try it both ways
EDIT: Hmmm I'm clearly doing something wrong -- any other ideas?
There's only one instance of ${state.structureId}, but there are 5 instances of structureId
Going to try it both ways
EDIT: Hmmm I'm clearly doing something wrong -- any other ideas?
I guess there's more complexity involved in the logic. I deep dive a bit more and found some URIs that aren't using the structure id in the API schema, so I think this will need some work to make it happen, unfortunately.
That’s alright — I appreciate the attempt just the same.
For now, I think the easiest thing is for me to create two different Flair accounts. If you do ever get around to it, let me know.
@mluck I'd need you to help me validate the code. I just added support for defining your own home structure id, but since I don't have more than one home I can't test it. Below is the code with the latest changes, you'll find in the UI a new input for Home ID. Let me know if the code below works for you, then once you confirm it works I'll push it to GitHub. Here's the code:
/**
*
* Copyright 2024 Jaime Botero. All Rights Reserved
*
* Licensed under the Apache License, Version 2.0 (the 'License');
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an 'AS IS' BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*
*/
import groovy.transform.Field
@Field static String BASE_URL = 'https://api.flair.co'
@Field static String CONTENT_TYPE = 'application/json'
@Field static String COOLING = 'cooling'
@Field static String HEATING = 'heating'
@Field static String PENDING_COOL = 'pending cool'
@Field static String PENDING_HEAT = 'pending heat'
@Field static Integer MILLIS_DELAY_TEMP_READINGS = 1000 * 30
@Field static BigDecimal MIN_PERCENTAGE_OPEN = 0.0
@Field static BigDecimal VENT_PRE_ADJUSTMENT_THRESHOLD_C = 0.2
@Field static BigDecimal MAX_PERCENTAGE_OPEN = 100.0
@Field static BigDecimal MAX_MINUTES_TO_SETPOINT = 60
@Field static BigDecimal MIN_MINUTES_TO_SETPOINT = 1 //5
@Field static BigDecimal SETPOINT_OFFSET_C = 0.7
@Field static BigDecimal MAX_TEMP_CHANGE_RATE_C = 1.5
@Field static BigDecimal MIN_TEMP_CHANGE_RATE_C = 0.001
@Field static BigDecimal MIN_COMBINED_VENT_FLOW_PERCENTAGE = 30.0
@Field static BigDecimal INREMENT_PERCENTAGE_WHEN_REACHING_VENT_FLOW_TAGET = 1.5
@Field static Integer MAX_NUMBER_OF_STANDARD_VENTS = 15
@Field static Integer MAX_ITERATIONS = 500
@Field static Integer HTTP_TIMEOUT_SECS = 5
@Field static BigDecimal BASE_CONST = 0.0991
@Field static BigDecimal EXP_CONST = 2.3
definition(
name: 'Flair Vents',
namespace: 'bot.flair',
author: 'Jaime Botero',
description: 'Provides discovery and control capabilities for Flair Vent devices',
category: 'Discovery',
oauth: false,
iconUrl: '',
iconX2Url: '',
iconX3Url: '',
singleInstance: true
)
preferences {
page(name: 'mainPage')
}
def mainPage() {
dynamicPage(name: 'mainPage', title: 'Setup', install: true, uninstall: true) {
section {
input 'clientId', 'text', title: 'Client Id (OAuth 2.0)', required: true, submitOnChange: true
input 'clientSecret', 'text', title: 'Client Secret OAuth 2.0', required: true, submitOnChange: true
paragraph '<small><b>Obtain your client Id and secret from ' +
"<a href='https://forms.gle/VohiQjWNv9CAP2ASA' target='_blank'>here<a/></b></small>"
if (settings?.clientId != null && settings?.clientSecret != null ) {
input 'authenticate', 'button', title: 'Authenticate', submitOnChange: true
}
if (state.authError) {
section {
paragraph "<span style='color: red;'>${state.authError}</span>"
}
}
}
if (state.flairAccessToken != null) {
section {
input 'discoverDevices', 'button', title: 'Discover', submitOnChange: true
input 'structureId', 'text', title: 'Home Id (SID)', required: false, submitOnChange: true
}
listDiscoveredDevices()
section('<h2>Dynamic Airflow Balancing</h2>') {
input 'dabEnabled', title: 'Use Dynamic Airflow Balancing', submitOnChange: true, defaultValue: false, 'bool'
if (dabEnabled) {
input 'thermostat1', title: 'Choose Thermostat for Vents', multiple: false, required: true, 'capability.thermostat'
input name: 'thermostat1TempUnit', type: 'enum', title: 'Units used by Thermostat', defaultValue: 2,
options: [1:'Celsius (°C)', 2:'Fahrenheit (°F)']
input 'thermostat1AdditionalStandardVents', title: 'Count of conventional Vents', submitOnChange: true, defaultValue: 0, 'number'
paragraph '<small>Enter the total number of standard (non-Flair) adjustable vents in the home associated ' +
'with the chosen thermostat, excluding Flair vents. This value will ensure the combined airflow across ' +
'all vents does not drop below a specified percent. It is used to maintain adequate airflow and prevent ' +
'potential frosting or other HVAC problems caused by lack of air movement.</small>'
input 'thermostat1CloseInactiveRooms', title: 'Close vents on inactive rooms', submitOnChange: true, defaultValue: true, 'bool'
if (settings.thermostat1AdditionalStandardVents < 0) {
app.updateSetting('thermostat1AdditionalStandardVents', 0)
} else if (settings.thermostat1AdditionalStandardVents > MAX_NUMBER_OF_STANDARD_VENTS) {
app.updateSetting('thermostat1AdditionalStandardVents', MAX_NUMBER_OF_STANDARD_VENTS)
}
if (!atomicState.thermostat1Mode || atomicState.thermostat1Mode == 'auto') {
patchStructureData(['mode': 'manual'])
atomicState.thermostat1Mode = 'manual'
}
} else if (!atomicState.thermostat1Mode || atomicState.thermostat1Mode == 'manual') {
patchStructureData(['mode': 'auto'])
atomicState.thermostat1Mode = 'auto'
}
for (child in getChildDevices()) {
input "thermostat${child.getId()}",
title: "Choose Thermostat for ${child.getLabel()} (Optional)",
multiple: false, required: false, 'capability.temperatureMeasurement'
}
}
} else {
section {
paragraph 'Device discovery button is hidden until authorization is completed.'
}
}
section {
input name: 'debugLevel', type: 'enum', title: 'Choose debug level', defaultValue: 0,
options: [0:'None', 1:'Level 1 (All)', 2:'Level 2', 3:'Level 3'], submitOnChange: true
}
}
}
def listDiscoveredDevices() {
final String acBoosterLink = 'https://amzn.to/3QwVGbs'
def children = getChildDevices()
BigDecimal maxCoolEfficiency = 0
BigDecimal maxHeatEfficiency = 0
for (vent in children) {
def coolingRate = vent.currentValue('room-cooling-rate') ?: 0
def heatingRate = vent.currentValue('room-heating-rate') ?: 0
if (maxCoolEfficiency < coolingRate) {
maxCoolEfficiency = coolingRate
}
if (maxHeatEfficiency < heatingRate) {
maxHeatEfficiency = heatingRate
}
}
def builder = new StringBuilder()
builder << '<style>' +
'.device-table { width: 100%; border-collapse: collapse; font-family: Arial, sans-serif; color: black; }' +
'.device-table th, .device-table td { padding: 8px; text-align: left; border-bottom: 1px solid #ddd; }' +
'.device-table th { background-color: #f2f2f2; color: #333; }' +
'.device-table tr:hover { background-color: #f5f5f5; }' +
'.device-table a { color: #333; text-decoration: none; }' +
'.device-table a:hover { color: #666; }' +
'.device-table th:not(:first-child), .device-table td:not(:first-child) { text-align: center; }' +
'.warning-message { color: darkorange; cursor: pointer; }' +
'.danger-message { color: red; cursor: pointer; }' +
'</style>' +
'<table class="device-table">' +
'<thead>' +
' <tr>' +
' <th></th>' +
' <th colspan="2">Efficiency</th>' +
' </tr>' +
' <tr>' +
' <th rowspan="2">Device</th>' +
' <th>Cooling</th>' +
' <th>Heating</th>' +
' </tr>' +
'</thead>' +
'<tbody>'
children.each {
if (it != null) {
def coolingRate = it.currentValue('room-cooling-rate') ?: 0
def heatingRate = it.currentValue('room-heating-rate') ?: 0
def coolEfficiency = maxCoolEfficiency > 0 ? roundBigDecimal((coolingRate / maxCoolEfficiency) * 100, 0) : 0
def heatEfficiency = maxHeatEfficiency > 0 ? roundBigDecimal((heatingRate / maxHeatEfficiency) * 100, 0) : 0
def coolClass = coolEfficiency <= 0 ? '' : coolEfficiency <= 25 ? 'danger-message' : coolEfficiency <= 45 ? 'warning-message' : ''
def heatClass = heatEfficiency <= 0 ? '' : heatEfficiency <= 25 ? 'danger-message' : heatEfficiency <= 45 ? 'warning-message' : ''
def warnMsg = 'This vent is very inefficient, consider installing an HVAC booster. Click for a recommendation.'
def coolPopupHtml = coolEfficiency <= 45 ?
"<span class='${coolClass}' onclick=\"window.open('${acBoosterLink}');\" title='${warnMsg}'>${coolEfficiency}%</span>" : "${coolEfficiency}%"
def heatPopupHtml = heatEfficiency <= 45 ?
"<span class='${heatClass}' onclick=\"window.open('${acBoosterLink}');\" title='${warnMsg}'>${heatEfficiency}%</span>" : "${heatEfficiency}%"
builder << "<tr><td><a href='/device/edit/${it.getId()}'>${it.getLabel()}</a>" +
"</td><td>${coolPopupHtml}</td><td>${heatPopupHtml}</td></tr>"
}
}
builder << '</table>'
def links = builder.toString()
section {
paragraph 'Discovered devices:'
paragraph links
}
}
def getStructureId() {
if (!settings?.structureId) {
getStructureData()
}
return settings?.structureId
}
def updated() {
log.debug('Hubitat Flair App updating')
initialize()
}
def installed() {
log.debug('Hubitat Flair App installed')
initialize()
}
def uninstalled() {
log.debug('Hubitat Flair App uninstalling')
removeChildren()
unschedule()
unsubscribe()
}
def initialize() {
unsubscribe()
if (settings.thermostat1) {
subscribe(settings.thermostat1, 'thermostatOperatingState', thermostat1ChangeStateHandler)
subscribe(settings.thermostat1, 'temperature', thermostat1ChangeTemp)
def temp = thermostat1.currentValue('temperature') ?: 0
def coolingSetpoint = thermostat1.currentValue('coolingSetpoint') ?: 0
def heatingSetpoint = thermostat1.currentValue('heatingSetpoint') ?: 0
String hvacMode = calculateHvacMode(temp, coolingSetpoint, heatingSetpoint)
runInMillis(3000, 'initializeRoomStates', [data: hvacMode])
}
}
// Helpers
private openAllVents(ventIdsByRoomId, percentOpen) {
ventIdsByRoomId.each { roomId, ventIds ->
ventIds.each {
def vent = getChildDevice(it)
if (vent) {
patchVent(vent, percentOpen)
}
}
}
}
private getRoomTemp(vent) {
def tempDevice = settings."thermostat${vent.getId()}"
if (tempDevice) {
def temp = tempDevice.currentValue('temperature') ?: 0
if (settings.thermostat1TempUnit == '2') {
temp = convertFahrenheitToCentigrades(temp)
}
log("Got temp from ${tempDevice.getLabel()} of ${temp}", 2)
return temp
}
return vent.currentValue('room-current-temperature-c') ?: 0
}
private atomicStateUpdate(stateKey, key, value) {
atomicState.updateMapValue(stateKey, key, value)
log("atomicStateUpdate(${stateKey}, ${key}, ${value}", 1)
}
def getThermostatSetpoint(hvacMode) {
BigDecimal setpoint = hvacMode == COOLING ?
(thermostat1.currentValue('coolingSetpoint') ?: 0) - SETPOINT_OFFSET_C :
(thermostat1.currentValue('heatingSetpoint') ?: 0) + SETPOINT_OFFSET_C
setpoint = setpoint ?: thermostat1.currentValue('thermostatSetpoint')
if (!setpoint) {
log.error('Thermostat has no setpoint property, please choose a vaid thermostat')
return setpoint
}
if (settings.thermostat1TempUnit == '2') {
setpoint = convertFahrenheitToCentigrades(setpoint)
}
return setpoint
}
def roundBigDecimal(BigDecimal number, decimalPoints = 3) {
number.setScale(decimalPoints, BigDecimal.ROUND_HALF_UP)
}
def convertFahrenheitToCentigrades(tempValue) {
(tempValue - 32) * (5 / 9)
}
def roundToNearestFifth(BigDecimal num) {
Math.round(num / 5) * 5
}
def rollingAverage(BigDecimal currentAverage, BigDecimal newNumber, BigDecimal weight = 1, int numEntries = 10) {
if (numEntries <= 0) { return 0 }
BigDecimal rollingAverage = !currentAverage || currentAverage == 0 ? newNumber : currentAverage
BigDecimal sum = rollingAverage * (numEntries - 1)
def weightedValue = (newNumber - rollingAverage) * weight
def numberToAdd = rollingAverage + weightedValue
sum += numberToAdd
return sum / numEntries
}
def hasRoomReachedSetpoint(hvacMode, setpoint, currentVentTemp, offset = 0) {
(hvacMode == COOLING && currentVentTemp <= setpoint - offset) ||
(hvacMode == HEATING && currentVentTemp >= setpoint + offset)
}
def calculateHvacMode(temp, coolingSetpoint, heatingSetpoint) {
Math.abs(temp - coolingSetpoint) < Math.abs(temp - heatingSetpoint) ?
COOLING : HEATING
}
void removeChildren() {
def children = getChildDevices()
log("Deleting all child devices: ${children}", 2)
children.each {
if (it != null) {
deleteChildDevice it.getDeviceNetworkId()
}
}
}
private logDetails(msg, details = null, level = 3) {
def settingsLevel = (settings?.debugLevel).toInteger()
if (!details || (settingsLevel == 3 && level >= 2)) {
log(msg, level)
} else {
log("${msg}\n${details}", level)
}
}
// Level 1 is the most verbose
private log(msg, level = 3) {
def settingsLevel = (settings?.debugLevel).toInteger()
if (settingsLevel == 0) {
return
}
if (settingsLevel <= level) {
log.debug(msg)
}
}
def isValidResponse(resp) {
if (!resp) {
log.error('HTTP Null response')
return false
}
try {
if (resp.hasError()) {
def respJson = groovy.json.JsonOutput.toJson(resp)
log.error("HTTP response: ${respJson}")
return false
}
} catch (err) {
log.error(err)
}
return true
}
def getDataAsync(uri, handler, data = null) {
def headers = [ Authorization: 'Bearer ' + state.flairAccessToken ]
def contentType = CONTENT_TYPE
def httpParams = [ uri: uri, headers: headers, contentType: contentType, timeout: HTTP_TIMEOUT_SECS ]
asynchttpGet(handler, httpParams, data)
}
def patchDataAsync(uri, handler, body, data = null) {
def headers = [ Authorization: 'Bearer ' + state.flairAccessToken ]
def contentType = CONTENT_TYPE
def httpParams = [
uri: uri,
headers: headers,
contentType: contentType,
requestContentType: contentType,
timeout: HTTP_TIMEOUT_SECS,
body: groovy.json.JsonOutput.toJson(body)
]
asynchttpPatch(handler, httpParams, data)
logDetails("patchDataAsync: ${uri}", "body:${body}", 2)
}
def login() {
autheticate()
getStructureData()
}
// ### OAuth ###
def autheticate() {
log('Getting access_token from Flair', 2)
def uri = BASE_URL + '/oauth2/token'
def body = "client_id=${settings?.clientId}&client_secret=${settings?.clientSecret}" +
'&scope=vents.view+vents.edit+structures.view+structures.edit&grant_type=client_credentials'
def params = [uri: uri, body: body, timeout: HTTP_TIMEOUT_SECS]
try {
httpPost(params) { response -> handleAuthResponse(response) }
state.remove('authError')
} catch (groovyx.net.http.HttpResponseException e) {
String err = "Login failed - ${e.getLocalizedMessage()}: ${e.response.data}"
log.error(err)
state.authError = err
return err
}
return ''
}
def handleAuthResponse(resp) {
def respJson = resp.getData()
//log("Authorized scopes: ${respJson.scope}", 1)
state.flairAccessToken = respJson.access_token
}
// ### Get devices ###
def appButtonHandler(btn) {
switch (btn) {
case 'authenticate':
login()
unschedule(login)
runEvery1Hour login
break
case 'discoverDevices':
discover()
break
}
}
private void discover() {
log('Discovery started', 3)
atomicState.remove('ventsByRoomId')
def structureId = getStructureId()
def uri = "${BASE_URL}/api/structures/${structureId}/vents"
getDataAsync(uri, handleDeviceList)
}
def handleDeviceList(resp, data) {
if (!isValidResponse(resp)) { return }
def respJson = resp.getJson()
// logDetails('Discovery Data', respJson, 1)
respJson.data.each {
def device = [:]
device.id = it.id
device.type = it.type
device.label = it.attributes.name
def dev = makeRealDevice(device)
if (dev != null) {
processVentTraits(dev, [data: it])
}
}
}
def makeRealDevice(device) {
def newDevice = getChildDevice(device.id)
if (!newDevice) {
def deviceType = "Flair ${device.type}"
newDevice = addChildDevice('bot.flair', deviceType.toString(), device.id, [name: device.label, label: device.label])
}
return newDevice
}
def getDeviceData(device) {
log("Refresh device details for ${device}", 2)
def deviceId = device.getDeviceNetworkId()
def uri = BASE_URL + '/api/vents/' + deviceId + '/current-reading'
getDataAsync(uri, handleDeviceGet, [device: device])
uri = BASE_URL + '/api/vents/' + deviceId + '/room'
getDataAsync(uri, handleRoomGet, [device: device])
}
// ### Get device data ###
def handleRoomGet(resp, data) {
if (!isValidResponse(resp) || !data || !data?.device) { return }
processRoomTraits(data.device, resp.getJson())
}
def handleDeviceGet(resp, data) {
if (!isValidResponse(resp) || !data || !data?.device) { return }
processVentTraits(data.device, resp.getJson())
}
def traitExtract(device, details, propNameData, propNameDriver = propNameData, unit = null) {
try {
def propValue = details.data.attributes[propNameData]
if (propValue != null) {
if (unit) {
sendEvent(device, [name: propNameDriver, value: propValue, unit: unit])
} else {
sendEvent(device, [name: propNameDriver, value: propValue])
}
}
logDetails("Estracted: ${propNameData} = ${propValue}", 1)
} catch (err) {
log.warn(err)
}
}
def processVentTraits(device, details) {
logDetails("Processing Vent data for ${device}", details, 1)
if (!details || !details?.data) {
log.warn("Failed extracting data for ${device}")
return
}
traitExtract(device, details, 'firmware-version-s')
traitExtract(device, details, 'rssi')
traitExtract(device, details, 'connected-gateway-name')
traitExtract(device, details, 'created-at')
traitExtract(device, details, 'duct-pressure')
traitExtract(device, details, 'percent-open', 'percent-open', '%')
traitExtract(device, details, 'percent-open', 'level', '%')
traitExtract(device, details, 'duct-temperature-c')
traitExtract(device, details, 'motor-run-time')
traitExtract(device, details, 'system-voltage', 'voltage')
traitExtract(device, details, 'motor-current')
traitExtract(device, details, 'has-buzzed')
traitExtract(device, details, 'updated-at')
traitExtract(device, details, 'inactive')
}
def processRoomTraits(device, details) {
if (!device || !details || !details?.data || !details?.data?.id) { return }
logDetails("Processing Room data for ${device}", details, 1)
def roomId = details?.data?.id
sendEvent(device, [name: 'room-id', value: roomId])
traitExtract(device, details, 'name', 'room-name')
traitExtract(device, details, 'current-temperature-c', 'room-current-temperature-c')
traitExtract(device, details, 'room-conclusion-mode')
traitExtract(device, details, 'humidity-away-min', 'room-humidity-away-min')
traitExtract(device, details, 'room-type')
traitExtract(device, details, 'temp-away-min-c', 'room-temp-away-min-c')
traitExtract(device, details, 'level', 'room-level')
traitExtract(device, details, 'hold-until', 'room-hold-until')
traitExtract(device, details, 'room-away-mode')
traitExtract(device, details, 'heat-cool-mode', 'room-heat-cool-mode')
traitExtract(device, details, 'updated-at', 'room-updated-at')
traitExtract(device, details, 'state-updated-at', 'room-state-updated-at')
traitExtract(device, details, 'set-point-c', 'room-set-point-c')
traitExtract(device, details, 'hold-until-schedule-event', 'room-hold-until-schedule-event')
traitExtract(device, details, 'frozen-pipe-pet-protect', 'room-frozen-pipe-pet-protect')
traitExtract(device, details, 'created-at', 'room-created-at')
traitExtract(device, details, 'windows', 'room-windows')
traitExtract(device, details, 'air-return', 'room-air-return')
traitExtract(device, details, 'current-humidity', 'room-current-humidity')
traitExtract(device, details, 'hold-reason', 'room-hold-reason')
traitExtract(device, details, 'occupancy-mode', 'room-occupancy-mode')
traitExtract(device, details, 'temp-away-max-c', 'room-temp-away-max-c')
traitExtract(device, details, 'humidity-away-max', 'room-humidity-away-max')
traitExtract(device, details, 'preheat-precool', 'room-preheat-precool')
traitExtract(device, details, 'active', 'room-active')
traitExtract(device, details, 'set-point-manual', 'room-set-point-manual')
traitExtract(device, details, 'pucks-inactive', 'room-pucks-inactive')
if (details?.data?.relationships?.structure?.data) {
def structureId = details.data.relationships.structure.data.id
sendEvent(device, [name: 'structure-id', value: structureId])
}
if (details?.data?.relationships['remote-sensors'] && details?.data?.relationships['remote-sensors']?.data) {
def remoteSensor = details?.data?.relationships['remote-sensors']?.data?.first()
if (remoteSensor) {
uri = BASE_URL + '/api/remote-sensors/' + remoteSensor.id + '/sensor-readings'
getDataAsync(uri, handleRemoteSensorGet, [device: device])
}
}
updateByRoomIdState(details)
}
def handleRemoteSensorGet(resp, data) {
if (!isValidResponse(resp) || !data) { return }
def details = resp?.getJson()
if (!details?.data) { return }
if (!details?.data?.first()) { return }
def propValue = details?.data?.first()?.attributes['occupied']
//log("handleRemoteSensorGet: ${details}", 1)
sendEvent(data.device, [name: 'room-occupied', value: propValue])
}
def updateByRoomIdState(details) {
if (!details?.data?.relationships?.vents?.data) { return }
def roomId = details.data.id
if (!atomicState.ventsByRoomId?."${roomId}") {
def ventIds = []
details.data.relationships.vents.data.each {
ventIds.add(it.id)
}
atomicStateUpdate('ventsByRoomId', roomId, ventIds)
}
//log(atomicState.ventsByRoomId, 1)
}
// ### Operations ###
def patchStructureData(attributes) {
def body = [data: [type: 'structures', attributes: attributes]]
def uri = BASE_URL + "/api/structures/${getStructureId()}"
patchDataAsync(uri, null, body)
}
def getStructureData() {
log('getStructureData', 1)
def uri = BASE_URL + '/api/structures'
def headers = [ Authorization: 'Bearer ' + state.flairAccessToken ]
def contentType = CONTENT_TYPE
def httpParams = [ uri: uri, headers: headers, contentType: contentType, timeout: HTTP_TIMEOUT_SECS ]
httpGet(httpParams) { resp ->
if (!resp.success) {
return
}
def response = resp.getData()
if (!response) {
error('getStructureData: no data')
return
}
logDetails('response: ', response)
def myStruct = response.data.first()
if (!myStruct?.attributes) {
error('getStructureData: no structure data')
return
}
app.updateSetting("structureId", myStruct.id)
}
}
def patchVent(device, percentOpen) {
def pOpen = percentOpen
if (pOpen > 100) {
pOpen = 100
log.warn('Trying to set vent open percentage to inavlid value')
} else if (pOpen < 0) {
pOpen = 0
log.warn('Trying to set vent open percentage to inavlid value')
}
def currPercentOpen = (device?.currentValue('percent-open') ?: 0).toInteger()
if (percentOpen == currPercentOpen) {
log("Keeping percent open for ${device} unchanged to ${percentOpen}%", 3)
return
}
log("Setting percent open for ${device} from ${currPercentOpen} to ${percentOpen}%", 3)
def deviceId = device.getDeviceNetworkId()
def uri = BASE_URL + '/api/vents/' + deviceId
def body = [
data: [
type: 'vents',
attributes: [
'percent-open': (pOpen).toInteger()
]
]
]
patchDataAsync(uri, handleVentPatch, body, [device: device])
sendEvent(device, [name: 'percent-open', value: pOpen])
}
def handleVentPatch(resp, data) {
if (!isValidResponse(resp) || !data) { return }
traitExtract(data.device, resp.getJson(), 'percent-open', '%')
traitExtract(data.device, resp.getJson(), 'percent-open', 'level', '%')
}
def patchRoom(device, active) {
def roomId = device.currentValue('room-id')
if (!roomId || active == null) { return }
def isRoomActive = device.currentValue('room-active')
if (active == isRoomActive) { return }
def roomName = device.currentValue('room-name')
log("Setting active state to ${active} for '${roomName}'", 3)
def uri = BASE_URL + '/api/rooms/' + roomId
def body = [
data: [
type: 'rooms',
attributes: [
'active' : active == 'true'
]
]
]
patchDataAsync(uri, handleRoomPatch, body, [device: device])
}
def handleRoomPatch(resp, data) {
if (!isValidResponse(resp) || !data) { return }
traitExtract(data.device, resp.getJson(), 'active', 'room-active')
}
def thermostat1ChangeTemp(evt) {
log("thermostat changed temp to:${evt.value}", 2)
def temp = thermostat1.currentValue('temperature')
def coolingSetpoint = thermostat1.currentValue('coolingSetpoint') ?: 0
def heatingSetpoint = thermostat1.currentValue('heatingSetpoint') ?: 0
String hvacMode = calculateHvacMode(temp, coolingSetpoint, heatingSetpoint)
def thermostatSetpoint = getThermostatSetpoint(hvacMode)
if (isThermostatAboutToChangeState(hvacMode, thermostatSetpoint, temp)) {
runInMillis(3000, 'initializeRoomStates', [data: hvacMode])
}
}
def isThermostatAboutToChangeState(hvacMode, setpoint, temp) {
if (hvacMode == COOLING && temp + SETPOINT_OFFSET_C - VENT_PRE_ADJUSTMENT_THRESHOLD_C < setpoint) {
atomicState.tempDiffsInsideThreshold = false
return false
} else if (hvacMode == HEATING && temp - SETPOINT_OFFSET_C + VENT_PRE_ADJUSTMENT_THRESHOLD_C > setpoint) {
atomicState.tempDiffsInsideThreshold = false
return false
}
if (atomicState.tempDiffsInsideThreshold == true) {
return false
}
atomicState.tempDiffsInsideThreshold = true
log('Pre-adjusting vents for upcoming HVAC start. ' +
"[mode=${hvacMode}, setpoint=${setpoint}, temp=${temp}]", 3)
return true
}
def thermostat1ChangeStateHandler(evt) {
log("thermostat changed state to:${evt.value}", 3)
def hvacMode = evt.value
if (hvacMode == PENDING_COOL) {
hvacMode = COOLING
} else if (hvacMode == PENDING_HEAT) {
hvacMode = HEATING
}
switch (hvacMode) {
case COOLING:
case HEATING:
if (atomicState.thermostat1State) {
log("initializeRoomStates has already been executed (${evt.value})", 3)
return
}
atomicStateUpdate('thermostat1State', 'mode', hvacMode)
atomicStateUpdate('thermostat1State', 'startedRunning', now())
unschedule(initializeRoomStates)
runInMillis(1000, 'initializeRoomStates', [data: hvacMode]) // wait a bit since setpoint is set a few ms later
recordStartingTemperatures()
runEvery5Minutes('evaluateRebalancingVents')
runEvery30Minutes('reBalanceVents')
break
default:
unschedule(initializeRoomStates)
unschedule(finalizeRoomStates)
unschedule(evaluateRebalancingVents)
unschedule(reBalanceVents)
if (atomicState.thermostat1State) {
atomicStateUpdate('thermostat1State', 'finishedRunning', now())
def params = [
ventIdsByRoomId: atomicState.ventsByRoomId,
startedCycle: atomicState.thermostat1State?.startedCycle,
startedRunning: atomicState.thermostat1State?.startedRunning,
finishedRunning: atomicState.thermostat1State?.finishedRunning,
hvacMode: atomicState.thermostat1State?.mode
]
// Run a minute after to get more accurate temp readings
runInMillis(MILLIS_DELAY_TEMP_READINGS, 'finalizeRoomStates', [data: params])
//finalizeRoomStates(params)
atomicState.remove('thermostat1State')
}
break
}
}
// ### Dynamic Airflow Balancing ###
def reBalanceVents() {
log('Rebalancing Vents!!!', 3)
def params = [
ventIdsByRoomId: atomicState.ventsByRoomId,
startedCycle: atomicState.thermostat1State?.startedCycle,
startedRunning: atomicState.thermostat1State?.startedRunning,
finishedRunning: now(),
hvacMode: atomicState.thermostat1State?.mode
]
finalizeRoomStates(params)
initializeRoomStates(atomicState.thermostat1State?.mode)
}
def evaluateRebalancingVents() {
if (!atomicState.thermostat1State) { return }
def ventIdsByRoomId = atomicState.ventsByRoomId
String hvacMode = atomicState.thermostat1State?.mode
def setPoint = getThermostatSetpoint(hvacMode)
ventIdsByRoomId.each { roomId, ventIds ->
for (ventId in ventIds) {
try {
def vent = getChildDevice(ventId)
if (!vent) {
continue }
def isRoomActive = vent.currentValue('room-active') == 'true'
if (!isRoomActive) { continue }
def currPercentOpen = (vent.currentValue('percent-open') ?: 0).toInteger()
if (currPercentOpen < 50) { continue }
def roomTemp = getRoomTemp(vent)
def roomName = vent.currentValue('room-name') ?: ''
if (!hasRoomReachedSetpoint(hvacMode, setPoint, roomTemp, 0.5)) {
//log("Rebalancing Vents: Skipped as `${roomName}` hasn't reached setpoint", 3)
continue
}
log("Rebalancing Vents - '${roomName}' is at ${roomTemp} degrees, and has passed the ${setPoint} temp target", 3)
reBalanceVents()
break
} catch (err) {
log.error(err)
}
}
}
}
def finalizeRoomStates(data) {
if (!data.ventIdsByRoomId || !data.startedCycle || !data.startedRunning || !data.finishedRunning || !data.hvacMode) {
log.warn("Finalizing room states: wrong parameters (${data.ventIdsByRoomId}, ${data.startedCycle}, ${data.startedRunning}, ${data.finishedRunning}, ${data.hvacMode})")
return
}
log('Start - Finalizing room states', 3)
def totalRunningMinutes = (data.finishedRunning - data.startedRunning) / (1000 * 60)
def totalCycleMinutes = (data.finishedRunning - data.startedCycle) / (1000 * 60)
log("HVAC ran for ${totalRunningMinutes} minutes", 3)
atomicState.maxHvacRunningTime = roundBigDecimal(rollingAverage(atomicState.maxHvacRunningTime, totalRunningMinutes), 6)
if (totalCycleMinutes >= MIN_MINUTES_TO_SETPOINT) {
data.ventIdsByRoomId.each { roomId, ventIds ->
for (ventId in ventIds) {
def vent = getChildDevice(ventId)
if (!vent) {
log("Failed getting vent Id ${ventId}", 3)
break
}
def percentOpen = (vent.currentValue('percent-open') ?: 0).toInteger()
BigDecimal currentTemp = getRoomTemp(vent)
BigDecimal lastStartTemp = vent.currentValue('room-starting-temperature-c') ?: 0
def ratePropName = data.hvacMode == COOLING ? 'room-cooling-rate' : 'room-heating-rate'
BigDecimal currentRate = vent.currentValue(ratePropName) ?: 0
def newRate = calculateRoomChangeRate(lastStartTemp, currentTemp, totalCycleMinutes, percentOpen, currentRate)
def roomName = vent.currentValue('room-name') ?: ''
if (newRate <= 0) {
log("New rate for ${roomName}'s is ${newRate}", 3)
break
}
def rate = rollingAverage(currentRate, newRate, percentOpen / 100, 4)
sendEvent(vent, [name: ratePropName, value: rate])
log("Updating ${roomName}'s ${ratePropName} to ${roundBigDecimal(rate)}", 3)
}
}
} else {
log("Could not calculate room states as it ran for ${totalCycleMinutes} minutes and it needs to run for a minmum of ${MIN_MINUTES_TO_SETPOINT}", 3)
}
log('End - Finalizing room states', 3)
}
def recordStartingTemperatures() {
if (!atomicState.ventsByRoomId) { return }
atomicState.ventsByRoomId.each { roomId, ventIds ->
for (ventId in ventIds) {
try {
def vent = getChildDevice(ventId)
if (!vent) { break }
BigDecimal currentTemp = getRoomTemp(vent)
sendEvent(vent, [name: 'room-starting-temperature-c', value: currentTemp])
} catch (err) {
log.error(err)
}
}
}
}
def initializeRoomStates(hvacMode) {
if (!dabEnabled) { return }
log("Initializing room states - hvac mode: ${hvacMode})", 3)
if (!atomicState.ventsByRoomId) { return }
// Get the target temperature from the thermostat
BigDecimal setpoint = getThermostatSetpoint(hvacMode)
if (!setpoint) { return }
atomicStateUpdate('thermostat1State', 'startedCycle', now())
def rateAndTempPerVentId = getAttribsPerVentId(atomicState.ventsByRoomId, hvacMode)
// Get longest time to reach to target temp
def maxRunningTime = atomicState.maxHvacRunningTime ?: MAX_MINUTES_TO_SETPOINT
def longestTimeToGetToTarget = calculateLongestMinutesToTarget(
rateAndTempPerVentId, hvacMode, setpoint, maxRunningTime,
settings.thermostat1CloseInactiveRooms)
if (longestTimeToGetToTarget < 0) {
log("All vents already reached setpoint (setpoint: ${setpoint})", 3)
longestTimeToGetToTarget = maxRunningTime
}
if (longestTimeToGetToTarget == 0) {
log("Openning all vents (setpoint: ${setpoint})", 3)
openAllVents(atomicState.ventsByRoomId, MAX_PERCENTAGE_OPEN)
return
}
log("Initializing room states - setpoint: ${setpoint}, longestTimeToGetToTarget: ${roundBigDecimal(longestTimeToGetToTarget)}", 3)
def calculatedPercentOpenPerVentId = calculateOpenPercentageForAllVents(
rateAndTempPerVentId, hvacMode, setpoint, longestTimeToGetToTarget,
settings.thermostat1CloseInactiveRooms)
if (calculatedPercentOpenPerVentId.size() == 0) {
log("No vents are being changed (setpoint: ${setpoint})", 3)
return
}
// Ensure mimimum combined vent flow across vents
calculatedPercentOpenPerVentId = adjustVentOpeningsToEnsureMinimumAirflowTarget(rateAndTempPerVentId, hvacMode,
calculatedPercentOpenPerVentId, settings.thermostat1AdditionalStandardVents)
// Apply open percentage across all vents
calculatedPercentOpenPerVentId.each { ventId, percentOpen ->
def vent = getChildDevice(ventId)
if (vent) {
patchVent(vent, roundToNearestFifth(percentOpen))
}
}
}
def adjustVentOpeningsToEnsureMinimumAirflowTarget(rateAndTempPerVentId, hvacMode,
calculatedPercentOpenPerVentId, additionalStandardVents) {
def totalDeviceCount = additionalStandardVents > 0 ? additionalStandardVents : 0
def sumPercentages = totalDeviceCount * 50 // Assuming all standard vents are at 50%
calculatedPercentOpenPerVentId.each { ventId, percentOpen ->
totalDeviceCount++
if (percentOpen) {
sumPercentages += percentOpen
}
}
if (totalDeviceCount <= 0) {
log.warn('totalDeviceCount is zero')
return calculatedPercentOpenPerVentId
}
BigDecimal maxTemp = null
BigDecimal minTemp = null
rateAndTempPerVentId.each { ventId, stateVal ->
maxTemp = maxTemp == null || maxTemp < stateVal.temp ? stateVal.temp : maxTemp
minTemp = minTemp == null || minTemp > stateVal.temp ? stateVal.temp : minTemp
}
minTemp = minTemp - 0.1
maxTemp = maxTemp + 0.1
def combinedVentFlowPercentage = (100 * sumPercentages) / (totalDeviceCount * 100)
if (combinedVentFlowPercentage >= MIN_COMBINED_VENT_FLOW_PERCENTAGE) {
log("Combined vent flow percentage (${combinedVentFlowPercentage}) is greather than ${MIN_COMBINED_VENT_FLOW_PERCENTAGE}", 3)
return calculatedPercentOpenPerVentId
}
log("Combined Vent Flow Percentage (${combinedVentFlowPercentage}) is lower than ${MIN_COMBINED_VENT_FLOW_PERCENTAGE}%", 3)
def targetPercentSum = MIN_COMBINED_VENT_FLOW_PERCENTAGE * totalDeviceCount
def diffPercentageSum = targetPercentSum - sumPercentages
log("sumPercentages=${sumPercentages}, targetPercentSum=${targetPercentSum}, diffPercentageSum=${diffPercentageSum}", 2)
def iterations = 0
while (diffPercentageSum > 0 && iterations++ < MAX_ITERATIONS) {
for (item in rateAndTempPerVentId) {
def ventId = item.key
def stateVal = item.value
BigDecimal percentOpenVal = calculatedPercentOpenPerVentId?."${ventId}" ?: 0
if (percentOpenVal >= MAX_PERCENTAGE_OPEN) {
percentOpenVal = MAX_PERCENTAGE_OPEN
} else {
def proportion = hvacMode == COOLING ?
(stateVal.temp - minTemp) / (maxTemp - minTemp) :
(maxTemp - stateVal.temp) / (maxTemp - minTemp)
def increment = INREMENT_PERCENTAGE_WHEN_REACHING_VENT_FLOW_TAGET * proportion
percentOpenVal = percentOpenVal + increment
calculatedPercentOpenPerVentId."${ventId}" = percentOpenVal
log("Adjusting % open from ${roundBigDecimal(percentOpenVal - increment)}% to ${roundBigDecimal(percentOpenVal)}%", 2)
diffPercentageSum = diffPercentageSum - increment
if (diffPercentageSum <= 0) { break }
}
}
}
return calculatedPercentOpenPerVentId
}
def getAttribsPerVentId(ventIdsByRoomId, hvacMode) {
def rateAndTempPerVentId = [:]
ventIdsByRoomId.each { roomId, ventIds ->
for (ventId in ventIds) {
try {
def vent = getChildDevice(ventId)
if (!vent) { break }
def rate = hvacMode == COOLING ?
(vent.currentValue('room-cooling-rate') ?: 0) :
(vent.currentValue('room-heating-rate') ?: 0)
rate = rate ?: 0
def isRoomActive = vent.currentValue('room-active') == 'true'
rateAndTempPerVentId."${ventId}" = [
'rate': rate,
'temp': getRoomTemp(vent),
'active': isRoomActive,
'name': vent.currentValue('room-name') ?: ''
]
} catch (err) {
log.error(err)
}
}
}
return rateAndTempPerVentId
}
def calculateOpenPercentageForAllVents(rateAndTempPerVentId,
hvacMode, setpoint, longestTimeToGetToTarget, closeInactiveRooms = true) {
def calculatedPercentOpenPerVentId = [:]
rateAndTempPerVentId.each { ventId, stateVal ->
try {
def percentageOpen = MIN_PERCENTAGE_OPEN
if (closeInactiveRooms == true && !stateVal.active) {
log("Closing vent on inactive room: ${stateVal.name}", 3)
} else if (stateVal.rate < MIN_TEMP_CHANGE_RATE_C) {
log("Opening vents at max since change rate is lower than minumal: ${stateVal.name}", 3)
percentageOpen = MAX_PERCENTAGE_OPEN
} else {
percentageOpen = calculateVentOpenPercentange(stateVal.name, stateVal.temp, setpoint, hvacMode,
stateVal.rate, longestTimeToGetToTarget)
}
calculatedPercentOpenPerVentId."${ventId}" = percentageOpen
} catch (err) {
log.error(err)
}
}
return calculatedPercentOpenPerVentId
}
def calculateVentOpenPercentange(room, startTemp, setpoint, hvacMode, maxRate, longestTimeToGetToTarget) {
if (hasRoomReachedSetpoint(hvacMode, setpoint, startTemp)) {
def msgTemp = hvacMode == COOLING ? 'cooler' : 'warmer'
log("'${room}' is already ${msgTemp} (${startTemp}) than setpoint (${setpoint})", 3)
return MIN_PERCENTAGE_OPEN
}
BigDecimal percentageOpen = MAX_PERCENTAGE_OPEN
if (maxRate > 0 && longestTimeToGetToTarget > 0) {
def targetRate = Math.abs(setpoint - startTemp) / longestTimeToGetToTarget
percentageOpen = BASE_CONST * Math.exp((targetRate / maxRate) * EXP_CONST)
percentageOpen = roundBigDecimal(percentageOpen * 100)
if (percentageOpen < MIN_PERCENTAGE_OPEN) {
percentageOpen = MIN_PERCENTAGE_OPEN
} else if (percentageOpen > MAX_PERCENTAGE_OPEN) {
percentageOpen = MAX_PERCENTAGE_OPEN
}
log("changing percentage open for ${room} to ${percentageOpen}% (maxRate=${roundBigDecimal(maxRate)})", 3)
}
return percentageOpen
}
def calculateLongestMinutesToTarget(rateAndTempPerVentId, hvacMode, setpoint, maxRunningTime,
closeInactiveRooms = true) {
def longestTimeToGetToTarget = -1
rateAndTempPerVentId.each { ventId, stateVal ->
try {
def minutesToTarget = -1
def rate = stateVal.rate
if (closeInactiveRooms == true && !stateVal.active) {
log("'${stateVal.name}' is inactive", 3)
} else if (hasRoomReachedSetpoint(hvacMode, setpoint, stateVal.temp)) {
log("'${stateVal.name}' has already reached setpoint", 3)
} else if (rate > 0) {
minutesToTarget = Math.abs(setpoint - stateVal.temp) / rate
} else if (rate == 0) {
minutesToTarget = 0
}
if (minutesToTarget > maxRunningTime) {
log.warn("'${stateVal.name}' is estimated to take ${roundBigDecimal(minutesToTarget)} minutes " +
"to reach target temp, which is longer than the average ${roundBigDecimal(maxRunningTime)} minutes")
minutesToTarget = maxRunningTime
}
if (longestTimeToGetToTarget < minutesToTarget) {
longestTimeToGetToTarget = minutesToTarget
}
log("atomicState.ventsByRoomId: name=${stateVal.name}, roomTemp=${stateVal.temp}", 3)
} catch (err) {
log.error(err)
}
}
return longestTimeToGetToTarget
}
def calculateRoomChangeRate(lastStartTemp, currentTemp, totalMinutes, percentOpen, currentRate) {
if (totalMinutes < MIN_MINUTES_TO_SETPOINT) {
log('Insuficient number of minutes required to calculate change rate ' +
"(${totalMinutes} should be greather than ${MIN_MINUTES_TO_SETPOINT})", 3)
return -1
}
if (percentOpen <= MIN_PERCENTAGE_OPEN) {
log("Vent was opened less than ${MIN_PERCENTAGE_OPEN}% (${percentOpen}), therefore it is being excluded", 3)
return -1
}
BigDecimal diffTemps = Math.abs(lastStartTemp - currentTemp)
BigDecimal rate = diffTemps / totalMinutes
BigDecimal pOpen = percentOpen / 100
BigDecimal maxRate = Math.max(rate, currentRate)
BigDecimal approxEquivMaxRate = 0
if (maxRate != 0) {
approxEquivMaxRate = (rate / maxRate) / pOpen
}
if (approxEquivMaxRate > MAX_TEMP_CHANGE_RATE_C) {
def roundedRate = roundBigDecimal(approxEquivMaxRate)
log("Change rate (${roundedRate}) is greater than ${MAX_TEMP_CHANGE_RATE_C}, therefore it is being excluded", 3)
return -1
} else if (approxEquivMaxRate < MIN_TEMP_CHANGE_RATE_C) {
def roundedRate = roundBigDecimal(approxEquivMaxRate)
log("Change rate (${roundedRate}) is lower than ${MIN_TEMP_CHANGE_RATE_C}, therefore it is being excluded", 3)
return -1
}
return approxEquivMaxRate
}
I figured you’d come up with some awesome solution in a few weeks or months and I’d use two Flair accounts in the meantime. NBD.
Except you did it in a few hours instead. Kudos!
The new version appears to work great at identifying the right devices and continuing to populate them as before. All looks really good so far. I see just one (very minor) issue with some of the logging that appears to have no functional impact. In case it matters to you, here it is:
Thanks again.
Thanks @mluck, I'm glad it's working well so far. As I said in the past, knowing that my work is helping people somehow is what drives me to do this kind of work :). yeah, that issue seems like something minor and unrelated to the proposed changes. It appears like it's failing to get labels from these thermostats, and it's only for logging purposes, there's nothing functional that depends on those labels.
In using this automation (which continues to work great btw), I'm facing an interesting challenge that is more about climate control than home automation. But I thought folks are here might have experiences worth sharing. Here's the situation.
In our primary home, we use Hubitat and the Flair integration to, among other things, essentially shut down rooms (make them inactive). If the door to a bedroom (we have 4 kids in college) is closed for an extended period, Hubitat assumes no one is using the bedroom for awhile, which flags the room as inactive. So the vents close. The room gets hot in the summer and cold in the winter, but we don't care b/c it's unused. Our HVAC (and its bill) are a little happier for it. And when the kid comes home from college, we simply open the bedroom door, and everything returns to normal.
This works great! Except...
I tried to do the same thing in our vacation home, which is in the southeast U.S. in a climate that is often but not always hot and humid. What I realized quickly is if I make a room similarly inactive, I have the problem of accumulated humidity (and, I presume, the specter of mold growth) in a room without circulation. That seems to be way more problematic than a hot unused room in the summer or a cold unused room in the winter.
How do folks think about this problem?
@ljbotero, is your "dynamic airflow balancing" logic driven by the temperature or the humidity? Or both?
EDIT: Jaime, wonder if you’re familiar with Flair’s advanced circulation mode? Does your integration factor for this functionality somehow, where the open/close behavior is driven in part by fan behavior in addition to my question about humidity above? It would be trivial for me to replace this circulation mode using RM but I don’t want to create a conflict if your dynamic airflow balancing already does one or both of these things. Thanks
Hi @mluck, thanks for your thoughtful post! Sorry for the late reply—I promise I wasn’t ignoring you; your question was just marinating in my brain for a bit. Glad to hear the automation is still working great for you!
To answer your question, the "dynamic airflow balancing" logic in my integration only accounts for temperature, not humidity. So, while it’s pretty good at keeping rooms comfy (or letting them heat up when empty), it won’t address humidity buildup on its own. Given your situation in the southeast U.S., humidity is definitely a worthy adversary—no one wants their vacation home doubling as a terrarium!
One approach you might consider is creating an automation rule in Hubitat to partially open the vents (maybe around 50%) whenever the AC fan is running for circulation. This would allow for some airflow, hopefully keeping the humidity gremlins at bay. It won’t be as elegant as Flair’s advanced circulation mode but should help maintain air circulation and stave off mold growth. Plus, it keeps things from getting too stuffy.
Hope that helps, and feel free to share any insights or further thoughts. Always happy to brainstorm together!
Jaime, no worries, and thanks for the response.
It's simple to create the automation to open the vents a little when an inactive room gets humid -- that's what I was thinking too. However, I wasn't sure about whether I'd create some kind of race condition or other conflict with the dynamic airflow balancing since it's a bit of a black box to me.
For example, if I create a rule in RM5.1 like the below, will it play nicely with your automation?
Required Expression: Room Inactive
Trigger: Humidity > benchmark-humidity-for-rest-of-interior
Action: Set Vent Level to 50%
[should probably also have the reverse trigger -- if the room is made inactive when the humidity is already high]
I am trying to wrap my head around the Flair vents and your integration..... excuse me if this has already been addressed.
First, I am thinking about getting some Flair vents for my home.
Second, I DO want to use this integration as there are SO MANY responses to this topic and you seem to really be active.... thank you!
Third, is a Flair bridge necessary? I gather it is because that ties all the vents to the cloud?
Fourth, can I use my existing temperature sensors (Zooz ZSE44 temp/humidity sensors), that I am using to control my HVAC? so I don't need the Flair sensors (pucks)?
Fifth, I also gather that there is no local control... only cloud based (YECH)!
I apologize in advance if this has already been answered, I have read alot of posts and it is unclear to me.
Anybody???
Hey pomonabill220,
Great questions, here's some answers I hope can help:
Thinking About Flair Vents? Personally, I went with Flair because it was the best solution I found to tackle my problem of wildly different room temperatures (sometimes as much as 10 degrees Fahrenheit apart!). It’s been great for balancing the temperature and making the house more comfortable overall.
Integration Activity: Really appreciate your interest in my integration. I’m just a home automation geek who enjoys tinkering and supporting others on this journey!
Flair Bridge: I don’t actually use a Bridge myself, but from what I know, it can help with better connectivity for the Pucks to your home Wi-Fi. I rely on Wi-Fi repeaters around the house, so connectivity hasn’t been an issue for me.
Using Your Existing Sensors: Unfortunately, the Pucks are required since they act as the go-between for the vents and your Wi-Fi. But once you’ve got them in place, you should be good to go! As far as your Zooz ZSE44 sensors, I believe you should be able to use them from the app as thermostats.
Cloud Control: Yep, no getting around the cloud part (I know, “YECH” indeed!). It’s a bit of a trade-off, but it works well enough even if we all dream about full local control someday.
Hope this helps clear things up! Feel free to reach out if you’ve got more questions— just a heads up, I’m usually pretty busy during the week, so I tend to catch up on these posts over the weekend.
This is a little confusing to me.
Doesn't the bridge also act as a "go between" the vents and my wifi?
So, correct me if I am wrong....
Do I need at least a puck to connect the vent(s) to wifi?
If so, can I use one puck to connect all my vents to wifi?
Using your integration with my ZSE44 sensors, would I still need the puck, or is the puck only used for vent-to-wifi connection?
Will the vents connect directly to my wifi? (I gather not and a puck is needed for connectivity).
Thank you for the info! AND your integration to HE!!! Seems like it has matured ALOT and is still growing!
Forgot to add... I have about 4 zones that I would like to control and two zones that will be manually controlled by the dampers on the vents.... unused rooms and a bathroom.
The vents do not connect directly to the WiFi they need a Puck, not sure about the bridge, but my understanding is that the bridge can replace pucks. So from your list above #1 is not a valid configuration, not sure about #3 and #4. Vents do not have a Wi-Fi chip, I believe they connect to the pucks via some other radio signal frequency.
Thank you for the info!
This pretty much clears it up.
I will ask Flair if a bridge alone, will work without the puck, since I have my own temperature sensors and am going to use your integration.
That means that Flair designed their system that way to force you to buy the puck or bridge...
@pomonabill220 sorry for my slow response — busy at work these last few days. But I’ll pile on what @ljbotero said and add my experience.
Flair has been great for both of our homes and this integration, after a short period of working out the bumps, works great too. The only caveat would be if you live in a humid area there are some workarounds you’ll need — but I’m happy to share my lessons learned and solutions implemented if that applies to you.
On bridge versus puck, I can help explain as I’ve used both. First, as Jaime points out, you can’t connect Flair vents directly to the cloud; you need a local Flair hub to intermediate the vents to Flair’s cloud API. The puck was the first such hub—it connects to your network only via WiFi and supports a fairly small number of vents. Flair claims you’d need one puck per room, but my experience is that you can use a puck for every 2-3 rooms if the rooms are physically proximate. I don’t recall the max of vents per puck but it’s a small single digit number. Another reason Flair says you need one puck per room is because the puck itself operates as the thermostat for each room (but of course with Hubitat, you can use Hubitat temp sensors, so this requirement is relaxed if you’re integrating with Hubitat).
The second hub is the Bridge. The bridge has two major advantages over the puck for my use case: it can handle many more vents and you can use Ethernet/wired instead of WiFi. I have about 20 vents on a single bridge in my primary house. Works great. And since it’s wired, I’ve never had a blip (though some would argue WiFi is just as reliable. YMMV).
By the way, I’m not sure it’s accurate to say Flair designed their solution to force you to buy the puck or bridge. I mean, you’re right in the sense that at least one of those is required to operate vents. But the design choice as I understand it was really about burdening every vent with the cloud connectivity services (bigger, more battery use, and more costly), versus keep the vent skinny and put the connectivity layer in a shared device (hub). I won’t even pretend to understand that trade off but I suspect the considerations were not (entirely) about forcing consumers to buy more stuff. Remember they were second to market, trying to dislodge Keen Vents.
Anyway, sorry for the long note. Hope this helps. Feel free to respond with further questions or stuff I missed. I’m a big fan of Flair. They’ve improved the comfort of our house and saved us money.
WOW! Thank you SO much for all the detail!!!
It is clear to me that I will get a bridge for all my vents as I don't need a stat.
I guess Flair wanted to keep network load down and that is why either a bridge or (and) a puck can be used.
It was a little unfair for me to assume Flair was trying to get the most out of people by making them buy a hub/bridge... they were just trying to do all the processing local on the bridge/hub.
Makes more sense to me now.
I appreciate the long note! This is the detail that I have been wanting, and your note answered my confusion!
NOW off to Flair to get a bridge and some vents!!
Well I did it!
I went to Flair to order my vents and bridge, AND they are having their Black Friday sale!!!
20% off!!!!
Of course I used it and got my vents and bridge!
Should be here Monday as I ordered off Amazon using prime, so the shipping will be faster and the same prices.
It will be interesting to use this integration!