/** * Hubitat Flair Vents Integration * Version 0.233 * * 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 import groovy.json.JsonOutput // ------------------------------ // Constants and Configuration // ------------------------------ // Base URL for Flair API endpoints. @Field static final String BASE_URL = 'https://api.flair.co' // Instance-based cache durations (reduced from 60s to 30s for better responsiveness) @Field static final Long ROOM_CACHE_DURATION_MS = 30000 // 30 second cache duration @Field static final Long DEVICE_CACHE_DURATION_MS = 30000 // 30 second cache duration for device readings @Field static final Integer MAX_CACHE_SIZE = 50 // Maximum cache entries per instance // Content-Type header for API requests. @Field static final String CONTENT_TYPE = 'application/json' // HVAC mode constants. @Field static final String COOLING = 'cooling' @Field static final String HEATING = 'heating' // Pending HVAC mode values returned by the thermostat. @Field static final String PENDING_COOL = 'pending cool' @Field static final String PENDING_HEAT = 'pending heat' // Delay (in milliseconds) before re-reading temperature after an HVAC event. @Field static final Integer TEMP_READINGS_DELAY_MS = 30000 // 30 seconds // Minimum and maximum vent open percentages (in %). @Field static final BigDecimal MIN_PERCENTAGE_OPEN = 0.0 @Field static final BigDecimal MAX_PERCENTAGE_OPEN = 100.0 // Threshold (in °C) used to trigger a pre-adjustment of vent settings before the setpoint is reached. @Field static final BigDecimal VENT_PRE_ADJUST_THRESHOLD = 0.2 // HVAC timing constants. @Field static final BigDecimal MAX_MINUTES_TO_SETPOINT = 60 // Maximum minutes to reach setpoint. @Field static final BigDecimal MIN_MINUTES_TO_SETPOINT = 1 // Minimum minutes required to compute temperature change rate. // Temperature offset (in °C) applied to thermostat setpoints. @Field static final BigDecimal SETPOINT_OFFSET = 0.7 // Acceptable temperature change rate limits (in °C per minute). @Field static final BigDecimal MAX_TEMP_CHANGE_RATE = 1.5 @Field static final BigDecimal MIN_TEMP_CHANGE_RATE = 0.001 // Temperature sensor accuracy and noise filtering @Field static final BigDecimal TEMP_SENSOR_ACCURACY = 0.5 // ±0.5°C typical sensor accuracy @Field static final BigDecimal MIN_DETECTABLE_TEMP_CHANGE = 0.1 // Minimum change to consider real @Field static final Integer MIN_RUNTIME_FOR_RATE_CALC = 5 // Minimum minutes before calculating rate // Minimum combined vent airflow percentage across all vents (to ensure proper HVAC operation). @Field static final BigDecimal MIN_COMBINED_VENT_FLOW = 30.0 // INCREMENT_PERCENTAGE is used as a base multiplier when incrementally increasing vent open percentages // during airflow adjustments. For example, if the computed proportion for a vent is 0.5, // then the vent’s open percentage will be increased by 1.5 * 0.5 = 0.75% in that iteration. // This increment is applied repeatedly until the total combined airflow meets the minimum target. @Field static final BigDecimal INCREMENT_PERCENTAGE = 1.5 // Maximum number of standard (non-Flair) vents allowed. @Field static final Integer MAX_STANDARD_VENTS = 15 // Maximum iterations for the while-loop when adjusting vent openings. @Field static final Integer MAX_ITERATIONS = 500 // HTTP timeout for API requests (in seconds). @Field static final Integer HTTP_TIMEOUT_SECS = 5 // Default opening percentage for standard (non-Flair) vents (in %). @Field static final Integer STANDARD_VENT_DEFAULT_OPEN = 50 // Temperature tolerance for rebalancing vent operations (in °C). @Field static final BigDecimal REBALANCING_TOLERANCE = 0.5 // Temperature boundary adjustment for airflow calculations (in °C). @Field static final BigDecimal TEMP_BOUNDARY_ADJUSTMENT = 0.1 // Thermostat hysteresis to prevent cycling (in °C). @Field static final BigDecimal THERMOSTAT_HYSTERESIS = 0.6 // ~1°F // Polling intervals based on HVAC state (in minutes). @Field static final Integer POLLING_INTERVAL_ACTIVE = 3 // When HVAC is running @Field static final Integer POLLING_INTERVAL_IDLE = 10 // When HVAC is idle // Delay before initializing room states after certain events (in milliseconds). @Field static final Integer INITIALIZATION_DELAY_MS = 3000 // Delay after a thermostat state change before reinitializing (in milliseconds). @Field static final Integer POST_STATE_CHANGE_DELAY_MS = 1000 // Simple API throttling delay to prevent overwhelming the Flair API (in milliseconds). @Field static final Integer API_CALL_DELAY_MS = 1000 * 3 // Maximum concurrent HTTP requests to prevent API overload. @Field static final Integer MAX_CONCURRENT_REQUESTS = 8 // Maximum number of retry attempts for async API calls. @Field static final Integer MAX_API_RETRY_ATTEMPTS = 5 // ------------------------------ // End Constants // ------------------------------ 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: false ) preferences { page(name: 'mainPage') page(name: 'efficiencyDataPage') } def mainPage() { dynamicPage(name: 'mainPage', title: 'Setup', install: true, uninstall: true) { section('OAuth Setup') { input name: 'clientId', type: 'text', title: 'Client Id (OAuth 2.0)', required: true, submitOnChange: true input name: 'clientSecret', type: 'password', title: 'Client Secret OAuth 2.0', required: true, submitOnChange: true paragraph 'Obtain your client Id and secret from ' + "here" if (settings?.clientId && settings?.clientSecret) { if (!state.flairAccessToken && !state.authInProgress) { state.authInProgress = true state.remove('authError') // Clear any previous error when starting new auth runIn(2, 'autoAuthenticate') } if (state.flairAccessToken && !state.authError) { paragraph "✓ Authenticated successfully" } else if (state.authError && !state.authInProgress) { section { paragraph "${state.authError}" input name: 'retryAuth', type: 'button', title: 'Retry Authentication', submitOnChange: true paragraph "If authentication continues to fail, verify your credentials are correct and try again." } } else if (state.authInProgress) { paragraph "⏳ Authenticating... Please wait." paragraph "This may take 10-15 seconds. The page will refresh automatically when complete." } else { paragraph "Ready to authenticate..." } } } if (state.flairAccessToken) { section('Device Discovery') { input name: 'discoverDevices', type: 'button', title: 'Discover', submitOnChange: true input name: 'structureId', type: 'text', title: 'Home Id (SID)', required: false, submitOnChange: true } listDiscoveredDevices() section('

Dynamic Airflow Balancing

') { input name: 'dabEnabled', type: 'bool', title: 'Use Dynamic Airflow Balancing', defaultValue: false, submitOnChange: true if (dabEnabled) { input name: 'thermostat1', type: 'capability.thermostat', title: 'Choose Thermostat for Vents', multiple: false, required: true input name: 'thermostat1TempUnit', type: 'enum', title: 'Units used by Thermostat', defaultValue: 2, options: [1: 'Celsius (°C)', 2: 'Fahrenheit (°F)'] input name: 'thermostat1AdditionalStandardVents', type: 'number', title: 'Count of conventional Vents', defaultValue: 0, submitOnChange: true paragraph 'Enter the total number of standard (non-Flair) adjustable vents in the home associated ' + 'with the chosen thermostat, excluding Flair vents. This ensures the combined airflow does not drop ' + 'below a specified percent to prevent HVAC issues.' input name: 'thermostat1CloseInactiveRooms', type: 'bool', title: 'Close vents on inactive rooms', defaultValue: true, submitOnChange: true if (settings.thermostat1AdditionalStandardVents < 0) { app.updateSetting('thermostat1AdditionalStandardVents', 0) } else if (settings.thermostat1AdditionalStandardVents > MAX_STANDARD_VENTS) { app.updateSetting('thermostat1AdditionalStandardVents', MAX_STANDARD_VENTS) } if (!getThermostat1Mode() || getThermostat1Mode() == 'auto') { patchStructureData([mode: 'manual']) atomicState?.putAt('thermostat1Mode', 'manual') } // Efficiency Data Management Link section { href name: 'efficiencyDataLink', title: '🔄 Backup & Restore Efficiency Data', description: 'Save your learned room efficiency data to restore after app updates', page: 'efficiencyDataPage' // Show current status summary def vents = getChildDevices().findAll { it.hasAttribute('percent-open') } if (vents.size() > 0) { def roomsWithData = vents.findAll { (it.currentValue('room-cooling-rate') ?: 0) > 0 || (it.currentValue('room-heating-rate') ?: 0) > 0 } paragraph "Current Status: ${roomsWithData.size()} of ${vents.size()} rooms have learned efficiency data" } } } // Only show vents in DAB section, not pucks def vents = getChildDevices().findAll { it.hasAttribute('percent-open') } for (child in vents) { input name: "thermostat${child.getId()}", type: 'capability.temperatureMeasurement', title: "Choose Thermostat for ${child.getLabel()} (Optional)", multiple: false, required: false } } section('Vent Options') { input name: 'ventGranularity', type: 'enum', title: 'Vent Adjustment Granularity (in %)', options: ['5':'5%', '10':'10%', '25':'25%', '50':'50%', '100':'100%'], defaultValue: '5', required: true, submitOnChange: true paragraph 'Select how granular the vent adjustments should be. For example, if you choose 50%, vents ' + 'will only adjust to 0%, 50%, or 100%. Lower percentages allow for finer control, but may ' + 'result in more frequent adjustments (which could affect battery-powered vents).' } } else { section { paragraph 'Device discovery button is hidden until authorization is completed.' } } section('Debug Options') { 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 } } } // ------------------------------ // List and Device Discovery Functions // ------------------------------ def listDiscoveredDevices() { final String acBoosterLink = 'https://amzn.to/3QwVGbs' def children = getChildDevices() // Filter only vents by checking for percent-open attribute which pucks don't have def vents = children.findAll { it.hasAttribute('percent-open') } BigDecimal maxCoolEfficiency = 0 BigDecimal maxHeatEfficiency = 0 vents.each { vent -> def coolRate = vent.currentValue('room-cooling-rate') ?: 0 def heatRate = vent.currentValue('room-heating-rate') ?: 0 maxCoolEfficiency = maxCoolEfficiency.max(coolRate) maxHeatEfficiency = maxHeatEfficiency.max(heatRate) } def builder = new StringBuilder() builder << ''' ''' vents.each { vent -> def coolRate = vent.currentValue('room-cooling-rate') ?: 0 def heatRate = vent.currentValue('room-heating-rate') ?: 0 def coolEfficiency = maxCoolEfficiency > 0 ? roundBigDecimal((coolRate / maxCoolEfficiency) * 100, 0) : 0 def heatEfficiency = maxHeatEfficiency > 0 ? roundBigDecimal((heatRate / maxHeatEfficiency) * 100, 0) : 0 def warnMsg = 'This vent is very inefficient, consider installing an HVAC booster. Click for a recommendation.' def coolClass = coolEfficiency <= 25 ? 'danger-message' : (coolEfficiency <= 45 ? 'warning-message' : '') def heatClass = heatEfficiency <= 25 ? 'danger-message' : (heatEfficiency <= 45 ? 'warning-message' : '') def coolHtml = coolEfficiency <= 45 ? "${coolEfficiency}%" : "${coolEfficiency}%" def heatHtml = heatEfficiency <= 45 ? "${heatEfficiency}%" : "${heatEfficiency}%" builder << "" } builder << '
Device Cooling Efficiency Heating Efficiency
${vent.getLabel()}${coolHtml}${heatHtml}
' section { paragraph 'Discovered devices:' paragraph builder.toString() } } 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() // Initialize instance-based caches initializeInstanceCaches() // Clean up any existing BigDecimal precision issues cleanupExistingDecimalPrecision() // Check if we need to auto-authenticate on startup if (settings?.clientId && settings?.clientSecret) { if (!state.flairAccessToken) { log 'No access token found on initialization, auto-authenticating...', 2 autoAuthenticate() } else { // Token exists, ensure hourly refresh is scheduled unschedule(login) runEvery1Hour(login) } } if (settings.thermostat1) { subscribe(settings.thermostat1, 'thermostatOperatingState', thermostat1ChangeStateHandler) subscribe(settings.thermostat1, 'temperature', thermostat1ChangeTemp) def temp = settings.thermostat1?.currentValue('temperature') ?: 0 def coolingSetpoint = settings.thermostat1?.currentValue('coolingSetpoint') ?: 0 def heatingSetpoint = settings.thermostat1?.currentValue('heatingSetpoint') ?: 0 String hvacMode = calculateHvacMode(temp, coolingSetpoint, heatingSetpoint) runInMillis(INITIALIZATION_DELAY_MS, 'initializeRoomStates', [data: hvacMode]) // Set initial polling based on current thermostat state def currentThermostatState = settings.thermostat1?.currentValue('thermostatOperatingState') def initialInterval = (currentThermostatState in ['cooling', 'heating']) ? POLLING_INTERVAL_ACTIVE : POLLING_INTERVAL_IDLE log "Setting initial polling interval to ${initialInterval} minutes based on thermostat state: ${currentThermostatState}", 3 updateDevicePollingInterval(initialInterval) } // Schedule periodic cleanup of instance caches and pending requests runEvery5Minutes('cleanupPendingRequests') runEvery10Minutes('clearRoomCache') runEvery5Minutes('clearDeviceCache') } // ------------------------------ // Helper Functions // ------------------------------ private openAllVents(Map ventIdsByRoomId, int percentOpen) { ventIdsByRoomId.each { roomId, ventIds -> ventIds.each { ventId -> def vent = getChildDevice(ventId) if (vent) { patchVent(vent, percentOpen) } } } } private BigDecimal getRoomTemp(def vent) { def ventId = vent.getId() def roomName = vent.currentValue('room-name') ?: 'Unknown' def tempDevice = settings."thermostat${ventId}" if (tempDevice) { def temp = tempDevice.currentValue('temperature') if (temp == null) { log "WARNING: Temperature device ${tempDevice?.getLabel() ?: 'Unknown'} for room '${roomName}' is not reporting temperature!", 2 // Fall back to room temperature def roomTemp = vent.currentValue('room-current-temperature-c') ?: 0 log "Falling back to room temperature for '${roomName}': ${roomTemp}°C", 2 return roomTemp } if (settings.thermostat1TempUnit == '2') { temp = convertFahrenheitToCentigrade(temp) } log "Got temp from ${tempDevice?.getLabel() ?: 'Unknown'} for '${roomName}': ${temp}°C", 2 return temp } def roomTemp = vent.currentValue('room-current-temperature-c') if (roomTemp == null) { log "ERROR: No temperature available for room '${roomName}' - neither from Puck nor from room API!", 2 return 0 } log "Using room temperature for '${roomName}': ${roomTemp}°C", 2 return roomTemp } private atomicStateUpdate(String stateKey, String key, value) { atomicState.updateMapValue(stateKey, key, value) log "atomicStateUpdate(${stateKey}, ${key}, ${value})", 1 } def getThermostatSetpoint(String hvacMode) { BigDecimal setpoint = hvacMode == COOLING ? ((settings?.thermostat1?.currentValue('coolingSetpoint') ?: 0) - SETPOINT_OFFSET) : ((settings?.thermostat1?.currentValue('heatingSetpoint') ?: 0) + SETPOINT_OFFSET) setpoint = setpoint ?: settings?.thermostat1?.currentValue('thermostatSetpoint') if (!setpoint) { logError 'Thermostat has no setpoint property, please choose a valid thermostat' return setpoint } if (settings.thermostat1TempUnit == '2') { setpoint = convertFahrenheitToCentigrade(setpoint) } return setpoint } def roundBigDecimal(BigDecimal number, int scale = 3) { number.setScale(scale, BigDecimal.ROUND_HALF_UP) } // Function to round values to specific decimal places for JSON export def roundToDecimalPlaces(def value, int decimalPlaces) { if (value == null || value == 0) return 0 try { // Convert to double def doubleValue = value as Double // Use basic math to round to decimal places - this definitely works in Hubitat def multiplier = Math.pow(10, decimalPlaces) def rounded = Math.round(doubleValue * multiplier) / multiplier // Return as Double to ensure proper JSON serialization return rounded as Double } catch (Exception e) { log "Error rounding value ${value}: ${e.message}", 2 return 0 } } // Function to clean decimal values for JSON serialization // Enhanced version to handle Hubitat's BigDecimal precision issues def cleanDecimalForJson(def value) { if (value == null || value == 0) return 0 try { // Convert to String first to break BigDecimal precision chain def stringValue = value.toString() def doubleValue = Double.parseDouble(stringValue) // Handle edge cases if (!Double.isFinite(doubleValue)) { return 0.0d } // Apply aggressive rounding to exactly 10 decimal places def multiplier = 1000000000.0d // 10^9 for 10 decimal places def rounded = Math.round(doubleValue * multiplier) / multiplier // Ensure we return a clean Double, not BigDecimal return Double.valueOf(rounded) } catch (Exception e) { log "Error cleaning decimal for JSON: ${e.message}", 2 return 0.0d } } // Modified rounding function that uses the user-configured granularity. // It has been renamed to roundToNearestMultiple since it rounds a value to the nearest multiple of a given granularity. int roundToNearestMultiple(BigDecimal num) { int granularity = settings.ventGranularity ? settings.ventGranularity.toInteger() : 5 return (int)(Math.round(num / granularity) * granularity) } def convertFahrenheitToCentigrade(BigDecimal tempValue) { (tempValue - 32) * (5 / 9) } def rollingAverage(BigDecimal currentAverage, BigDecimal newNumber, BigDecimal weight = 1, int numEntries = 10) { if (numEntries <= 0) { return 0 } BigDecimal base = (currentAverage ?: 0) == 0 ? newNumber : currentAverage BigDecimal sum = base * (numEntries - 1) def weightedValue = (newNumber - base) * weight def numberToAdd = base + weightedValue sum += numberToAdd return sum / numEntries } def hasRoomReachedSetpoint(String hvacMode, BigDecimal setpoint, BigDecimal currentTemp, BigDecimal offset = 0) { (hvacMode == COOLING && currentTemp <= setpoint - offset) || (hvacMode == HEATING && currentTemp >= setpoint + offset) } def calculateHvacMode(BigDecimal temp, BigDecimal coolingSetpoint, BigDecimal 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) deleteChildDevice(it.getDeviceNetworkId()) } } // Only log messages if their level is greater than or equal to the debug level setting. private log(String msg, int level = 3) { def settingsLevel = (settings?.debugLevel as Integer) ?: 0 if (settingsLevel == 0) { return } if (level >= settingsLevel) { log.debug msg } } // Safe getter for thermostat mode from atomic state private getThermostat1Mode() { return atomicState?.thermostat1Mode } // Safe sendEvent wrapper for test compatibility private safeSendEvent(device, Map eventData) { try { sendEvent(device, eventData) } catch (Exception e) { // In test environment, sendEvent might not be available log "Warning: Could not send event ${eventData} to device ${device}: ${e.message}", 2 } } // Clean up existing BigDecimal precision issues in stored data def cleanupExistingDecimalPrecision() { try { log "Cleaning up existing decimal precision issues", 2 // Clean up global rates in atomicState if (atomicState.maxCoolingRate) { def cleanedCooling = cleanDecimalForJson(atomicState.maxCoolingRate) if (cleanedCooling != atomicState.maxCoolingRate) { atomicState.maxCoolingRate = cleanedCooling log "Cleaned maxCoolingRate: ${atomicState.maxCoolingRate}", 2 } } if (atomicState.maxHeatingRate) { def cleanedHeating = cleanDecimalForJson(atomicState.maxHeatingRate) if (cleanedHeating != atomicState.maxHeatingRate) { atomicState.maxHeatingRate = cleanedHeating log "Cleaned maxHeatingRate: ${atomicState.maxHeatingRate}", 2 } } // Clean up device attributes for existing vents def devicesUpdated = 0 getChildDevices().findAll { it.hasAttribute('percent-open') }.each { device -> try { def coolingRate = device.currentValue('room-cooling-rate') def heatingRate = device.currentValue('room-heating-rate') if (coolingRate && coolingRate != 0) { def cleanedCooling = cleanDecimalForJson(coolingRate) if (cleanedCooling != coolingRate) { sendEvent(device, [name: 'room-cooling-rate', value: cleanedCooling]) devicesUpdated++ } } if (heatingRate && heatingRate != 0) { def cleanedHeating = cleanDecimalForJson(heatingRate) if (cleanedHeating != heatingRate) { sendEvent(device, [name: 'room-heating-rate', value: cleanedHeating]) devicesUpdated++ } } } catch (Exception e) { log "Error cleaning device precision for ${device.getLabel()}: ${e.message}", 2 } } if (devicesUpdated > 0) { log "Updated decimal precision for ${devicesUpdated} device attributes", 2 } } catch (Exception e) { log "Error during decimal precision cleanup: ${e.message}", 2 } } // ------------------------------ // Instance-Based Caching Infrastructure // ------------------------------ // Get current time - now() is always available in Hubitat private getCurrentTime() { return now() } // Get unique instance identifier private getInstanceId() { try { // Try to use app ID if available (production) def appId = app?.getId()?.toString() if (appId) { return appId } } catch (Exception e) { // Expected in test environment } // For test environment, use current time as unique identifier // This provides reasonable uniqueness for test instances return "test-${now()}" } // Initialize instance-level cache variables private initializeInstanceCaches() { def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" if (!state."${cacheKey}_initialized") { state."${cacheKey}_roomCache" = [:] state."${cacheKey}_roomCacheTimestamps" = [:] state."${cacheKey}_deviceCache" = [:] state."${cacheKey}_deviceCacheTimestamps" = [:] state."${cacheKey}_pendingRoomRequests" = [:] state."${cacheKey}_pendingDeviceRequests" = [:] state."${cacheKey}_initialized" = true log "Initialized instance-based caches for instance ${instanceId}", 3 } } // Room data caching methods def cacheRoomData(String roomId, Map roomData) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def roomCache = state."${cacheKey}_roomCache" def roomCacheTimestamps = state."${cacheKey}_roomCacheTimestamps" // Implement LRU cache with max size if (roomCache.size() >= MAX_CACHE_SIZE) { // Remove least recently used entry (oldest access time) def lruKey = null def oldestAccessTime = Long.MAX_VALUE roomCacheTimestamps.each { key, timestamp -> if (timestamp < oldestAccessTime) { oldestAccessTime = timestamp lruKey = key } } if (lruKey) { roomCache.remove(lruKey) roomCacheTimestamps.remove(lruKey) log "Evicted LRU cache entry: ${lruKey}", 4 } } roomCache[roomId] = roomData roomCacheTimestamps[roomId] = getCurrentTime() } def getCachedRoomData(String roomId) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def roomCache = state."${cacheKey}_roomCache" def roomCacheTimestamps = state."${cacheKey}_roomCacheTimestamps" def timestamp = roomCacheTimestamps[roomId] if (!timestamp) return null if (isCacheExpired(roomId)) { roomCache.remove(roomId) roomCacheTimestamps.remove(roomId) return null } // Update access time for LRU tracking when item is accessed roomCacheTimestamps[roomId] = getCurrentTime() return roomCache[roomId] } def getRoomCacheSize() { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def roomCache = state."${cacheKey}_roomCache" return roomCache.size() } // Test helper method def cacheRoomDataWithTimestamp(String roomId, Map roomData, Long timestamp) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def roomCache = state."${cacheKey}_roomCache" def roomCacheTimestamps = state."${cacheKey}_roomCacheTimestamps" roomCache[roomId] = roomData roomCacheTimestamps[roomId] = timestamp } def isCacheExpired(String roomId) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def roomCacheTimestamps = state."${cacheKey}_roomCacheTimestamps" def timestamp = roomCacheTimestamps[roomId] if (!timestamp) return true return (getCurrentTime() - timestamp) > ROOM_CACHE_DURATION_MS } // Pending request tracking def markRequestPending(String requestId) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def pendingRequests = state."${cacheKey}_pendingRoomRequests" pendingRequests[requestId] = true } def isRequestPending(String requestId) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def pendingRequests = state."${cacheKey}_pendingRoomRequests" return pendingRequests[requestId] == true } def clearPendingRequest(String requestId) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def pendingRequests = state."${cacheKey}_pendingRoomRequests" pendingRequests[requestId] = false } // Device reading caching methods def cacheDeviceReading(String deviceKey, Map deviceData) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def deviceCache = state."${cacheKey}_deviceCache" def deviceCacheTimestamps = state."${cacheKey}_deviceCacheTimestamps" // Implement LRU cache with max size if (deviceCache.size() >= MAX_CACHE_SIZE) { // Remove least recently used entry (oldest access time) def lruKey = null def oldestAccessTime = Long.MAX_VALUE deviceCacheTimestamps.each { key, timestamp -> if (timestamp < oldestAccessTime) { oldestAccessTime = timestamp lruKey = key } } if (lruKey) { deviceCache.remove(lruKey) deviceCacheTimestamps.remove(lruKey) log "Evicted LRU device cache entry: ${lruKey}", 4 } } deviceCache[deviceKey] = deviceData deviceCacheTimestamps[deviceKey] = getCurrentTime() } def getCachedDeviceReading(String deviceKey) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def deviceCache = state."${cacheKey}_deviceCache" def deviceCacheTimestamps = state."${cacheKey}_deviceCacheTimestamps" def timestamp = deviceCacheTimestamps[deviceKey] if (!timestamp) return null if ((getCurrentTime() - timestamp) > DEVICE_CACHE_DURATION_MS) { deviceCache.remove(deviceKey) deviceCacheTimestamps.remove(deviceKey) return null } // Update access time for LRU tracking when item is accessed deviceCacheTimestamps[deviceKey] = getCurrentTime() return deviceCache[deviceKey] } // Device pending request tracking def isDeviceRequestPending(String deviceKey) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def pendingRequests = state."${cacheKey}_pendingDeviceRequests" return pendingRequests[deviceKey] == true } def markDeviceRequestPending(String deviceKey) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def pendingRequests = state."${cacheKey}_pendingDeviceRequests" pendingRequests[deviceKey] = true } def clearDeviceRequestPending(String deviceKey) { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def pendingRequests = state."${cacheKey}_pendingDeviceRequests" pendingRequests[deviceKey] = false } // Clear all instance caches def clearInstanceCache() { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def roomCache = state."${cacheKey}_roomCache" def roomCacheTimestamps = state."${cacheKey}_roomCacheTimestamps" def deviceCache = state."${cacheKey}_deviceCache" def deviceCacheTimestamps = state."${cacheKey}_deviceCacheTimestamps" def pendingRoomRequests = state."${cacheKey}_pendingRoomRequests" def pendingDeviceRequests = state."${cacheKey}_pendingDeviceRequests" roomCache.clear() roomCacheTimestamps.clear() deviceCache.clear() deviceCacheTimestamps.clear() pendingRoomRequests.clear() pendingDeviceRequests.clear() log "Cleared all instance caches", 3 } // ------------------------------ // End Instance-Based Caching Infrastructure // ------------------------------ // Initialize request tracking private initRequestTracking() { if (atomicState.activeRequests == null) { atomicState.activeRequests = 0 } } // Check if we can make a request (under concurrent limit) def canMakeRequest() { initRequestTracking() def currentActiveRequests = atomicState.activeRequests ?: 0 // Immediate stuck counter detection and reset if (currentActiveRequests >= MAX_CONCURRENT_REQUESTS) { log "CRITICAL: Active request counter is stuck at ${currentActiveRequests}/${MAX_CONCURRENT_REQUESTS} - resetting immediately", 1 atomicState.activeRequests = 0 log "Reset active request counter to 0 immediately", 1 return true // Now we can make the request } return currentActiveRequests < MAX_CONCURRENT_REQUESTS } // Increment active request counter def incrementActiveRequests() { initRequestTracking() atomicState.activeRequests = (atomicState.activeRequests ?: 0) + 1 } // Decrement active request counter def decrementActiveRequests() { initRequestTracking() def currentCount = atomicState.activeRequests ?: 0 atomicState.activeRequests = Math.max(0, currentCount - 1) log "Decremented active requests from ${currentCount} to ${atomicState.activeRequests}", 1 } // Wrapper for log.error that respects debugLevel setting private logError(String msg) { def settingsLevel = (settings?.debugLevel as Integer) ?: 0 if (settingsLevel > 0) { log.error msg } } // Wrapper for log.warn that respects debugLevel setting private logWarn(String msg) { def settingsLevel = (settings?.debugLevel as Integer) ?: 0 if (settingsLevel > 0) { log.warn msg } } private logDetails(String msg, details = null, int level = 3) { def settingsLevel = (settings?.debugLevel as Integer) ?: 0 if (settingsLevel == 0) { return } if (level >= settingsLevel) { if (details) { log?.debug "${msg}\n${details}" } else { log?.debug msg } } } def isValidResponse(resp) { if (!resp) { log 'HTTP Null response', 1 return false } try { // Check if this is an actual HTTP response object (has hasError method) if (resp.hasProperty('hasError') && resp.hasError()) { // Check for authentication failures if (resp.getStatus() == 401 || resp.getStatus() == 403) { log "Authentication error detected (${resp.getStatus()}), re-authenticating...", 2 runIn(1, 'autoReauthenticate') return false } // Don't log 404s at error level - they might be expected if (resp.getStatus() == 404) { log "HTTP 404 response", 1 } else { log "HTTP response error: ${resp.getStatus()}", 1 } return false } // If it's not an HTTP response object, check if it's a hub load exception if (resp instanceof Exception || resp.toString().contains('LimitExceededException')) { log "Hub load exception detected in response validation", 1 return false } } catch (err) { log "HTTP response validation error: ${err.message ?: err.toString()}", 1 return false } return true } // Updated getDataAsync to accept a String callback name with simple throttling. def getDataAsync(String uri, String callback, data = null, int retryCount = 0) { if (canMakeRequest()) { incrementActiveRequests() def headers = [ Authorization: "Bearer ${state.flairAccessToken}" ] def httpParams = [ uri: uri, headers: headers, contentType: CONTENT_TYPE, timeout: HTTP_TIMEOUT_SECS ] try { asynchttpGet(callback, httpParams, data) } catch (Exception e) { log "HTTP GET exception: ${e.message}", 2 // Decrement on exception since the request didn't actually happen decrementActiveRequests() return } } else { if (retryCount < MAX_API_RETRY_ATTEMPTS) { def retryData = [uri: uri, callback: callback, retryCount: retryCount + 1] if (data?.device && uri.contains('/room')) { retryData.data = [deviceId: data.device.getDeviceNetworkId()] } else { retryData.data = data } runInMillis(API_CALL_DELAY_MS, 'retryGetDataAsyncWrapper', [data: retryData]) } else { logError "getDataAsync failed after ${MAX_API_RETRY_ATTEMPTS} retries for URI: ${uri}" } } } // Wrapper method for getDataAsync retry def retryGetDataAsyncWrapper(data) { if (!data || !data.uri) { logError "retryGetDataAsyncWrapper called with invalid data: ${data}" return } // Check if this is a room data request that should go through cache if (data.uri.contains('/room') && data.callback == 'handleRoomGetWithCache' && data.data?.deviceId) { // When retry data is passed through runInMillis, device objects become serialized // So we need to look up the device by ID instead def deviceId = data.data.deviceId def device = getChildDevice(deviceId) if (!device) { logError "retryGetDataAsyncWrapper: Could not find device with ID ${deviceId}" return } def isPuck = !device.hasAttribute('percent-open') def roomId = device.currentValue('room-id') if (roomId) { // Check cache first using instance-based cache def cachedData = getCachedRoomData(roomId) if (cachedData) { log "Using cached room data for room ${roomId} on retry", 3 processRoomTraits(device, cachedData) return } // Check if request is already pending if (isRequestPending(roomId)) { // log "Room data request already pending for room ${roomId} on retry, skipping", 3 return } } // Re-route through cache check getRoomDataWithCache(device, deviceId, isPuck) } else { // Normal retry for non-room requests getDataAsync(data.uri, data.callback, data.data, data.retryCount) } } // Updated patchDataAsync to accept a String callback name with simple throttling. // If callback is null, we use a no-op callback. def patchDataAsync(String uri, String callback, body, data = null, int retryCount = 0) { if (!callback) { callback = 'noOpHandler' } if (canMakeRequest()) { incrementActiveRequests() def headers = [ Authorization: "Bearer ${state.flairAccessToken}" ] def httpParams = [ uri: uri, headers: headers, contentType: CONTENT_TYPE, requestContentType: CONTENT_TYPE, timeout: HTTP_TIMEOUT_SECS, body: JsonOutput.toJson(body) ] try { asynchttpPatch(callback, httpParams, data) } catch (Exception e) { log "HTTP PATCH exception: ${e.message}", 2 // Decrement on exception since the request didn't actually happen decrementActiveRequests() return } } else { if (retryCount < MAX_API_RETRY_ATTEMPTS) { def retryData = [uri: uri, callback: callback, body: body, data: data, retryCount: retryCount + 1] runInMillis(API_CALL_DELAY_MS, 'retryPatchDataAsyncWrapper', [data: retryData]) } else { logError "patchDataAsync failed after ${MAX_API_RETRY_ATTEMPTS} retries for URI: ${uri}" } } } // Wrapper method for patchDataAsync retry def retryPatchDataAsyncWrapper(data) { if (!data || !data.uri || !data.callback) { logError "retryPatchDataAsyncWrapper called with invalid data: ${data}" return } patchDataAsync(data.uri, data.callback, data.body, data.data, data.retryCount) } def noOpHandler(resp, data) { log 'noOpHandler called', 3 } def login() { authenticate() getStructureData() } def authenticate(int retryCount = 0) { log 'Getting access_token from Flair using async method', 2 state.authInProgress = true state.remove('authError') // Clear any previous error state 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+pucks.view+pucks.edit&grant_type=client_credentials' def params = [ uri: uri, body: body, timeout: HTTP_TIMEOUT_SECS, contentType: 'application/x-www-form-urlencoded' ] if (canMakeRequest()) { incrementActiveRequests() try { asynchttpPost('handleAuthResponse', params, [retryCount: retryCount]) } catch (Exception e) { def err = "Authentication request failed: ${e.message}" logError err state.authError = err state.authInProgress = false decrementActiveRequests() // Decrement on exception return err } } else { // If we can't make request now, reschedule authentication state.authInProgress = false if (retryCount < MAX_API_RETRY_ATTEMPTS) { runInMillis(API_CALL_DELAY_MS, 'retryAuthenticateWrapper', [data: [retryCount: retryCount + 1]]) } else { def err = "Authentication failed after ${MAX_API_RETRY_ATTEMPTS} retries" logError err state.authError = err } } return '' } // Wrapper method for authenticate retry def retryAuthenticateWrapper(data) { authenticate(data?.retryCount ?: 0) } def handleAuthResponse(resp, data) { decrementActiveRequests() // Always decrement when response comes back try { log "handleAuthResponse called with resp status: ${resp?.getStatus()}", 2 state.authInProgress = false if (!resp) { state.authError = "Authentication failed: No response from Flair API" logError state.authError return } if (resp.hasError()) { def status = resp.getStatus() def errorMsg = "Authentication failed with HTTP ${status}" if (status == 401) { errorMsg += ": Invalid credentials. Please verify your Client ID and Client Secret." } else if (status == 403) { errorMsg += ": Access forbidden. Please verify your OAuth credentials have proper permissions." } else if (status == 429) { errorMsg += ": Rate limited. Please wait a few minutes and try again." } else { errorMsg += ": ${resp.getErrorMessage() ?: 'Unknown error'}" } state.authError = errorMsg logError state.authError return } def respJson = resp.getJson() if (respJson?.access_token) { state.flairAccessToken = respJson.access_token state.remove('authError') log 'Authentication successful', 2 // Call getStructureData async after successful auth runIn(2, 'getStructureDataAsync') } else { def errorDetails = respJson?.error_description ?: respJson?.error ?: 'No access token in response' state.authError = "Authentication failed: ${errorDetails}. " + "Please verify your OAuth 2.0 credentials are correct." logError state.authError } } catch (Exception e) { state.authInProgress = false state.authError = "Authentication processing failed: ${e.message}" logError "handleAuthResponse exception: ${e.message}" log "Exception stack trace: ${e.getStackTrace()}", 1 } } def appButtonHandler(String btn) { switch (btn) { case 'authenticate': login() unschedule(login) runEvery1Hour(login) break case 'retryAuth': login() unschedule(login) runEvery1Hour(login) break case 'discoverDevices': discover() break case 'exportEfficiencyData': handleExportEfficiencyData() break case 'importEfficiencyData': handleImportEfficiencyData() break case 'clearExportData': handleClearExportData() break } } // Auto-authenticate when credentials are provided def autoAuthenticate() { if (settings?.clientId && settings?.clientSecret && !state.flairAccessToken) { log 'Auto-authenticating with provided credentials', 2 login() unschedule(login) runEvery1Hour(login) } } // Automatically re-authenticate when token expires def autoReauthenticate() { log 'Token expired or invalid, re-authenticating...', 2 state.remove('flairAccessToken') // Clear any error state state.remove('authError') // Re-authenticate and reschedule if (authenticate() == '') { // If authentication succeeded, reschedule hourly refresh unschedule(login) runEvery1Hour(login) log 'Re-authentication successful, rescheduled hourly token refresh', 2 } } private void discover() { log 'Discovery started', 3 atomicState.remove('ventsByRoomId') def structureId = getStructureId() // Discover vents first def ventsUri = "${BASE_URL}/api/structures/${structureId}/vents" log "Calling vents endpoint: ${ventsUri}", 2 getDataAsync(ventsUri, 'handleDeviceList', [deviceType: 'vents']) // Then discover pucks separately - they might be at a different endpoint def pucksUri = "${BASE_URL}/api/structures/${structureId}/pucks" log "Calling pucks endpoint: ${pucksUri}", 2 getDataAsync(pucksUri, 'handleDeviceList', [deviceType: 'pucks']) // Also try to get pucks from rooms since they might be associated there def roomsUri = "${BASE_URL}/api/structures/${structureId}/rooms?include=pucks" log "Calling rooms endpoint for pucks: ${roomsUri}", 2 getDataAsync(roomsUri, 'handleRoomsWithPucks') // Try getting pucks directly without structure def allPucksUri = "${BASE_URL}/api/pucks" log "Calling all pucks endpoint: ${allPucksUri}", 2 getDataAsync(allPucksUri, 'handleAllPucks') } def handleAllPucks(resp, data) { decrementActiveRequests() // Always decrement when response comes back try { log "handleAllPucks called", 2 if (!isValidResponse(resp)) { log "handleAllPucks: Invalid response status: ${resp?.getStatus()}", 2 return } def respJson = resp?.getJson() log "All pucks endpoint response: has data=${respJson?.data != null}, count=${respJson?.data?.size() ?: 0}", 2 if (respJson?.data) { def puckCount = 0 respJson.data.each { puckData -> try { if (puckData?.id) { puckCount++ def puckId = puckData?.id?.toString()?.trim() def puckName = puckData?.attributes?.name?.toString()?.trim() ?: "Puck-${puckId}" log "Creating puck from all pucks endpoint: ${puckName} (${puckId})", 2 def device = [ id : puckId, type : 'pucks', label: puckName ] def dev = makeRealDevice(device) if (dev) { log "Created puck device: ${puckName}", 2 } } } catch (Exception e) { log "Error processing puck from all pucks: ${e.message}", 1 } } if (puckCount > 0) { log "Discovered ${puckCount} pucks from all pucks endpoint", 3 } } } catch (Exception e) { log "Error in handleAllPucks: ${e.message}", 1 } } def handleRoomsWithPucks(resp, data) { decrementActiveRequests() // Always decrement when response comes back try { log "handleRoomsWithPucks called", 2 if (!isValidResponse(resp)) { log "handleRoomsWithPucks: Invalid response status: ${resp?.getStatus()}", 2 return } def respJson = resp.getJson() // Log the structure to debug log "handleRoomsWithPucks response: has included=${respJson?.included != null}, included count=${respJson?.included?.size() ?: 0}, has data=${respJson?.data != null}, data count=${respJson?.data?.size() ?: 0}", 2 // Check if we have included pucks data if (respJson?.included) { def puckCount = 0 respJson.included.each { it -> try { if (it?.type == 'pucks' && it?.id) { puckCount++ def puckId = it.id?.toString()?.trim() if (!puckId || puckId.isEmpty()) { log "Skipping puck with invalid ID", 2 return // Skip this puck } def puckName = it.attributes?.name?.toString()?.trim() // Ensure we have a valid name if (!puckName || puckName.isEmpty()) { puckName = "Puck-${puckId}" } // Double-check the name is not empty after all processing if (!puckName || puckName.isEmpty()) { log "Skipping puck with empty name even after fallback", 2 return } log "About to create puck device with id: ${puckId}, name: ${puckName}", 1 def device = [ id : puckId, type : 'pucks', // Use string literal to ensure it's not null label: puckName ] def dev = makeRealDevice(device) if (dev) { log "Created puck device: ${puckName}", 2 } } } catch (Exception e) { log "Error processing puck in loop: ${e.message}, line: ${e.stackTrace?.find()?.lineNumber}", 1 } } if (puckCount > 0) { log "Discovered ${puckCount} pucks from rooms include", 3 } } } catch (Exception e) { log "Error in handleRoomsWithPucks: ${e.message} at line ${e.stackTrace?.find()?.lineNumber}", 1 } // Also check if pucks are in the room data relationships try { if (respJson?.data) { def roomPuckCount = 0 respJson.data.each { room -> if (room.relationships?.pucks?.data) { room.relationships.pucks.data.each { puck -> try { roomPuckCount++ def puckId = puck.id?.toString()?.trim() if (!puckId || puckId.isEmpty()) { log "Skipping puck with invalid ID in room ${room.attributes?.name}", 2 return } // Create a minimal puck device from the reference def puckName = "Puck-${puckId}" if (room.attributes?.name) { puckName = "${room.attributes.name} Puck" } log "Creating puck device from room reference: ${puckName} (${puckId})", 2 def device = [ id : puckId, type : 'pucks', label: puckName ] def dev = makeRealDevice(device) if (dev) { log "Created puck device from room reference: ${puckName}", 2 } } catch (Exception e) { log "Error creating puck from room reference: ${e.message}", 1 } } } } if (roomPuckCount > 0) { log "Found ${roomPuckCount} puck references in rooms", 3 } } } catch (Exception e) { log "Error checking room puck relationships: ${e.message}", 1 } } def handleDeviceList(resp, data) { decrementActiveRequests() // Always decrement when response comes back log "handleDeviceList called for ${data?.deviceType}", 2 if (!isValidResponse(resp)) { // Check if this was a pucks request that returned 404 if (resp?.hasError() && resp.getStatus() == 404 && data?.deviceType == 'pucks') { log "Pucks endpoint returned 404 - this is normal, trying other methods", 2 } else if (data?.deviceType == 'pucks') { log "Pucks endpoint failed with error: ${resp?.getStatus()}", 2 } return } def respJson = resp?.getJson() if (!respJson?.data || respJson.data.isEmpty()) { if (data?.deviceType == 'pucks') { log "No pucks found in structure endpoint - they may be included with rooms instead", 2 } else { logWarn "No devices discovered. This may occur with OAuth 1.0 credentials. " + "Please ensure you're using OAuth 2.0 credentials or Legacy API (OAuth 1.0) credentials." } return } def ventCount = 0 def puckCount = 0 respJson.data.each { it -> if (it?.type == 'vents' || it?.type == 'pucks') { if (it.type == 'vents') { ventCount++ } else if (it.type == 'pucks') { puckCount++ } def device = [ id : it?.id, type : it?.type, label: it?.attributes?.name ] def dev = makeRealDevice(device) if (dev && it.type == 'vents') { processVentTraits(dev, [data: it]) } } } log "Discovered ${ventCount} vents and ${puckCount} pucks", 3 if (ventCount == 0 && puckCount == 0) { logWarn "No devices found in the structure. " + "This typically happens with incorrect OAuth credentials." } } def makeRealDevice(Map device) { // Validate inputs if (!device?.id || !device?.label || !device?.type) { logError "Invalid device data: ${device}" return null } def deviceId = device.id?.toString()?.trim() def deviceLabel = device.label?.toString()?.trim() if (!deviceId || deviceId.isEmpty() || !deviceLabel || deviceLabel.isEmpty()) { logError "Invalid device ID or label: id=${deviceId}, label=${deviceLabel}" return null } def newDevice = getChildDevice(deviceId) if (!newDevice) { def deviceType = device.type == 'vents' ? 'Flair vents' : 'Flair pucks' try { newDevice = addChildDevice('bot.flair', deviceType, deviceId, [name: deviceLabel, label: deviceLabel]) } catch (Exception e) { logError "Failed to add child device: ${e.message}" return null } } return newDevice } def getDeviceData(device) { log "Refresh device details for ${device}", 2 def deviceId = device.getDeviceNetworkId() def roomId = device.currentValue('room-id') // Check if it's a puck by looking for the percent-open attribute which only vents have def isPuck = !device.hasAttribute('percent-open') if (isPuck) { // Get puck data and current reading with caching getDeviceDataWithCache(device, deviceId, 'pucks', 'handlePuckGet') getDeviceReadingWithCache(device, deviceId, 'pucks', 'handlePuckReadingGet') // Check cache before making room API call getRoomDataWithCache(device, deviceId, isPuck) } else { // Get vent reading with caching getDeviceReadingWithCache(device, deviceId, 'vents', 'handleDeviceGet') // Check cache before making room API call getRoomDataWithCache(device, deviceId, isPuck) } } // New function to handle room data with caching def getRoomDataWithCache(device, deviceId, isPuck) { def roomId = device.currentValue('room-id') if (roomId) { // Check cache first using instance-based cache def cachedData = getCachedRoomData(roomId) if (cachedData) { log "Using cached room data for room ${roomId}", 3 processRoomTraits(device, cachedData) return } // Check if a request is already pending for this room if (isRequestPending(roomId)) { // log "Room data request already pending for room ${roomId}, skipping duplicate request", 3 return } // Mark this room as having a pending request markRequestPending(roomId) } // No valid cache and no pending request, make the API call def endpoint = isPuck ? "pucks" : "vents" getDataAsync("${BASE_URL}/api/${endpoint}/${deviceId}/room", 'handleRoomGetWithCache', [device: device]) } // New function to handle device data with caching (for pucks) def getDeviceDataWithCache(device, deviceId, deviceType, callback) { def cacheKey = "${deviceType}_${deviceId}" // Check cache first using instance-based cache def cachedData = getCachedDeviceReading(cacheKey) if (cachedData) { log "Using cached ${deviceType} data for device ${deviceId}", 3 // Process the cached data if (callback == 'handlePuckGet') { handlePuckGet([getJson: { cachedData }], [device: device]) } return } // Check if a request is already pending if (isDeviceRequestPending(cacheKey)) { // log "${deviceType} data request already pending for device ${deviceId}, skipping duplicate request", 3 return } // Mark this device as having a pending request markDeviceRequestPending(cacheKey) // No valid cache and no pending request, make the API call def uri = "${BASE_URL}/api/${deviceType}/${deviceId}" getDataAsync(uri, callback + 'WithCache', [device: device, cacheKey: cacheKey]) } // New function to handle device reading with caching def getDeviceReadingWithCache(device, deviceId, deviceType, callback) { def cacheKey = "${deviceType}_reading_${deviceId}" // Check cache first using instance-based cache def cachedData = getCachedDeviceReading(cacheKey) if (cachedData) { log "Using cached ${deviceType} reading for device ${deviceId}", 3 // Process the cached data if (callback == 'handlePuckReadingGet') { handlePuckReadingGet([getJson: { cachedData }], [device: device]) } else if (callback == 'handleDeviceGet') { handleDeviceGet([getJson: { cachedData }], [device: device]) } return } // Check if a request is already pending if (isDeviceRequestPending(cacheKey)) { // log "${deviceType} reading request already pending for device ${deviceId}, skipping duplicate request", 3 return } // Mark this device as having a pending request markDeviceRequestPending(cacheKey) // No valid cache and no pending request, make the API call def uri = deviceType == 'pucks' ? "${BASE_URL}/api/pucks/${deviceId}/current-reading" : "${BASE_URL}/api/vents/${deviceId}/current-reading" getDataAsync(uri, callback + 'WithCache', [device: device, cacheKey: cacheKey]) } def handleRoomGet(resp, data) { decrementActiveRequests() // Always decrement when response comes back if (!isValidResponse(resp) || !data?.device) { return } processRoomTraits(data.device, resp.getJson()) } // Modified handleRoomGet to include caching def handleRoomGetWithCache(resp, data) { def roomData = null def roomId = null try { // First, try to get roomId from device for cleanup purposes if (data?.device) { roomId = data.device.currentValue('room-id') } if (isValidResponse(resp) && data?.device) { roomData = resp.getJson() // Update roomId if we got it from response if (roomData?.data?.id) { roomId = roomData.data.id } if (roomId) { // Cache the room data using instance-based cache cacheRoomData(roomId, roomData) log "Cached room data for room ${roomId}", 3 } processRoomTraits(data.device, roomData) } else { // Log the error for debugging log "Room data request failed for device ${data?.device}, status: ${resp?.getStatus()}", 2 } } catch (Exception e) { log "Error in handleRoomGetWithCache: ${e.message}", 1 } finally { // Always clear the pending flag, even if the request failed if (roomId) { clearPendingRequest(roomId) log "Cleared pending request for room ${roomId}", 1 } } } // Add a method to clear the cache periodically (optional) def clearRoomCache() { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def currentTime = getCurrentTime() def expiredRooms = [] def roomCacheTimestamps = state."${cacheKey}_roomCacheTimestamps" def roomCache = state."${cacheKey}_roomCache" roomCacheTimestamps.each { roomId, timestamp -> if ((currentTime - timestamp) > ROOM_CACHE_DURATION_MS) { expiredRooms << roomId } } expiredRooms.each { roomId -> roomCache.remove(roomId) roomCacheTimestamps.remove(roomId) log "Cleared expired cache for room ${roomId}", 4 } } // Clear device cache periodically def clearDeviceCache() { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def currentTime = getCurrentTime() def expiredDevices = [] def deviceCacheTimestamps = state."${cacheKey}_deviceCacheTimestamps" def deviceCache = state."${cacheKey}_deviceCache" deviceCacheTimestamps.each { deviceKey, timestamp -> if ((currentTime - timestamp) > DEVICE_CACHE_DURATION_MS) { expiredDevices << deviceKey } } expiredDevices.each { deviceKey -> deviceCache.remove(deviceKey) deviceCacheTimestamps.remove(deviceKey) log "Cleared expired cache for device ${deviceKey}", 4 } } // Periodic cleanup of pending request flags def cleanupPendingRequests() { initializeInstanceCaches() def instanceId = getInstanceId() def cacheKey = "instanceCache_${instanceId}" def pendingRoomRequests = state."${cacheKey}_pendingRoomRequests" def pendingDeviceRequests = state."${cacheKey}_pendingDeviceRequests" // First, check if the active request counter is stuck def currentActiveRequests = atomicState.activeRequests ?: 0 if (currentActiveRequests >= MAX_CONCURRENT_REQUESTS) { log "CRITICAL: Active request counter is stuck at ${currentActiveRequests}/${MAX_CONCURRENT_REQUESTS} - resetting to 0", 1 atomicState.activeRequests = 0 log "Reset active request counter to 0", 1 } // Collect keys first to avoid concurrent modification def roomsToClean = [] pendingRoomRequests.each { roomId, isPending -> if (isPending) { roomsToClean << roomId } } // Now modify the map outside of iteration roomsToClean.each { roomId -> pendingRoomRequests[roomId] = false } if (roomsToClean.size() > 0) { log "Cleared ${roomsToClean.size()} stuck pending request flags for rooms: ${roomsToClean.join(', ')}", 2 } // Same for device requests def devicesToClean = [] pendingDeviceRequests.each { deviceKey, isPending -> if (isPending) { devicesToClean << deviceKey } } devicesToClean.each { deviceKey -> pendingDeviceRequests[deviceKey] = false } if (devicesToClean.size() > 0) { log "Cleared ${devicesToClean.size()} stuck pending request flags for devices: ${devicesToClean.join(', ')}", 2 } } def handleDeviceGet(resp, data) { decrementActiveRequests() // Always decrement when response comes back if (!isValidResponse(resp) || !data?.device) { return } processVentTraits(data.device, resp.getJson()) } // Modified handleDeviceGet to include caching def handleDeviceGetWithCache(resp, data) { def deviceData = null def cacheKey = data?.cacheKey try { if (isValidResponse(resp) && data?.device) { deviceData = resp.getJson() if (cacheKey && deviceData) { // Cache the device data using instance-based cache cacheDeviceReading(cacheKey, deviceData) log "Cached device reading for ${cacheKey}", 3 } processVentTraits(data.device, deviceData) } else { // Handle hub load exceptions specifically if (resp instanceof Exception || resp.toString().contains('LimitExceededException')) { logWarn "Device reading request failed due to hub load: ${resp.toString()}" } else { log "Device reading request failed for ${cacheKey}, status: ${resp?.getStatus()}", 2 } } } catch (Exception e) { logWarn "Error in handleDeviceGetWithCache: ${e.message}" } finally { // Always clear the pending flag if (cacheKey) { clearDeviceRequestPending(cacheKey) log "Cleared pending device request for ${cacheKey}", 1 } } } def handlePuckGet(resp, data) { decrementActiveRequests() // Always decrement when response comes back if (!isValidResponse(resp) || !data?.device) { return } def respJson = resp.getJson() if (respJson?.data) { def puckData = respJson.data // Extract puck attributes if (puckData?.attributes?.'current-temperature-c' != null) { def tempC = puckData.attributes['current-temperature-c'] def tempF = (tempC * 9/5) + 32 sendEvent(data.device, [name: 'temperature', value: tempF, unit: '°F']) log "Puck temperature: ${tempF}°F", 2 } if (puckData?.attributes?.'current-humidity' != null) { sendEvent(data.device, [name: 'humidity', value: puckData.attributes['current-humidity'], unit: '%']) } if (puckData?.attributes?.voltage != null) { try { def voltage = puckData.attributes.voltage as BigDecimal def battery = ((voltage - 2.0) / 1.6) * 100 // Assuming 2.0V = 0%, 3.6V = 100% battery = Math.max(0, Math.min(100, battery.round() as int)) sendEvent(data.device, [name: 'battery', value: battery, unit: '%']) } catch (Exception e) { log "Error calculating battery for puck: ${e.message}", 2 } } ['inactive', 'created-at', 'updated-at', 'current-rssi', 'name'].each { attr -> if (puckData.attributes && puckData.attributes[attr] != null) { sendEvent(data.device, [name: attr, value: puckData.attributes[attr]]) } } } } // Modified handlePuckGet to include caching def handlePuckGetWithCache(resp, data) { def deviceData = null def cacheKey = data?.cacheKey try { if (isValidResponse(resp) && data?.device) { deviceData = resp.getJson() if (cacheKey && deviceData) { // Cache the device data using instance-based cache cacheDeviceReading(cacheKey, deviceData) log "Cached puck data for ${cacheKey}", 3 } // Process using existing logic handlePuckGet([getJson: { deviceData }], data) } } finally { // Always clear the pending flag if (cacheKey) { clearDeviceRequestPending(cacheKey) } } } def handlePuckReadingGet(resp, data) { decrementActiveRequests() // Always decrement when response comes back if (!isValidResponse(resp) || !data?.device) { return } def respJson = resp.getJson() if (respJson?.data) { def reading = respJson.data // Process sensor reading data if (reading.attributes?.'room-temperature-c' != null) { def tempC = reading.attributes['room-temperature-c'] def tempF = (tempC * 9/5) + 32 sendEvent(data.device, [name: 'temperature', value: tempF, unit: '°F']) log "Puck temperature from reading: ${tempF}°F", 2 } if (reading.attributes?.humidity != null) { sendEvent(data.device, [name: 'humidity', value: reading.attributes.humidity, unit: '%']) } if (reading.attributes?.'system-voltage' != null) { try { def voltage = reading.attributes['system-voltage'] // Map system-voltage to voltage attribute for Rule Machine compatibility sendEvent(data.device, [name: 'voltage', value: voltage, unit: 'V']) def battery = ((voltage - 2.0) / 1.6) * 100 battery = Math.max(0, Math.min(100, battery.round() as int)) sendEvent(data.device, [name: 'battery', value: battery, unit: '%']) } catch (Exception e) { log "Error calculating battery from reading: ${e.message}", 2 } } } } // Modified handlePuckReadingGet to include caching def handlePuckReadingGetWithCache(resp, data) { def deviceData = null def cacheKey = data?.cacheKey try { if (isValidResponse(resp) && data?.device) { deviceData = resp.getJson() if (cacheKey && deviceData) { // Cache the device data using instance-based cache cacheDeviceReading(cacheKey, deviceData) log "Cached puck reading for ${cacheKey}", 3 } // Process using existing logic handlePuckReadingGet([getJson: { deviceData }], data) } } finally { // Always clear the pending flag if (cacheKey) { clearDeviceRequestPending(cacheKey) } } } def traitExtract(device, details, String propNameData, String propNameDriver = propNameData, unit = null) { try { def propValue = details.data.attributes[propNameData] if (propValue != null) { def eventData = [name: propNameDriver, value: propValue] if (unit) { eventData.unit = unit } sendEvent(device, eventData) } log "Extracted: ${propNameData} = ${propValue}", 1 } catch (err) { logWarn err } } def processVentTraits(device, details) { logDetails "Processing Vent data for ${device}", details, 1 if (!details?.data) { logWarn "Failed extracting data for ${device}" return } ['firmware-version-s', 'rssi', 'connected-gateway-name', 'created-at', 'duct-pressure', 'percent-open', 'duct-temperature-c', 'motor-run-time', 'system-voltage', 'motor-current', 'has-buzzed', 'updated-at', 'inactive'].each { attr -> traitExtract(device, details, attr, attr == 'percent-open' ? 'level' : attr, attr == 'percent-open' ? '%' : null) } // Map system-voltage to voltage attribute for Rule Machine compatibility if (details?.data?.attributes?.'system-voltage' != null) { def voltage = details.data.attributes['system-voltage'] sendEvent(device, [name: 'voltage', value: voltage, unit: 'V']) } } def processRoomTraits(device, details) { if (!device || !details?.data || !details.data.id) { return } logDetails "Processing Room data for ${device}", details, 1 sendEvent(device, [name: 'room-id', value: details.data.id]) [ 'name': 'room-name', 'current-temperature-c': 'room-current-temperature-c', 'room-conclusion-mode': 'room-conclusion-mode', 'humidity-away-min': 'room-humidity-away-min', 'room-type': 'room-type', 'temp-away-min-c': 'room-temp-away-min-c', 'level': 'room-level', 'hold-until': 'room-hold-until', 'room-away-mode': 'room-away-mode', 'heat-cool-mode': 'room-heat-cool-mode', 'updated-at': 'room-updated-at', 'state-updated-at': 'room-state-updated-at', 'set-point-c': 'room-set-point-c', 'hold-until-schedule-event': 'room-hold-until-schedule-event', 'frozen-pipe-pet-protect': 'room-frozen-pipe-pet-protect', 'created-at': 'room-created-at', 'windows': 'room-windows', 'air-return': 'room-air-return', 'current-humidity': 'room-current-humidity', 'hold-reason': 'room-hold-reason', 'occupancy-mode': 'room-occupancy-mode', 'temp-away-max-c': 'room-temp-away-max-c', 'humidity-away-max': 'room-humidity-away-max', 'preheat-precool': 'room-preheat-precool', 'active': 'room-active', 'set-point-manual': 'room-set-point-manual', 'pucks-inactive': 'room-pucks-inactive' ].each { key, driverKey -> traitExtract(device, details, key, driverKey) } if (details?.data?.relationships?.structure?.data) { sendEvent(device, [name: 'structure-id', value: details.data.relationships.structure.data.id]) } if (details?.data?.relationships['remote-sensors']?.data && !details.data.relationships['remote-sensors'].data.isEmpty()) { def remoteSensor = details.data.relationships['remote-sensors'].data.first() if (remoteSensor?.id) { def uri = "${BASE_URL}/api/remote-sensors/${remoteSensor.id}/sensor-readings" getDataAsync(uri, 'handleRemoteSensorGet', [device: device]) } } updateByRoomIdState(details) } def handleRemoteSensorGet(resp, data) { decrementActiveRequests() // Always decrement when response comes back if (!data) { return } // Don't log 404 errors for missing sensors - this is expected if (resp?.hasError() && resp.getStatus() == 404) { log "No remote sensor data available for ${data?.device?.getLabel() ?: 'unknown device'}", 1 return } if (!isValidResponse(resp)) { return } // Additional validation before parsing JSON try { def details = resp.getJson() if (!details?.data?.first()) { return } def propValue = details.data.first().attributes['occupied'] sendEvent(data.device, [name: 'room-occupied', value: propValue]) } catch (Exception e) { log "Error parsing remote sensor JSON: ${e.message}", 2 return } } 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.collect { it.id } atomicStateUpdate('ventsByRoomId', roomId, ventIds) } } def patchStructureData(Map attributes) { def body = [data: [type: 'structures', attributes: attributes]] def uri = "${BASE_URL}/api/structures/${getStructureId()}" patchDataAsync(uri, null, body) } def getStructureDataAsync(int retryCount = 0) { log 'Getting structure data asynchronously', 2 def uri = "${BASE_URL}/api/structures" def headers = [ Authorization: "Bearer ${state.flairAccessToken}" ] def httpParams = [ uri: uri, headers: headers, contentType: CONTENT_TYPE, timeout: HTTP_TIMEOUT_SECS ] if (canMakeRequest()) { incrementActiveRequests() try { asynchttpGet('handleStructureResponse', httpParams) } catch (Exception e) { logError "Structure data request failed: ${e.message}" decrementActiveRequests() // Decrement on exception } } else { // If we can't make request now, retry later if (retryCount < MAX_API_RETRY_ATTEMPTS) { runInMillis(API_CALL_DELAY_MS, 'retryGetStructureDataAsyncWrapper', [data: [retryCount: retryCount + 1]]) } else { logError "getStructureDataAsync failed after ${MAX_API_RETRY_ATTEMPTS} retries" } } } // Wrapper method for getStructureDataAsync retry def retryGetStructureDataAsyncWrapper(data) { getStructureDataAsync(data?.retryCount ?: 0) } def handleStructureResponse(resp, data) { decrementActiveRequests() // Always decrement when response comes back try { if (!isValidResponse(resp)) { logError "Structure data request failed" return } def response = resp.getJson() if (!response?.data?.first()) { logError 'No structure data available' return } def myStruct = response.data.first() if (myStruct?.id) { app.updateSetting('structureId', myStruct.id) log "Structure loaded: id=${myStruct.id}, name=${myStruct.attributes?.name}", 2 } } catch (Exception e) { logError "Structure data processing failed: ${e.message}" } } def getStructureData(int retryCount = 0) { log 'getStructureData', 1 // Check concurrent request limit first if (!canMakeRequest()) { if (retryCount < MAX_API_RETRY_ATTEMPTS) { log "Structure data request delayed due to concurrent limit (attempt ${retryCount + 1}/${MAX_API_RETRY_ATTEMPTS})", 2 // Schedule retry asynchronously to avoid blocking runInMillis(API_CALL_DELAY_MS, 'retryGetStructureDataWrapper', [data: [retryCount: retryCount + 1]]) return } else { logError "getStructureData failed after ${MAX_API_RETRY_ATTEMPTS} attempts due to concurrent limits" return } } def uri = "${BASE_URL}/api/structures" def headers = [ Authorization: "Bearer ${state.flairAccessToken}" ] def httpParams = [ uri: uri, headers: headers, contentType: CONTENT_TYPE, timeout: HTTP_TIMEOUT_SECS ] incrementActiveRequests() try { httpGet(httpParams) { resp -> decrementActiveRequests() if (!resp.success) { throw new Exception("HTTP request failed with status: ${resp.status}") } def response = resp.getData() if (!response) { logError 'getStructureData: no data' return } // Only log full response at debug level 1 logDetails 'Structure response: ', response, 1 def myStruct = response.data.first() if (!myStruct?.attributes) { logError 'getStructureData: no structure data' return } // Log only essential fields at level 3 log "Structure loaded: id=${myStruct.id}, name=${myStruct.attributes.name}, mode=${myStruct.attributes.mode}", 3 app.updateSetting('structureId', myStruct.id) } } catch (Exception e) { decrementActiveRequests() if (retryCount < MAX_API_RETRY_ATTEMPTS) { log "Structure data request failed (attempt ${retryCount + 1}/${MAX_API_RETRY_ATTEMPTS}): ${e.message}", 2 // Schedule retry asynchronously runInMillis(API_CALL_DELAY_MS, 'retryGetStructureDataWrapper', [data: [retryCount: retryCount + 1]]) } else { logError "getStructureData failed after ${MAX_API_RETRY_ATTEMPTS} attempts: ${e.message}" } } } // Wrapper method for synchronous getStructureData retry def retryGetStructureDataWrapper(data) { getStructureData(data?.retryCount ?: 0) } def patchVentDevice(device, percentOpen) { def pOpen = Math.min(100, Math.max(0, percentOpen as int)) def currentOpen = (device?.currentValue('percent-open') ?: 0).toInteger() if (pOpen == currentOpen) { log "Keeping ${device} percent open unchanged at ${pOpen}%", 3 return } log "Setting ${device} percent open from ${currentOpen} to ${pOpen}%", 3 def deviceId = device.getDeviceNetworkId() def uri = "${BASE_URL}/api/vents/${deviceId}" def body = [ data: [ type: 'vents', attributes: [ 'percent-open': pOpen ] ] ] // Don't update local state until API call succeeds patchDataAsync(uri, 'handleVentPatch', body, [device: device, targetOpen: pOpen]) } // Keep the old method name for backward compatibility def patchVent(device, percentOpen) { patchVentDevice(device, percentOpen) } def handleVentPatch(resp, data) { decrementActiveRequests() // Always decrement when response comes back if (!isValidResponse(resp) || !data) { if (resp instanceof Exception || resp.toString().contains('LimitExceededException')) { log "Vent patch failed due to hub load: ${resp.toString()}", 2 } else { log "Vent patch failed - invalid response or data", 2 } return } // Get the actual device for processing (handle serialized device objects) def device = null if (data.device?.getDeviceNetworkId) { device = data.device } else if (data.device?.deviceNetworkId) { device = getChildDevice(data.device.deviceNetworkId) } if (!device) { log "Could not get device object for vent patch processing", 2 return } // Process the API response def respJson = resp.getJson() traitExtract(device, [data: respJson.data], 'percent-open', 'percent-open', '%') traitExtract(device, [data: respJson.data], 'percent-open', 'level', '%') // Update local state ONLY after successful API response if (data.targetOpen != null) { try { safeSendEvent(device, [name: 'percent-open', value: data.targetOpen]) safeSendEvent(device, [name: 'level', value: data.targetOpen]) log "Updated ${device.getLabel()} to ${data.targetOpen}%", 3 } catch (Exception e) { log "Error updating device state: ${e.message}", 2 } } } def patchRoom(device, active) { def roomId = device.currentValue('room-id') if (!roomId || active == null) { return } if (active == device.currentValue('room-active')) { return } log "Setting active state to ${active} for '${device.currentValue('room-name')}'", 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) { decrementActiveRequests() // Always decrement when response comes back 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 = settings?.thermostat1?.currentValue('temperature') def coolingSetpoint = settings?.thermostat1?.currentValue('coolingSetpoint') ?: 0 def heatingSetpoint = settings?.thermostat1?.currentValue('heatingSetpoint') ?: 0 String hvacMode = calculateHvacMode(temp, coolingSetpoint, heatingSetpoint) def thermostatSetpoint = getThermostatSetpoint(hvacMode) // Apply hysteresis to prevent frequent cycling def lastSignificantTemp = atomicState.lastSignificantTemp ?: temp def tempDiff = Math.abs(temp - lastSignificantTemp) if (tempDiff >= THERMOSTAT_HYSTERESIS) { atomicState.lastSignificantTemp = temp log "Significant temperature change detected: ${tempDiff}°C (threshold: ${THERMOSTAT_HYSTERESIS}°C)", 2 if (isThermostatAboutToChangeState(hvacMode, thermostatSetpoint, temp)) { runInMillis(INITIALIZATION_DELAY_MS, 'initializeRoomStates', [data: hvacMode]) } } else { log "Temperature change ${tempDiff}°C is below hysteresis threshold ${THERMOSTAT_HYSTERESIS}°C - ignoring", 3 } } def isThermostatAboutToChangeState(String hvacMode, BigDecimal setpoint, BigDecimal temp) { if (hvacMode == COOLING && temp + SETPOINT_OFFSET - VENT_PRE_ADJUST_THRESHOLD < setpoint) { atomicState.tempDiffsInsideThreshold = false return false } else if (hvacMode == HEATING && temp - SETPOINT_OFFSET + VENT_PRE_ADJUST_THRESHOLD > 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 in [PENDING_COOL, PENDING_HEAT] ? (evt.value == PENDING_COOL ? COOLING : HEATING) : evt.value switch (hvacMode) { case COOLING: case HEATING: if (atomicState.thermostat1State) { log "initializeRoomStates already executed (${evt.value})", 3 return } atomicStateUpdate('thermostat1State', 'mode', hvacMode) atomicStateUpdate('thermostat1State', 'startedRunning', now()) unschedule(initializeRoomStates) runInMillis(POST_STATE_CHANGE_DELAY_MS, 'initializeRoomStates', [data: hvacMode]) recordStartingTemperatures() runEvery5Minutes('evaluateRebalancingVents') runEvery30Minutes('reBalanceVents') // Update polling to active interval when HVAC is running updateDevicePollingInterval(POLLING_INTERVAL_ACTIVE) 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 ] runInMillis(TEMP_READINGS_DELAY_MS, 'finalizeRoomStates', [data: params]) atomicState.remove('thermostat1State') } // Update polling to idle interval when HVAC is idle updateDevicePollingInterval(POLLING_INTERVAL_IDLE) break } } 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 } if (vent.currentValue('room-active') != 'true') { continue } def currPercentOpen = (vent.currentValue('percent-open') ?: 0).toInteger() if (currPercentOpen <= STANDARD_VENT_DEFAULT_OPEN) { continue } def roomTemp = getRoomTemp(vent) if (!hasRoomReachedSetpoint(hvacMode, setPoint, roomTemp, REBALANCING_TOLERANCE)) { continue } log "Rebalancing Vents - '${vent.currentValue('room-name')}' is at ${roomTemp}° (target: ${setPoint})", 3 reBalanceVents() break } catch (err) { logError err } } } } def finalizeRoomStates(data) { // Check for required parameters if (!data.ventIdsByRoomId || !data.startedCycle || !data.finishedRunning) { logWarn "Finalizing room states: missing required parameters (${data})" return } // Handle edge case when HVAC was already running during code deployment if (!data.startedRunning || !data.hvacMode) { log "Skipping room state finalization - HVAC cycle started before code deployment", 2 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, totalRunningMinutes), 6) if (totalCycleMinutes >= MIN_MINUTES_TO_SETPOINT) { // Track room rates that have been calculated Map roomRates = [:] data.ventIdsByRoomId.each { roomId, ventIds -> for (ventId in ventIds) { def vent = getChildDevice(ventId) if (!vent) { log "Failed getting vent Id ${ventId}", 3 continue } def roomName = vent.currentValue('room-name') def ratePropName = data.hvacMode == COOLING ? 'room-cooling-rate' : 'room-heating-rate' // Check if rate already calculated for this room if (roomRates.containsKey(roomName)) { // Use the already calculated rate for this room def rate = roomRates[roomName] sendEvent(vent, [name: ratePropName, value: rate]) log "Applying same ${ratePropName} (${roundBigDecimal(rate)}) to additional vent in '${roomName}'", 3 continue } // Calculate rate for this room (first vent in room) def percentOpen = (vent.currentValue('percent-open') ?: 0).toInteger() BigDecimal currentTemp = getRoomTemp(vent) BigDecimal lastStartTemp = vent.currentValue('room-starting-temperature-c') ?: 0 BigDecimal currentRate = vent.currentValue(ratePropName) ?: 0 def newRate = calculateRoomChangeRate(lastStartTemp, currentTemp, totalCycleMinutes, percentOpen, currentRate) if (newRate <= 0) { log "New rate for ${roomName} is ${newRate}", 3 // Check if room is already at or beyond setpoint def isAtSetpoint = hasRoomReachedSetpoint(data.hvacMode, getThermostatSetpoint(data.hvacMode), currentTemp) if (isAtSetpoint && currentRate > 0) { // Room is already at setpoint - maintain last known efficiency log "${roomName} is already at setpoint, maintaining last known efficiency rate: ${currentRate}", 3 newRate = currentRate // Keep existing rate } else if (percentOpen > 0) { // Vent was open but no temperature change - use minimum rate newRate = MIN_TEMP_CHANGE_RATE log "Setting minimum rate for ${roomName} - no temperature change detected with ${percentOpen}% open vent", 3 } else if (currentRate == 0) { // Room has zero efficiency and vent was closed - set baseline efficiency def maxRate = data.hvacMode == COOLING ? atomicState.maxCoolingRate ?: MAX_TEMP_CHANGE_RATE : atomicState.maxHeatingRate ?: MAX_TEMP_CHANGE_RATE newRate = maxRate * 0.1 // 10% of maximum as baseline log "Setting baseline efficiency for ${roomName} (10% of max rate: ${newRate})", 3 } else { continue // Skip if vent was closed and room has existing efficiency } } def rate = rollingAverage(currentRate, newRate, percentOpen / 100, 4) def cleanedRate = cleanDecimalForJson(rate) sendEvent(vent, [name: ratePropName, value: cleanedRate]) log "Updating ${roomName}'s ${ratePropName} to ${roundBigDecimal(cleanedRate)}", 3 // Store the calculated rate for this room roomRates[roomName] = cleanedRate // Track maximum rates for baseline calculations if (cleanedRate > 0) { if (data.hvacMode == COOLING) { def maxCoolRate = atomicState.maxCoolingRate ?: 0 if (cleanedRate > maxCoolRate) { atomicState.maxCoolingRate = cleanDecimalForJson(cleanedRate) log "Updated maximum cooling rate to ${cleanedRate}", 3 } } else if (data.hvacMode == HEATING) { def maxHeatRate = atomicState.maxHeatingRate ?: 0 if (cleanedRate > maxHeatRate) { atomicState.maxHeatingRate = cleanDecimalForJson(cleanedRate) log "Updated maximum heating rate to ${cleanedRate}", 3 } } } } } } else { log "Could not calculate room states as it ran for ${totalCycleMinutes} minutes and needs to run for at least ${MIN_MINUTES_TO_SETPOINT} minutes", 3 } log 'End - Finalizing room states', 3 } def recordStartingTemperatures() { if (!atomicState.ventsByRoomId) { return } log "Recording starting temperatures for all rooms", 2 atomicState.ventsByRoomId.each { roomId, ventIds -> ventIds.each { ventId -> try { def vent = getChildDevice(ventId) if (!vent) { return } BigDecimal currentTemp = getRoomTemp(vent) sendEvent(vent, [name: 'room-starting-temperature-c', value: currentTemp]) log "Starting temperature for '${vent.currentValue('room-name')}': ${currentTemp}°C", 2 } catch (err) { logError err } } } } def initializeRoomStates(String hvacMode) { if (!settings.dabEnabled) { return } log "Initializing room states - hvac mode: ${hvacMode}", 3 if (!atomicState.ventsByRoomId) { return } BigDecimal setpoint = getThermostatSetpoint(hvacMode) if (!setpoint) { return } atomicStateUpdate('thermostat1State', 'startedCycle', now()) def rateAndTempPerVentId = getAttribsPerVentId(atomicState.ventsByRoomId, hvacMode) def maxRunningTime = atomicState.maxHvacRunningTime ?: MAX_MINUTES_TO_SETPOINT def longestTimeToTarget = calculateLongestMinutesToTarget(rateAndTempPerVentId, hvacMode, setpoint, maxRunningTime, settings.thermostat1CloseInactiveRooms) if (longestTimeToTarget < 0) { log "All vents already reached setpoint (${setpoint})", 3 longestTimeToTarget = maxRunningTime } if (longestTimeToTarget == 0) { log "Opening all vents (setpoint: ${setpoint})", 3 openAllVents(atomicState.ventsByRoomId, MAX_PERCENTAGE_OPEN as int) return } log "Initializing room states - setpoint: ${setpoint}, longestTimeToTarget: ${roundBigDecimal(longestTimeToTarget)}", 3 def calcPercentOpen = calculateOpenPercentageForAllVents(rateAndTempPerVentId, hvacMode, setpoint, longestTimeToTarget, settings.thermostat1CloseInactiveRooms) if (!calcPercentOpen) { log "No vents are being changed (setpoint: ${setpoint})", 3 return } calcPercentOpen = adjustVentOpeningsToEnsureMinimumAirflowTarget(rateAndTempPerVentId, hvacMode, calcPercentOpen, settings.thermostat1AdditionalStandardVents) calcPercentOpen.each { ventId, percentOpen -> def vent = getChildDevice(ventId) if (vent) { patchVent(vent, roundToNearestMultiple(percentOpen)) } } } def adjustVentOpeningsToEnsureMinimumAirflowTarget(rateAndTempPerVentId, String hvacMode, Map calculatedPercentOpen, additionalStandardVents) { int totalDeviceCount = additionalStandardVents > 0 ? additionalStandardVents : 0 def sumPercentages = totalDeviceCount * STANDARD_VENT_DEFAULT_OPEN calculatedPercentOpen.each { ventId, percent -> totalDeviceCount++ sumPercentages += percent ?: 0 } if (totalDeviceCount <= 0) { logWarn 'Total device count is zero' return calculatedPercentOpen } 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 } if (minTemp == null || maxTemp == null) { minTemp = 20.0 maxTemp = 25.0 } else { minTemp = minTemp - TEMP_BOUNDARY_ADJUSTMENT maxTemp = maxTemp + TEMP_BOUNDARY_ADJUSTMENT } def combinedFlowPercentage = (100 * sumPercentages) / (totalDeviceCount * 100) if (combinedFlowPercentage >= MIN_COMBINED_VENT_FLOW) { log "Combined vent flow percentage (${combinedFlowPercentage}%) is greater than ${MIN_COMBINED_VENT_FLOW}%", 3 return calculatedPercentOpen } log "Combined Vent Flow Percentage (${combinedFlowPercentage}) is lower than ${MIN_COMBINED_VENT_FLOW}%", 3 def targetPercentSum = MIN_COMBINED_VENT_FLOW * totalDeviceCount def diffPercentageSum = targetPercentSum - sumPercentages log "sumPercentages=${sumPercentages}, targetPercentSum=${targetPercentSum}, diffPercentageSum=${diffPercentageSum}", 2 int iterations = 0 while (diffPercentageSum > 0 && iterations++ < MAX_ITERATIONS) { for (item in rateAndTempPerVentId) { def ventId = item.key def stateVal = item.value BigDecimal percentOpenVal = calculatedPercentOpen[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 = INCREMENT_PERCENTAGE * proportion percentOpenVal = percentOpenVal + increment calculatedPercentOpen[ventId] = percentOpenVal log "Adjusting % open from ${roundBigDecimal(percentOpenVal - increment)}% to ${roundBigDecimal(percentOpenVal)}%", 2 diffPercentageSum = diffPercentageSum - increment if (diffPercentageSum <= 0) { break } } } } return calculatedPercentOpen } def getAttribsPerVentId(ventsByRoomId, String hvacMode) { def rateAndTemp = [:] ventsByRoomId.each { roomId, ventIds -> ventIds.each { ventId -> try { def vent = getChildDevice(ventId) if (!vent) { return } def rate = hvacMode == COOLING ? (vent.currentValue('room-cooling-rate') ?: 0) : (vent.currentValue('room-heating-rate') ?: 0) rate = rate ?: 0 def isActive = vent.currentValue('room-active') == 'true' def roomTemp = getRoomTemp(vent) def roomName = vent.currentValue('room-name') ?: '' // Log rooms with zero efficiency for debugging if (rate == 0) { def tempSource = settings."thermostat${ventId}" ? "Puck ${settings."thermostat${ventId}".getLabel()}" : "Room API" log "Room '${roomName}' has zero ${hvacMode} efficiency rate, temp=${roomTemp}°C from ${tempSource}", 2 } rateAndTemp[ventId] = [ rate: rate, temp: roomTemp, active: isActive, name: roomName ] } catch (err) { logError err } } } return rateAndTemp } def calculateOpenPercentageForAllVents(rateAndTempPerVentId, String hvacMode, BigDecimal setpoint, longestTime, boolean closeInactive = true) { def percentOpenMap = [:] rateAndTempPerVentId.each { ventId, stateVal -> try { def percentageOpen = MIN_PERCENTAGE_OPEN if (closeInactive && !stateVal.active) { log "Closing vent on inactive room: ${stateVal.name}", 3 } else if (stateVal.rate < MIN_TEMP_CHANGE_RATE) { log "Opening vent at max since change rate is too low: ${stateVal.name}", 3 percentageOpen = MAX_PERCENTAGE_OPEN } else { percentageOpen = calculateVentOpenPercentage(stateVal.name, stateVal.temp, setpoint, hvacMode, stateVal.rate, longestTime) } percentOpenMap[ventId] = percentageOpen } catch (err) { logError err } } return percentOpenMap } def calculateVentOpenPercentage(String roomName, BigDecimal startTemp, BigDecimal setpoint, String hvacMode, BigDecimal maxRate, BigDecimal longestTime) { if (hasRoomReachedSetpoint(hvacMode, setpoint, startTemp)) { def msg = hvacMode == COOLING ? 'cooler' : 'warmer' log "'${roomName}' is already ${msg} (${startTemp}) than setpoint (${setpoint})", 3 return MIN_PERCENTAGE_OPEN } BigDecimal percentageOpen = MAX_PERCENTAGE_OPEN if (maxRate > 0 && longestTime > 0) { BigDecimal BASE_CONST = 0.0991 BigDecimal EXP_CONST = 2.3 // Calculate the target rate: the average temperature change required per minute. def targetRate = Math.abs(setpoint - startTemp) / longestTime percentageOpen = BASE_CONST * Math.exp((targetRate / maxRate) * EXP_CONST) percentageOpen = roundBigDecimal(percentageOpen * 100, 3) // Ensure percentageOpen stays within defined limits. percentageOpen = percentageOpen < MIN_PERCENTAGE_OPEN ? MIN_PERCENTAGE_OPEN : (percentageOpen > MAX_PERCENTAGE_OPEN ? MAX_PERCENTAGE_OPEN : percentageOpen) log "changing percentage open for ${roomName} to ${percentageOpen}% (maxRate=${roundBigDecimal(maxRate)})", 3 } return percentageOpen } def calculateLongestMinutesToTarget(rateAndTempPerVentId, String hvacMode, BigDecimal setpoint, maxRunningTime, boolean closeInactive = true) { def longestTime = -1 rateAndTempPerVentId.each { ventId, stateVal -> try { def minutesToTarget = -1 if (closeInactive && !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 (stateVal.rate > 0) { minutesToTarget = Math.abs(setpoint - stateVal.temp) / stateVal.rate // Check for unrealistic time estimates due to minimal temperature change if (minutesToTarget > maxRunningTime * 2) { logWarn "'${stateVal.name}' shows minimal temperature change (rate: ${roundBigDecimal(stateVal.rate)}°C/min). " + "Estimated time ${roundBigDecimal(minutesToTarget)} minutes is unrealistic." minutesToTarget = maxRunningTime // Cap at max running time } } else if (stateVal.rate == 0) { minutesToTarget = 0 logWarn "'${stateVal.name}' shows no temperature change with vent open" } if (minutesToTarget > maxRunningTime) { logWarn "'${stateVal.name}' is estimated to take ${roundBigDecimal(minutesToTarget)} minutes to reach target temp, which is longer than the average ${roundBigDecimal(maxRunningTime)} minutes" minutesToTarget = maxRunningTime } longestTime = Math.max(longestTime, minutesToTarget.doubleValue()) log "Room '${stateVal.name}' temp: ${stateVal.temp}", 3 } catch (err) { logError err } } return longestTime } // Overloaded method for backward compatibility with tests def calculateRoomChangeRate(def lastStartTemp, def currentTemp, def totalMinutes, def percentOpen, def currentRate) { // Null safety checks if (lastStartTemp == null || currentTemp == null || totalMinutes == null || percentOpen == null || currentRate == null) { log "calculateRoomChangeRate: null parameter detected", 3 return -1 } try { return calculateRoomChangeRate( lastStartTemp as BigDecimal, currentTemp as BigDecimal, totalMinutes as BigDecimal, percentOpen as int, currentRate as BigDecimal ) } catch (Exception e) { log "calculateRoomChangeRate casting error: ${e.message}", 3 return -1 } } def calculateRoomChangeRate(BigDecimal lastStartTemp, BigDecimal currentTemp, BigDecimal totalMinutes, int percentOpen, BigDecimal currentRate) { if (totalMinutes < MIN_MINUTES_TO_SETPOINT) { log "Insufficient number of minutes required to calculate change rate (${totalMinutes} should be greater than ${MIN_MINUTES_TO_SETPOINT})", 3 return -1 } // Skip rate calculation if HVAC hasn't run long enough for meaningful temperature changes if (totalMinutes < MIN_RUNTIME_FOR_RATE_CALC) { log "HVAC runtime too short for rate calculation: ${totalMinutes} minutes < ${MIN_RUNTIME_FOR_RATE_CALC} minutes minimum", 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) // Check if temperature change is within sensor noise/accuracy range if (diffTemps < MIN_DETECTABLE_TEMP_CHANGE) { log "Temperature change (${diffTemps}°C) is below minimum detectable threshold (${MIN_DETECTABLE_TEMP_CHANGE}°C) - likely sensor noise", 2 // If no meaningful temperature change but vent was significantly open, assign minimum efficiency if (percentOpen >= 30) { log "Vent was ${percentOpen}% open but no meaningful temperature change detected - assigning minimum efficiency", 2 return MIN_TEMP_CHANGE_RATE } return -1 } // Account for sensor accuracy when detecting minimal changes if (diffTemps < TEMP_SENSOR_ACCURACY) { log "Temperature change (${diffTemps}°C) is within sensor accuracy range (±${TEMP_SENSOR_ACCURACY}°C) - adjusting calculation", 2 // Use a minimum reliable change for calculation to avoid division by near-zero diffTemps = Math.max(diffTemps, MIN_DETECTABLE_TEMP_CHANGE) } BigDecimal rate = diffTemps / totalMinutes BigDecimal pOpen = percentOpen / 100 BigDecimal maxRate = Math.max(rate.doubleValue(), currentRate.doubleValue()) BigDecimal approxRate = maxRate != 0 ? (rate / maxRate) / pOpen : 0 if (approxRate > MAX_TEMP_CHANGE_RATE) { log "Change rate (${roundBigDecimal(approxRate)}) is greater than ${MAX_TEMP_CHANGE_RATE}, therefore it is being excluded", 3 return -1 } else if (approxRate < MIN_TEMP_CHANGE_RATE) { log "Change rate (${roundBigDecimal(approxRate)}) is lower than ${MIN_TEMP_CHANGE_RATE}, adjusting to minimum (startTemp=${lastStartTemp}, currentTemp=${currentTemp}, percentOpen=${percentOpen}%)", 3 // Return minimum rate instead of excluding to prevent zero efficiency return MIN_TEMP_CHANGE_RATE } return approxRate } // ------------------------------ // Dynamic Polling Control // ------------------------------ def updateDevicePollingInterval(Integer intervalMinutes) { log "Updating device polling interval to ${intervalMinutes} minutes", 3 // Update all child vents getChildDevices()?.findAll { it.typeName == 'Flair vents' }?.each { device -> try { device.updateParentPollingInterval(intervalMinutes) } catch (Exception e) { log "Error updating polling interval for vent ${device.getLabel()}: ${e.message}", 2 } } // Update all child pucks getChildDevices()?.findAll { it.typeName == 'Flair pucks' }?.each { device -> try { device.updateParentPollingInterval(intervalMinutes) } catch (Exception e) { log "Error updating polling interval for puck ${device.getLabel()}: ${e.message}", 2 } } atomicState.currentPollingInterval = intervalMinutes log "Updated polling interval for ${getChildDevices()?.size() ?: 0} devices", 3 } // ------------------------------ // Efficiency Data Export/Import Functions // ------------------------------ def handleExportEfficiencyData() { try { log "Starting efficiency data export", 2 // Collect efficiency data from all vents def efficiencyData = exportEfficiencyData() // Generate JSON format def jsonData = generateEfficiencyJSON(efficiencyData) // Set export status message def roomCount = efficiencyData.roomEfficiencies.size() state.exportStatus = "✓ Exported efficiency data for ${roomCount} rooms. Copy the JSON data below:" // Store the JSON data for display state.exportedJsonData = jsonData log "Export completed successfully for ${roomCount} rooms", 2 } catch (Exception e) { def errorMsg = "Export failed: ${e.message}" logError errorMsg state.exportStatus = "✗ ${errorMsg}" state.exportedJsonData = null } } def handleImportEfficiencyData() { try { log "Starting efficiency data import", 2 // Clear previous status state.remove('importStatus') state.remove('importSuccess') // Get JSON data from user input def jsonData = settings.importJsonData if (!jsonData?.trim()) { state.importStatus = "✗ No JSON data provided. Please paste the exported efficiency data." state.importSuccess = false return } // Import the data def result = importEfficiencyData(jsonData.trim()) if (result.success) { def statusMsg = "✓ Import successful! Updated ${result.roomsUpdated} rooms" if (result.globalUpdated) { statusMsg += " and global efficiency rates" } if (result.roomsSkipped > 0) { statusMsg += ". Skipped ${result.roomsSkipped} rooms (not found)" } state.importStatus = statusMsg state.importSuccess = true // Clear the input field after successful import app.updateSetting('importJsonData', '') log "Import completed: ${result.roomsUpdated} rooms updated, ${result.roomsSkipped} skipped", 2 } else { state.importStatus = "✗ Import failed: ${result.error}" state.importSuccess = false logError "Import failed: ${result.error}" } } catch (Exception e) { def errorMsg = "Import failed: ${e.message}" logError errorMsg state.importStatus = "✗ ${errorMsg}" state.importSuccess = false } } def handleClearExportData() { try { log "Clearing export data", 2 state.remove('exportStatus') state.remove('exportedJsonData') log "Export data cleared successfully", 2 } catch (Exception e) { logError "Failed to clear export data: ${e.message}" } } def exportEfficiencyData() { def data = [ globalRates: [ maxCoolingRate: cleanDecimalForJson(atomicState.maxCoolingRate), maxHeatingRate: cleanDecimalForJson(atomicState.maxHeatingRate) ], roomEfficiencies: [] ] // Only collect from vents (devices with percent-open attribute) getChildDevices().findAll { it.hasAttribute('percent-open') }.each { device -> def coolingRate = device.currentValue('room-cooling-rate') ?: 0 def heatingRate = device.currentValue('room-heating-rate') ?: 0 def roomData = [ roomId: device.currentValue('room-id'), roomName: device.currentValue('room-name'), ventId: device.getDeviceNetworkId(), coolingRate: cleanDecimalForJson(coolingRate), heatingRate: cleanDecimalForJson(heatingRate) ] data.roomEfficiencies << roomData } return data } def generateEfficiencyJSON(data) { def exportData = [ exportMetadata: [ version: '0.23', exportDate: new Date().format("yyyy-MM-dd'T'HH:mm:ss'Z'"), structureId: settings.structureId ?: 'Unknown' ], efficiencyData: data ] return JsonOutput.toJson(exportData) } def importEfficiencyData(jsonContent) { try { def jsonData = new groovy.json.JsonSlurper().parseText(jsonContent) if (!validateImportData(jsonData)) { return [success: false, error: 'Invalid data format. Please ensure you are using exported efficiency data.'] } def results = applyImportedEfficiencies(jsonData.efficiencyData) return [ success: true, globalUpdated: results.globalUpdated, roomsUpdated: results.roomsUpdated, roomsSkipped: results.roomsSkipped, errors: results.errors ] } catch (Exception e) { return [success: false, error: e.message] } } def validateImportData(jsonData) { // Check required structure if (!jsonData.exportMetadata || !jsonData.efficiencyData) return false if (!jsonData.efficiencyData.globalRates) return false if (!jsonData.efficiencyData.roomEfficiencies) return false // Validate global rates def globalRates = jsonData.efficiencyData.globalRates if (globalRates.maxCoolingRate == null || globalRates.maxHeatingRate == null) return false if (globalRates.maxCoolingRate < 0 || globalRates.maxHeatingRate < 0) return false if (globalRates.maxCoolingRate > 10 || globalRates.maxHeatingRate > 10) return false // Validate room efficiencies for (room in jsonData.efficiencyData.roomEfficiencies) { if (!room.roomId || !room.roomName || !room.ventId) return false if (room.coolingRate == null || room.heatingRate == null) return false if (room.coolingRate < 0 || room.heatingRate < 0) return false if (room.coolingRate > 10 || room.heatingRate > 10) return false } return true } def applyImportedEfficiencies(efficiencyData) { def results = [ globalUpdated: false, roomsUpdated: 0, roomsSkipped: 0, errors: [] ] // Update global rates if (efficiencyData.globalRates) { atomicState.maxCoolingRate = efficiencyData.globalRates.maxCoolingRate atomicState.maxHeatingRate = efficiencyData.globalRates.maxHeatingRate results.globalUpdated = true log "Updated global rates: cooling=${efficiencyData.globalRates.maxCoolingRate}, heating=${efficiencyData.globalRates.maxHeatingRate}", 2 } // Update room efficiencies efficiencyData.roomEfficiencies?.each { roomData -> def device = matchDeviceByRoomId(roomData.roomId) ?: matchDeviceByRoomName(roomData.roomName) if (device) { sendEvent(device, [name: 'room-cooling-rate', value: roomData.coolingRate]) sendEvent(device, [name: 'room-heating-rate', value: roomData.heatingRate]) results.roomsUpdated++ log "Updated efficiency for '${roomData.roomName}': cooling=${roomData.coolingRate}, heating=${roomData.heatingRate}", 2 } else { results.roomsSkipped++ results.errors << "Room not found: ${roomData.roomName} (${roomData.roomId})" log "Skipped room '${roomData.roomName}' - no matching device found", 2 } } return results } def matchDeviceByRoomId(roomId) { return getChildDevices().find { device -> device.hasAttribute('percent-open') && device.currentValue('room-id') == roomId } } def matchDeviceByRoomName(roomName) { return getChildDevices().find { device -> device.hasAttribute('percent-open') && device.currentValue('room-name') == roomName } } def efficiencyDataPage() { // Auto-generate export data on page load def vents = getChildDevices().findAll { it.hasAttribute('percent-open') } def roomsWithData = vents.findAll { (it.currentValue('room-cooling-rate') ?: 0) > 0 || (it.currentValue('room-heating-rate') ?: 0) > 0 } // Automatically generate JSON data when page loads def exportJsonData = "" if (roomsWithData.size() > 0) { try { def efficiencyData = exportEfficiencyData() exportJsonData = generateEfficiencyJSON(efficiencyData) } catch (Exception e) { log "Error generating export data: ${e.message}", 2 } } dynamicPage(name: 'efficiencyDataPage', title: '🔄 Backup & Restore Efficiency Data', install: false, uninstall: false) { section { paragraph '''

📚 What is this?

Your Flair vents learn how efficiently each room heats and cools over time. This data helps the system optimize energy usage. Use this page to backup your data before app updates or restore it after system resets.

''' } // Show current status if (vents.size() > 0) { section("📊 Current Status") { if (roomsWithData.size() > 0) { paragraph "
✓ Your system has learned efficiency data for ${roomsWithData.size()} out of ${vents.size()} rooms
" } else { paragraph "
⚠ Your system is still learning (${vents.size()} rooms found, but no efficiency data yet)
" paragraph "Let your system run for a few heating/cooling cycles before backing up data." } } } // Export Section - Auto-generated if (roomsWithData.size() > 0 && exportJsonData) { section("💾 Save Your Data (Backup)") { // Create base64 encoded download link with current date def currentDate = new Date().format("yyyy-MM-dd") def fileName = "Flair-Backup-${currentDate}.json" def base64Data = exportJsonData.bytes.encodeBase64().toString() def downloadUrl = "data:application/json;charset=utf-8;base64,${base64Data}" paragraph "Your backup data is ready:" paragraph "📥 Download ${fileName}" } } else if (vents.size() > 0) { section("💾 Save Your Data (Backup)") { paragraph "System is still learning. Check back after a few heating/cooling cycles." } } // Import Section section("📥 Step 2: Restore Your Data (Import)") { paragraph '''

When should I do this?

• After reinstalling this app
• After resetting your Hubitat hub
• After replacing hardware

''' paragraph '''

How to restore your data:

1. Find your saved backup JSON file (e.g., "Flair-Backup-2025-06-26.json")
2. Open the JSON file in Notepad/TextEdit
3. Select all text (Ctrl+A) and copy (Ctrl+C)
4. Paste it in the box below (Ctrl+V)
5. Click "Restore My Data"

Note: Hubitat doesn't support file uploads, so we need to copy/paste the JSON content.

''' input name: 'importJsonData', type: 'textarea', title: 'Paste JSON Backup Data', description: 'Open your backup JSON file and paste ALL the content here', required: false, rows: 8 input name: 'importEfficiencyData', type: 'button', title: 'Restore My Data', submitOnChange: true, width: 4 if (state.importStatus) { def statusColor = state.importSuccess ? 'green' : 'red' def statusIcon = state.importSuccess ? '✓' : '✗' paragraph "
${statusIcon} ${state.importStatus}
" if (state.importSuccess) { paragraph '''

🎉 Success! What happens now?

Your room learning data has been restored. Your Flair vents will now use the saved efficiency information to:

You're all set! The system will continue learning and improving from this restored baseline.

''' } } } // Help & Tips Section section("❓ Need Help?") { paragraph '''

💡 Tips for Success

🚨 Troubleshooting

''' } section { href name: 'backToMain', title: '← Back to Main Settings', description: 'Return to the main app configuration', page: 'mainPage' } } } // ------------------------------ // End of Core Functions // ------------------------------