[Release] PurpleAir Local Driver with retry logic

This is a modified version of the PurpleAir Local Driver originally written by @sidjohn1.

As with his driver:

[it] pulls Temperature, Humidity, Pressure, Dew Point, AQI, RSSI as well as PM1.0, PM2.5, PM10 an VOC particle reading directly from the device , no internet connection or cloud needed.
Devices with dual lasers both values will be averaged

I have modified it to implement retry logic with timeout handling. More details:

  • Name changed to "PurpleAir AQI Local V2"
  • httpGet changed from synchronous call to asynchronous call to reduce resource contention if there is a delay in the response.
  • Added an option to retry the httpGet every 10 seconds if it fails. Default is 4 retries.
  • Automatically limits retry count to 3 if update frequency is 1-minute; limits return count of 6 if update frequency is 2-minutes.
  • Clarified that the "real time" parameter is 2 min average when off; real time when on.
  • Set aqiMessage & aqiDisplay to "Error" if all retries fail.
  • Dual sensor devices will report the AQI of the working sensor if one of the 2 sensors reports null. If both report null, it will return an AQI of "0" instead of null.

Marc

/**
*
*  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.
*
*  PurpleAir AQI Local
*
*  Author: Sidney Johnson through version 1.4
*		   Marc Aronson & curson.com from v1.5 on. 
*
*  Date: 2024-06-03
*
*  1.0 - Initial Release
*  1.1 - Corrected pressure reporting to mBar
*  1.2 - Added aqiDisplay, for dual laser model rounded AQI to a whole number after averaging, formatting tweaks, added temp and humidity adjustments, changed aqimessage to aqiMessage.
*  1.3 - Added voc, vocDisplay and vocMessage. AQI Clean up.
*  1.4 - Code clean up and refactoring.
*  1.5 - Enhanced with asynchronous HTTP GET and retry logic with timeout handling. Set aqiMessage & aqiDisplay to "Error" if all retries fail.
*  1.6 - If one sensor returns NULL, AQI return the value of the other sensor. If both sensors return NULL, AQI returns 0 instead of NULL. Retry time reduced from 10 to 8 seconds.
*
*/

import java.math.BigDecimal

metadata {
    definition(name: "PurpleAir AQI Local with retry", namespace: "sidjohn1", author: "Sidney Johnson, Marc Aronson") {
        capability "Temperature Measurement"
        capability "Relative Humidity Measurement"
        capability "Air Quality"
        capability "Pressure Measurement"
        capability "Signal Strength"
        capability "Sensor"
        capability "Polling"

        attribute "pressure", "number"          // Current pressure in Millibars
        attribute "dewPoint", "number"          // °F
        attribute "aqi", "number"               // AQI (0-500)
        attribute "aqiDisplay", "string"        // AQI + short danger level
        attribute "aqiMessage", "string"        // AQI danger level
        attribute "pm01", "number"              // µg/m³ - PM1.0 particle reading - current
        attribute "pm25", "number"              // µg/m³ - PM2.5 particle reading - current
        attribute "pm10", "number"              // µg/m³ - PM10 particle reading - current
        attribute "voc", "number"               // IAQ - Index for Air Quality (0-500)
        attribute "vocDisplay", "string"        // IAQ + short danger level
        attribute "vocMessage", "string"        // IAQ danger level
        attribute "rssi", "string"              // Signal Strength attribute
        attribute "timestamp", "string"         //
    }
}

preferences {
    section("URIs") {
        input "ipAddress", "text", title: "Local IP Address", required: true
        input name: "realTime", type: "bool", title: "off=2 min average, on=Real Time", defaultValue: false
        input name: "updateMins", type: "enum", title: "Update frequency (minutes)", description: "Default: 5<br>Disabled: 0", defaultValue: '5', options: ['0', '1', '2', '5', '10', '15', '30'], required: true
        input name: "retryCnt", type: "number", title: "Retry count", description: "Number of retry attempts if httpGet fails<br>Default: 4", range: "0..10", defaultValue: 5, displayDuringSetup: true, required: true
        input name: "temperatureAdj", type: "number", title: "Temperature Calibration", description: "Adjust temperature up/down in °F [(-50)-(+50)]<br>Default: -8", range: "-50..50", defaultValue: -8, displayDuringSetup: true, required: true
        input name: "humidityAdj", type: "number", title: "Humidity Calibration", description: "Adjust humidity up/down in percent [(-50)-(+50)]<br>Default: 4", range: "-50..50", defaultValue: 4, displayDuringSetup: true, required: true
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
    }
}

def logsOff() {
    log.warn "debug logging disabled..."
    device.updateSetting("logEnable", [value: "false", type: "bool"])
}

def updated() {
    unschedule()
    log.info "Device updated..."
    log.warn "debug logging is: ${logEnable}"
    realTime = realTime ?: false
    if (logEnable) runIn(1800, logsOff)
    if (updateMins != "0") {
        Random rand = new Random()
        int randomSeconds = rand.nextInt(49)
        schedule("${randomSeconds} */${updateMins} * ? * *", poll)
    }
}

def parse(String description) {
    if (logEnable) log.debug(description)
}

def poll() {
    if (logEnable) log.debug "Device polling..."
    def url = "http://${ipAddress}/json?live=${realTime}"
    if (logEnable) log.debug url
    
    // Start the retry process with attempt 1
    pollWithRetry(url, 1)
}

def pollWithRetry(url, attempt) {
    retryCnt = [retryCnt, updateMins.toLong() * 3].min()
    if (logEnable) log.debug "Attempt ${attempt} of ${retryCnt} for PurpleAir request"
    
    def params = [
        uri: url,
        timeout: 9,  // 9 second timeout
        contentType: "application/json"
    ]
    
    try {
        asynchttpGet("handleHttpResponse", params, [attempt: attempt, url: url])
    } catch (Exception e) {
        log.error "Error occurred during asynchttpGet attempt ${attempt}: ${e.message}"
        handleRetry(attempt, url, e.message)
    }
}

def handleHttpResponse(response, data) {
    def attempt = data.attempt
    def url = data.url
    
    if (logEnable) log.debug "HTTP Response received for attempt ${attempt}"
    
    if (response?.status == 200) {
        if (logEnable) log.debug "Successful response: ${response.getData()}"
        try {
            // Parse the JSON response data
            def jsonData = response.getJson()
            processResponse(jsonData)
        } catch (Exception e) {
            log.error "Error parsing JSON response (attempt ${attempt}): ${e.message}"
            handleRetry(attempt, url, "JSON Parse Error: ${e.message}")
        }
    } else {
        log.error "Invalid response for PurpleAir request (attempt ${attempt}): ${response?.status} - ${response?.errorMessage}"
        handleRetry(attempt, url, "HTTP ${response?.status}: ${response?.errorMessage}")
    }
}

def handleRetry(attempt, url, errorMessage) {
    retryCnt = [retryCnt, updateMins.toLong() * 3].min()
    if (attempt < retryCnt) {
        def nextAttempt = attempt + 1
        log.warn "PurpleAir request failed (attempt ${attempt}): ${errorMessage}. Retrying in 8 seconds..."
        if (logEnable) log.debug "Scheduling retry attempt ${nextAttempt} in 8 seconds"
        runIn(8, "retryPoll", [data: [url: url, attempt: nextAttempt]])
    } else {
        log.error "PurpleAir request failed after ${retryCnt} attempts. Last error: ${errorMessage}. Giving up."
        sendEvent(name: 'aqiMessage', value: "Error - httpget failed")
        sendEvent(name: 'aqiDisplay', value: "Error")

    }
}

def retryPoll(data) {
    def url = data.url
    def attempt = data.attempt
    if (logEnable) log.debug "Executing retry attempt ${attempt}"
    pollWithRetry(url, attempt)
}

def processResponse(data) {
    calibrateTempHum('temperature', data?.current_temp_f, settings.temperatureAdj, '°F', 'Temperature Calibrated')
    calibrateTempHum('humidity', data?.current_humidity, settings.humidityAdj, '%', 'Humidity Calibrated')
    sendEvent(name: 'dewPoint', value: data?.current_dewpoint_f, unit: '°F')
    sendEvent(name: 'pressure', value: data?.pressure, unit: 'mBar')
    processAQI(data)
    processPM(data, 'pm01', 'pm1_0_atm', 'pm1_0_atm_b')
    processPM(data, 'pm25', 'pm2_5_atm', 'pm2_5_atm_b')
    processPM(data, 'pm10', 'pm10_0_atm', 'pm10_0_atm_b')
    processVOC(data?.gas_680)
    sendEvent(name: 'rssi', value: data?.rssi, unit: 'db')
    sendEvent(name: 'timestamp', value: data?.DateTime, displayed: false)
}

def calibrateTempHum(name, value, adjustment, unit, logMessage) {
    if (adjustment) {
        def adjustedValue = (value.toInteger() + adjustment.toInteger())
        sendEvent(name: name, value: adjustedValue, unit: unit)
        if (logEnable) log.debug "${logMessage}: ${value} + ${adjustment} = ${adjustedValue}"
    } else {
        sendEvent(name: name, value: value, unit: unit)
    }
}

def processAQI(data) {
    def aqi = calculateAverage(data?."pm2.5_aqi", data?."pm2.5_aqi_b", 0)
    sendEvent(name: 'aqi', value: aqi)
    def (display, message) = getAQIMessage(aqi)
    sendEvent(name: 'aqiDisplay', value: display)
    sendEvent(name: 'aqiMessage', value: message)
}

def processPM(data, name, value1, value2) {
    def pm = calculateAverage(data[value1], data[value2], 2)
    sendEvent(name: name, value: pm, unit: 'µg/m³')
}

def processVOC(voc) {
    if (voc) {
        sendEvent(name: 'voc', value: voc)
        def (display, message) = getVOCMessage(voc)
        sendEvent(name: 'vocDisplay', value: display)
        sendEvent(name: 'vocMessage', value: message)
    } else if (logEnable) {
        log.debug "VOC Not Detected"
    }
}

def calculateAverage(value1, value2, scale) {
    if (value1 && value2) {
        return ((value1.toBigDecimal() + value2.toBigDecimal()) / 2.0).setScale(scale, BigDecimal.ROUND_HALF_UP)
    } else if (value1) {
        return value1.toBigDecimal().setScale(scale, BigDecimal.ROUND_HALF_UP)
    } else if (value2) {
        return value2.toBigDecimal().setScale(scale, BigDecimal.ROUND_HALF_UP)
    }
    return 0
}

def getAQIMessage(aqi) {
    if (aqi < 51) {
        return ["${aqi} - GOOD", "GOOD: little to no health risk"]
    } else if (aqi < 101) {
        return ["${aqi} - MODERATE", "MODERATE: slight risk for some people"]
    } else if (aqi < 151) {
        return ["${aqi} - UNHEALTHY", "UNHEALTHY: for sensitive groups"]
    } else if (aqi < 201) {
        return ["${aqi} - UNHEALTHY", "UNHEALTHY: for most people"]
    } else if (aqi < 301) {
        return ["${aqi} - VERY UNHEALTHY", "VERY UNHEALTHY: serious effects for everyone"]
    } else if (aqi < 501) {
        return ["${aqi} - HAZARDOUS", "HAZARDOUS: emergency conditions for everyone"]
    }
    return ["${aqi} - UNKNOWN", "UNKNOWN: invalid reading"]
}

def getVOCMessage(voc) {
    if (voc < 51) {
        return ["${voc} - EXCELLENT", "EXCELLENT: pure air, best for well-being"]
    } else if (voc < 101) {
        return ["${voc} - GOOD", "GOOD: no irritation or impact on well-being"]
    } else if (voc < 151) {
        return ["${voc} - LIGHTLY POLLUTED", "LIGHTLY POLLUTED: Ventilation suggested"]
    } else if (voc < 201) {
        return ["${voc} - MODERATELY POLLUTED", "MODERATELY POLLUTED: Ventilate with clean air"]
    } else if (voc < 251) {
        return ["${voc} - HEAVILY POLLUTED", "HEAVILY POLLUTED: Optimize ventilation"]
    } else if (voc < 351) {
        return ["${voc} - SEVERELY POLLUTED", "SEVERELY POLLUTED: Maximize ventilation & reduce attendance"]
    } else if (voc < 501) {
        return ["${voc} - EXTREMELY POLLUTED", "EXTREMELY POLLUTED: Maximize ventilation & avoid attendance"]
    }
    return ["${voc} - UNKNOWN", "UNKNOWN: invalid reading"]
}