Custom driver for Sonoff SNZB-02DR2 Temperature and Humidity Sensor (BETA 2)

Below is a custom driver for the Sonoff SNZB-02DR2 (see image below). This device has native min/max temp and humidity data; the driver also computes min/max values with a timestamp.

EDIT 2026-06-04: my coding assistant, Claude AI, made a silly error in the prior version, but fortunately my code review assistant, ChatGPT, found the error and Claude fixed it. So “Comfort Temp Min” now shows real data, and several of the other data fields have been corrected (they were off by one slot in Current States). However, we determined that the “Reset Device Min Max” button could not work ever, so it has been removed.

Notes:

  • The device, through its native app, can be used to report the T&H data of a compatible remote Sonoff sensor; that capability is likely not going to be available via Zigbee.
  • I’m still testing whether the SNZB-02DR2 behaves well in a Zigbee mesh. It has dropped off a couple of times in the past 2 days, requiring re-pairing, but I was pushing its back button a lot to force refreshes. I’m going to wait a week or so to see if it is stable before buying more.

/* groovylint-disable CompileStatic, LineLength, NoDef, VariableTypeRequired */
/**
 *  Sonoff SNZB-02DR2 Temperature and Humidity Sensor (AirGuard TH)
 *
 *  Hubitat driver for the Sonoff SNZB-02DR2 Zigbee temperature/humidity sensor.
 *
 *  Features:
 *    - Temperature and humidity reporting (clusters 0x0402 / 0x0405)
 *    - Battery percentage reporting (cluster 0x0001)
 *    - Health status monitoring
 *    - Software-tracked min/max temperature and humidity (mirrors the device LCD 24-hr extremes)
 *    - Read/write of FC11 comfort-zone thresholds (trigger ❄️ and 🔥 icons on the LCD)
 *    - Temperature unit setting (°C / °F) via FC11 cluster
 *    - Temperature and humidity calibration offsets via FC11 cluster
 *    - Configurable reporting intervals and sensitivity thresholds
 *    - Reset of tracked min/max values via command
 *
 *  Important notes about min/max:
 *    The 24-hour min/max values shown on the physical LCD display are calculated
 *    entirely inside the device firmware and are NOT transmitted over Zigbee.
 *    This driver tracks its own running min/max from every temperature and humidity
 *    report it receives, which will closely mirror the device display.  Use the
 *    "Reset Min/Max" command to start a fresh tracking period.
 *
 *  FC11 cluster comfort thresholds:
 *    The device shows ❄️ when temperature < comfortTempMin
 *    The device shows 🔥 when temperature > comfortTempMax
 *    These are writable via this driver's preferences.
 *    NOTE: The device is a sleepy end-device. Wake it up (press the back button)
 *    before saving preferences that write to FC11, otherwise the write will time out.
 *
 *  Licensed under the Apache License, Version 2.0
 *
 *  Changelog:
 *    1.0.0  2025-06-02  Initial release
 *    1.1.0  2026-06-02  Added Min/Max Reset timestamp and Readings Since Reset counter
 *    1.2.0  2026-06-02  Decode FC11 0x2008-0x200D device-native 24-hr min/max; fix null display
 *    1.6.0  2026-06-02  Add comfortTempMin sentinel; add resetDeviceMinMax command
 *    1.7.0  2026-06-03  Added scheduled auto-reset of driver min/max at a user-specified time
 *    1.8.0  2026-06-03  Embed occurrence timestamp in Temperature/Humidity Min/Max values
 *    1.9.0  2026-06-04  Fix FC11 attribute map (comfortTempMin was 0x0002, correct is 0x0004;
 *                       tempUnit moves from 0x0001 to 0x0007; calibration moves to 0x2003/0x2004).
 *                       Replace resetDeviceMinMax() with readDeviceMinMax() — device 24-hr
 *                       min/max is firmware-maintained and not remotely resettable.
 *    1.10.0 2026-06-04  Remove readDeviceMinMax() — fully redundant with Refresh.
 */

import groovy.transform.Field
import hubitat.zigbee.zcl.DataType

@Field static final String VERSION     = '1.10.0'
@Field static final String TIME_STAMP  = '2026/06/04'

// Cluster constants
@Field static final Integer CLUSTER_POWER_CFG  = 0x0001
@Field static final Integer CLUSTER_TEMP       = 0x0402
@Field static final Integer CLUSTER_HUMIDITY   = 0x0405
@Field static final Integer CLUSTER_FC11       = 0xFC11   // Sonoff private cluster

// FC11 attribute IDs — verified against zigbee-herdsman-converters sonoff.ts
// (https://github.com/Koenkk/zigbee-herdsman-converters/blob/master/src/devices/sonoff.ts)
// NOTE: 0x0001 and 0x0002 are NOT used by SNZB-02DR2 comfort attributes.
@Field static final Integer FC11_ATTR_COMFORT_TEMP_MAX = 0x0003  // INT16,  value * 100 (°C × 100)
@Field static final Integer FC11_ATTR_COMFORT_TEMP_MIN = 0x0004  // INT16,  value * 100 (°C × 100)
@Field static final Integer FC11_ATTR_COMFORT_HUMI_MIN = 0x0005  // UINT16, value * 100 (% × 100)
@Field static final Integer FC11_ATTR_COMFORT_HUMI_MAX = 0x0006  // UINT16, value * 100 (% × 100)
@Field static final Integer FC11_ATTR_TEMP_UNIT        = 0x0007  // UINT8,  0=Celsius 1=Fahrenheit
@Field static final Integer FC11_ATTR_TEMP_CALIBRATION = 0x2003  // INT16,  offset * 100
@Field static final Integer FC11_ATTR_HUMI_CALIBRATION = 0x2004  // INT16,  offset * 100

// FC11 device-native 24-hr min/max (confirmed by Zigbee sniff, unsolicited ~hourly)
// Declared as int (not Integer) so if/else comparisons work reliably in Groovy
@Field static final int FC11_MAX_TEMP  = 0x2008  // INT16  max temp x100 degC
@Field static final int FC11_MIN_TEMP  = 0x2009  // INT16  min temp x100 degC
@Field static final int FC11_PREV_TEMP = 0x200A  // INT16  reference temp
@Field static final int FC11_MAX_HUMI  = 0x200B  // UINT16 max humidity x100 %
@Field static final int FC11_MIN_HUMI  = 0x200C  // UINT16 min humidity x100 %
@Field static final int FC11_PREV_HUMI = 0x200D  // UINT16 reference humidity

@Field static final Integer PRESENCE_THRESHOLD = 4   // missed health-check cycles before "offline"
@Field static final Integer PING_TIMEOUT_MS    = 10000

metadata {
    definition(
        name:      'Sonoff SNZB-02DR2 Temperature and Humidity Sensor',
        namespace: 'v. 1.10.0',
        author:    'John Land',
        importUrl: ''
    ) {
        capability 'Sensor'
        capability 'TemperatureMeasurement'
        capability 'RelativeHumidityMeasurement'
        capability 'Battery'
        capability 'Refresh'
        capability 'Health Check'

        // Running min/max tracked in software
        attribute 'Humidity Max',         'string'
        attribute 'Humidity Min',         'string'
        attribute 'Temperature Max',      'string'
        attribute 'Temperature Min',      'string'

        attribute 'Min/Max Reset',        'string'
        attribute 'Last Reading',         'string'
        attribute 'Readings Since Reset', 'number'

        // Device-native 24-hr min/max from FC11 cluster (populated by device firmware)
        // Declared as string so a '--' placeholder shows while waiting for first report
        attribute 'Device Humidity Max',  'string'
        attribute 'Device Humidity Min',  'string'
        attribute 'Device Temp Max',      'string'
        attribute 'Device Temp Min',      'string'

        // Comfort zone thresholds (readable state, set via preferences)
        attribute 'comfortTempMin',       'string'
        attribute 'comfortTempMax',       'string'
        attribute 'comfortHumiMin',       'string'
        attribute 'comfortHumiMax',       'string'
   
        attribute 'healthStatus',         'enum', ['unknown', 'online', 'offline']
        attribute 'rtt',                  'number'

        command 'resetMinMax',       [[name: 'Reset the Hubitat-tracked min/max temperature and humidity values']]
        command 'initialize',        [[name: 'Re-initialize driver state and re-configure device reporting']]

        fingerprint profileId: '0104',
                    endpointId: '01',
                    inClusters: '0000,0001,0003,0402,0405,0020,FC57,FC11',
                    outClusters: '000A,0019',
                    model: 'SNZB-02DR2',
                    manufacturer: 'SONOFF',
                    deviceJoinName: 'Sonoff SNZB-02DR2 AirGuard TH'

        fingerprint profileId: '0104',
                    endpointId: '01',
                    inClusters: '0000,0001,0003,0402,0405,0020,FC57,FC11',
                    outClusters: '000A,0019',
                    model: 'SNZB-02DR2',
                    manufacturer: 'eWeLink',
                    deviceJoinName: 'Sonoff SNZB-02DR2 AirGuard TH'
    }

    preferences {
        input name: 'logEnable',
              type: 'bool',
              title: '<b>Debug logging</b>',
              description: 'Enable debug log output. Automatically disabled after 24 hours.',
              defaultValue: true

        input name: 'txtEnable',
              type: 'bool',
              title: '<b>Description text logging</b>',
              description: 'Log sensor readings in the Events log.',
              defaultValue: true

        input name: 'minReportTemp',
              type: 'number',
              title: '<b>Min time between temperature reports (seconds)</b>',
              description: 'Minimum reporting interval for temperature.',
              defaultValue: 10,
              range: '1..3600'

        input name: 'maxReportTemp',
              type: 'number',
              title: '<b>Max time between temperature reports (seconds)</b>',
              description: 'Maximum reporting interval (heartbeat) for temperature.',
              defaultValue: 3600,
              range: '10..43200'

        input name: 'tempChange',
              type: 'decimal',
              title: '<b>Temperature reporting threshold (°C)</b>',
              description: 'Minimum change to trigger a temperature report.',
              defaultValue: 0.2,
              range: '0.1..5.0'

        input name: 'minReportHumi',
              type: 'number',
              title: '<b>Min time between humidity reports (seconds)</b>',
              description: 'Minimum reporting interval for humidity.',
              defaultValue: 10,
              range: '1..3600'

        input name: 'maxReportHumi',
              type: 'number',
              title: '<b>Max time between humidity reports (seconds)</b>',
              description: 'Maximum reporting interval (heartbeat) for humidity.',
              defaultValue: 3600,
              range: '10..43200'

        input name: 'humiChange',
              type: 'number',
              title: '<b>Humidity reporting threshold (%)</b>',
              description: 'Minimum change to trigger a humidity report.',
              defaultValue: 1,
              range: '1..10'

        input name: 'temperatureOffset',
              type: 'decimal',
              title: '<b>Temperature offset (°C)</b>',
              description: 'Offset applied to every temperature reading in Hubitat (does not write to device).',
              defaultValue: 0.0,
              range: '-20.0..20.0'

        input name: 'humidityOffset',
              type: 'decimal',
              title: '<b>Humidity offset (%)</b>',
              description: 'Offset applied to every humidity reading in Hubitat (does not write to device).',
              defaultValue: 0.0,
              range: '-20.0..20.0'

        input name: 'pref_comfortTempMin',
              type: 'decimal',
              title: '<b>Comfort temperature minimum (°C)</b>',
              description: 'Device shows ❄️ below this value. Always enter in °C regardless of hub scale. Wake device before saving.',
              defaultValue: 18.0,
              range: '-10..60'

        input name: 'pref_comfortTempMax',
              type: 'decimal',
              title: '<b>Comfort temperature maximum (°C)</b>',
              description: 'Device shows 🔥 above this value. Always enter in °C regardless of hub scale. Wake device before saving.',
              defaultValue: 26.0,
              range: '-10..60'

        input name: 'pref_comfortHumiMin',
              type: 'number',
              title: '<b>Comfort humidity minimum (%)</b>',
              description: 'Lower end of the comfort humidity range. Range 0 to 99.',
              defaultValue: 40,
              range: '0..99'

        input name: 'pref_comfortHumiMax',
              type: 'number',
              title: '<b>Comfort humidity maximum (%)</b>',
              description: 'Upper end of the comfort humidity range. Range 1 to 100.',
              defaultValue: 60,
              range: '1..100'

        input name: 'autoResetEnable',
              type: 'bool',
              title: '<b>Auto-reset min/max daily</b>',
              description: 'When enabled, the driver-tracked min/max values are reset automatically each day at the time set below.',
              defaultValue: false

        input name: 'autoResetTime',
              type: 'time',
              title: '<b>Auto-reset time</b>',
              description: 'Time of day at which the driver min/max is automatically reset (only used when auto-reset is enabled).',
              defaultValue: '00:00'
    }
}

// ── Lifecycle ────────────────────────────────────────────────────────────────

def installed() {
    logInfo 'installed()'
    initVars(true)
    configure()
}

def updated() {
    logInfo 'updated()'
    if (logEnable) { runIn(86400, 'logsOff') }

    // Schedule or cancel the daily auto-reset
    rescheduleAutoReset()

    // Write FC11 comfort thresholds to device (device must be awake)
    List<String> cmds = []
    cmds += writeFC11ComfortThresholds()
    if (cmds) { sendZigbeeCommands(cmds) }
}

def configure() {
    logInfo 'configure()'
    List<String> cmds = []

    // Configure temperature reporting
    Integer tempDelta = Math.round((safeDouble(tempChange, 0.2) * 100) as double) as Integer
    cmds += zigbee.configureReporting(CLUSTER_TEMP, 0x0000, DataType.INT16,
        safeInt(minReportTemp, 10), safeInt(maxReportTemp, 3600), tempDelta, [:], 200)

    // Configure humidity reporting
    Integer humiDelta = Math.round((safeDouble(humiChange, 1.0) * 100) as double) as Integer
    cmds += zigbee.configureReporting(CLUSTER_HUMIDITY, 0x0000, DataType.UINT16,
        safeInt(minReportHumi, 10), safeInt(maxReportHumi, 3600), humiDelta, [:], 200)

    // Configure battery reporting
    cmds += zigbee.configureReporting(CLUSTER_POWER_CFG, 0x0021, DataType.UINT8,
        60, 14400, 1, [:], 200)

    // Read current values
    cmds += zigbee.readAttribute(CLUSTER_TEMP,      0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_HUMIDITY,  0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_POWER_CFG, 0x0021, [:], 200)

    // Read current FC11 comfort thresholds
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MIN, [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MAX, [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MIN, [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MAX, [mfgCode: '0x1286'], 200)

    scheduleHealthCheck()
    sendZigbeeCommands(cmds)
    runIn(2, 'updated')
}

def initialize() {
    logInfo 'initialize()'
    unschedule()
    initVars(true)
    configure()
}

def refresh() {
    logInfo 'refresh()'
    List<String> cmds = []
    cmds += zigbee.readAttribute(CLUSTER_TEMP,      0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_HUMIDITY,  0x0000, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_POWER_CFG, 0x0021, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_POWER_CFG, 0x0020, [:], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MIN, [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MAX, [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MIN, [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MAX, [mfgCode: '0x1286'], 200)
    // Read device-native min/max (FC11 0x2008-0x200D)
    // These are normally pushed unsolicited ~hourly; reading them explicitly
    // lets Refresh populate them without waiting for the next automatic report
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MAX_TEMP,  [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MIN_TEMP,  [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MAX_HUMI,  [mfgCode: '0x1286'], 200)
    cmds += zigbee.readAttribute(CLUSTER_FC11, FC11_MIN_HUMI,  [mfgCode: '0x1286'], 200)
    sendZigbeeCommands(cmds)
}

def ping() {
    logDebug 'ping()'
    state.pingTime = now()
    scheduleCommandTimeout()
    sendZigbeeCommands(zigbee.readAttribute(0x0000, 0x0001, [:], 0))
}

// ── Commands ─────────────────────────────────────────────────────────────────

def resetMinMax() {
    logInfo 'Resetting min/max tracking'
    state.minTemp        = null
    state.maxTemp        = null
    state.minHumi        = null
    state.maxHumi        = null
    state.readingCount   = 0
    String ts = new Date().format('yyyy-MM-dd HH:mm:ss', location.timeZone)
    sendEvent(name: 'Min/Max Reset',        value: ts,   descriptionText: "Min/max reset at ${ts}")
    sendEvent(name: 'Readings Since Reset', value: 0,    descriptionText: 'Reading counter reset to 0')
    sendEvent(name: 'Temperature Min',      value: '--', descriptionText: 'Min temperature reset')
    sendEvent(name: 'Temperature Max',      value: '--', descriptionText: 'Max temperature reset')
    sendEvent(name: 'Humidity Min',         value: '--', descriptionText: 'Min humidity reset')
    sendEvent(name: 'Humidity Max',         value: '--', descriptionText: 'Max humidity reset')
}

// ── Auto-reset scheduling ─────────────────────────────────────────────────────

private void rescheduleAutoReset() {
    unschedule('autoResetMinMax')
    if (!autoResetEnable) {
        logInfo 'Auto-reset disabled'
        return
    }
    // Parse the user-supplied time preference and build a daily cron expression
    try {
        def t = timeToday(autoResetTime, location.timeZone)
        String cronExpr = "0 ${t.minutes} ${t.hours} * * ? *"
        schedule(cronExpr, 'autoResetMinMax')
        logInfo "Auto-reset scheduled daily at ${autoResetTime} (cron: ${cronExpr})"
    } catch (e) {
        logWarn "Auto-reset scheduling failed: ${e.message}"
    }
}

def autoResetMinMax() {
    logInfo 'Auto-reset: resetting driver min/max tracking'
    resetMinMax()
}

// ── Parse ─────────────────────────────────────────────────────────────────────

def parse(String description) {
    setPresent()

    if (!(description?.startsWith('catchall:') || description?.startsWith('read attr -'))) {
        logDebug "unhandled description: ${description}"
        return
    }

    Map descMap = zigbee.parseDescriptionAsMap(description)
    logDebug "parse() descMap = ${descMap}"

    switch (descMap.clusterInt as Integer) {
        case CLUSTER_POWER_CFG:
            parsePowerCluster(descMap)
            break
        case CLUSTER_TEMP:
            parseTempCluster(descMap)
            break
        case CLUSTER_HUMIDITY:
            parseHumiCluster(descMap)
            break
        case CLUSTER_FC11:
            parseFC11Cluster(descMap)
            break
        case 0x0000:
            // Basic cluster – used for ping response
            if (descMap.attrInt == 0x0001 && descMap.value) {
                def elapsed = now() - (state.pingTime ?: 0)
                if (elapsed < PING_TIMEOUT_MS && elapsed > 0) {
                    unschedule('commandTimeout')
                    sendEvent(name: 'rtt', value: elapsed, unit: 'ms', descriptionText: "Round-trip time ${elapsed} ms")
                    logInfo "ping RTT = ${elapsed} ms"
                }
            }
            break
        default:
            logDebug "unhandled cluster: 0x${Integer.toHexString(descMap.clusterInt ?: 0)}"
            break
    }
}

// ── Cluster parsers ──────────────────────────────────────────────────────────

private void parsePowerCluster(Map descMap) {
    if (descMap.attrInt == 0x0021 && descMap.value && descMap.value.length() <= 8) {
        // Battery percentage (value is reported as twice the percentage)
        Integer raw = (Long.parseLong(descMap.value, 16) & 0xFF) as Integer
        Integer pct = Math.min(100, Math.round(raw / 2.0) as Integer)
        sendEvent(name: 'battery', value: pct, unit: '%',
                  descriptionText: "${device.displayName} battery is ${pct}%", type: 'physical')
        logInfo "battery ${pct}%"
    } else if (descMap.attrInt == 0x0020 && descMap.value && descMap.value.length() <= 8) {
        // Battery voltage (raw in 100 mV units)
        Integer raw = (Long.parseLong(descMap.value, 16) & 0xFF) as Integer
        double volts = raw / 10.0
        // Derive a percentage from voltage range 2.1V-3.0V
        Integer pct = Math.min(100, Math.max(1, Math.round(((volts - 2.1) / 0.9) * 100) as Integer))
        sendEvent(name: 'battery', value: pct, unit: '%',
                  descriptionText: "${device.displayName} battery ~${pct}% (${volts}V)", type: 'physical')
        logInfo "battery voltage ${volts}V (~${pct}%)"
    }
}

private void parseTempCluster(Map descMap) {
    // Accept attrInt 0x0000 from read-response or null from some catchall frames
    if (descMap.attrInt != null && descMap.attrInt != 0x0000) { return }
    if (!descMap.value || descMap.value.length() > 8) { return }
    Integer raw = (Long.parseLong(descMap.value, 16) & 0xFFFF) as Integer
    if (raw == 0x8000) { logDebug 'temperature: invalid sentinel value'; return }
    // Two's complement for negative temperatures
    if (raw > 32767) { raw = raw - 65536 }
    double tempC = raw / 100.0
    double tempAdjusted = tempC + safeDouble(temperatureOffset, 0.0)

    double display
    String unit
    if (location.temperatureScale == 'F') {
        display = Math.round(((tempAdjusted * 1.8) + 32 - 0.05) * 10) / 10
        unit = '\u00B0F'
    } else {
        display = Math.round((tempAdjusted - 0.05) * 10) / 10
        unit = '\u00B0C'
    }

    sendEvent(name: 'temperature', value: display, unit: unit,
              descriptionText: "${device.displayName} temperature is ${display}${unit}", type: 'physical')
    logInfo "temperature ${display}${unit}"
    updateMinMax('temperature', tempAdjusted)
    String ts = new Date().format('yyyy-MM-dd HH:mm:ss', location.timeZone)
    sendEvent(name: 'Last Reading', value: ts, descriptionText: "Last reading at ${ts}")
}

private void parseHumiCluster(Map descMap) {
    // Accept attrInt 0x0000 from read-response or null from some catchall frames
    if (descMap.attrInt != null && descMap.attrInt != 0x0000) { return }
    if (!descMap.value || descMap.value.length() > 8) { return }
    Integer raw = (Long.parseLong(descMap.value, 16) & 0xFFFF) as Integer
    double humiPct = raw / 100.0
    double humiAdjusted = humiPct + safeDouble(humidityOffset, 0.0)
    humiAdjusted = Math.max(0.0, Math.min(100.0, humiAdjusted))
    Integer display = Math.round(humiAdjusted) as Integer

    sendEvent(name: 'humidity', value: display, unit: '%',
              descriptionText: "${device.displayName} humidity is ${display}%", type: 'physical')
    logInfo "humidity ${display}%"
    updateMinMax('humidity', humiAdjusted)
}

private void parseFC11Cluster(Map descMap) {
    // Process additional attributes packed into the same frame
    if (descMap.additionalAttrs) {
        descMap.additionalAttrs.each { Map aa ->
            String v = aa.value as String
            if (v && v.length() <= 8) {
                parseFC11Attr(aa.attrInt as int, v)
            }
        }
    }
    String val = descMap.value as String
    if (!val || val.length() > 8) {
        if (val && val.length() > 8) { logDebug "FC11 skipping blob len=${val.length()}" }
        return
    }
    parseFC11Attr(descMap.attrInt as int, val)
}

// Separate method so attribute comparisons use plain int arithmetic (avoids Groovy
// switch/Integer boxing issues that silently fail for values > 0x00FF)
private void parseFC11Attr(int attrInt, String hexVal) {
    Integer raw = (Long.parseLong(hexVal, 16) & 0xFFFF) as Integer

    if (attrInt == (FC11_ATTR_TEMP_UNIT as int)) {
        logInfo "FC11 temperature unit: ${raw == 1 ? 'Fahrenheit' : 'Celsius'}"

    } else if (attrInt == (FC11_ATTR_COMFORT_TEMP_MIN as int)) {
        Integer signed = raw > 32767 ? raw - 65536 : raw
        double valC = signed / 100.0
        String display = formatTemp(valC)
        sendEvent(name: 'comfortTempMin', value: display,
                  descriptionText: "Comfort temp min = ${display} (${valC}\u00B0C device setting)")
        logInfo "FC11 comfort temp min = ${display}"

    } else if (attrInt == (FC11_ATTR_COMFORT_TEMP_MAX as int)) {
        Integer signed = raw > 32767 ? raw - 65536 : raw
        double valC = signed / 100.0
        String display = formatTemp(valC)
        sendEvent(name: 'comfortTempMax', value: display,
                  descriptionText: "Comfort temp max = ${display} (${valC}\u00B0C device setting)")
        logInfo "FC11 comfort temp max = ${display}"

    } else if (attrInt == (FC11_ATTR_COMFORT_HUMI_MIN as int)) {
        double val = raw / 100.0
        String humiMinDisplay = "${Math.round(val)}%"
        sendEvent(name: 'comfortHumiMin', value: humiMinDisplay,
                  descriptionText: "Comfort humidity min = ${humiMinDisplay}")
        logInfo "FC11 comfort humidity min = ${humiMinDisplay}"

    } else if (attrInt == (FC11_ATTR_COMFORT_HUMI_MAX as int)) {
        double val = raw / 100.0
        String humiMaxDisplay = "${Math.round(val)}%"
        sendEvent(name: 'comfortHumiMax', value: humiMaxDisplay,
                  descriptionText: "Comfort humidity max = ${humiMaxDisplay}")
        logInfo "FC11 comfort humidity max = ${humiMaxDisplay}"

    } else if (attrInt == (FC11_ATTR_TEMP_CALIBRATION as int)) {
        Integer signed = raw > 32767 ? raw - 65536 : raw
        logInfo "FC11 temp calibration = ${String.format('%.2f', signed / 100.0)}\u00B0C"

    } else if (attrInt == (FC11_ATTR_HUMI_CALIBRATION as int)) {
        Integer signed = raw > 32767 ? raw - 65536 : raw
        logInfo "FC11 humidity calibration = ${String.format('%.2f', signed / 100.0)}%"

    } else if (attrInt == FC11_MAX_TEMP) {
        Integer signed = raw > 32767 ? raw - 65536 : raw
        String display = formatTemp(signed / 100.0)
        sendEvent(name: 'Device Temp Max', value: display,
                  descriptionText: "Device max temp = ${display}")
        logInfo "FC11 device max temp = ${display}"

    } else if (attrInt == FC11_MIN_TEMP) {
        Integer signed = raw > 32767 ? raw - 65536 : raw
        String display = formatTemp(signed / 100.0)
        sendEvent(name: 'Device Temp Min', value: display,
                  descriptionText: "Device min temp = ${display}")
        logInfo "FC11 device min temp = ${display}"

    } else if (attrInt == FC11_PREV_TEMP) {
        Integer signed = raw > 32767 ? raw - 65536 : raw
        logInfo "FC11 0x200A ref temp = ${formatTemp(signed / 100.0)}"

    } else if (attrInt == FC11_MAX_HUMI) {
        String display = "${Math.round(raw / 100.0)}%"
        sendEvent(name: 'Device Humidity Max', value: display,
                  descriptionText: "Device max humidity = ${display}")
        logInfo "FC11 device max humidity = ${display}"

    } else if (attrInt == FC11_MIN_HUMI) {
        String display = "${Math.round(raw / 100.0)}%"
        sendEvent(name: 'Device Humidity Min', value: display,
                  descriptionText: "Device min humidity = ${display}")
        logInfo "FC11 device min humidity = ${display}"

    } else if (attrInt == FC11_PREV_HUMI) {
        logInfo "FC11 0x200D ref humidity = ${Math.round(raw / 100.0)}%"

    } else {
        logDebug "FC11 unhandled attr 0x${Integer.toHexString(attrInt)} = ${raw}"
    }
}

private String formatTemp(double tempC) {
    if (location.temperatureScale == 'F') {
        return "${Math.round((tempC * 1.8 + 32) * 10) / 10}\u00B0F"
    }
    return "${Math.round(tempC * 10) / 10}\u00B0C"
}

// ── Min/Max tracking ─────────────────────────────────────────────────────────

private void updateMinMax(String type, double value) {
    if (type == 'temperature') {
        // Increment the reading counter once per temp+humidity pair
        state.readingCount = (state.readingCount ?: 0) + 1
        sendEvent(name: 'Readings Since Reset', value: state.readingCount,
                  descriptionText: "${state.readingCount} readings since last reset")
        double rounded = Math.round(value * 10) / 10
        String unit = location.temperatureScale == 'F' ? '\u00B0F' : '\u00B0C'
        double displayVal = location.temperatureScale == 'F' ? Math.round(((value * 1.8) + 32) * 10) / 10 : rounded

        if (state.minTemp == null || value < (state.minTemp as double)) {
            state.minTemp = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Temperature Min', value: "${displayVal}${unit} @ ${ts}",
                      descriptionText: "Min temperature is ${displayVal}${unit} (at ${ts}, since last reset)")
            logInfo "New min temperature: ${displayVal}${unit} @ ${ts}"
        }
        if (state.maxTemp == null || value > (state.maxTemp as double)) {
            state.maxTemp = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Temperature Max', value: "${displayVal}${unit} @ ${ts}",
                      descriptionText: "Max temperature is ${displayVal}${unit} (at ${ts}, since last reset)")
            logInfo "New max temperature: ${displayVal}${unit} @ ${ts}"
        }
    } else if (type == 'humidity') {
        Integer roundedPct = Math.round(value) as Integer
        if (state.minHumi == null || value < (state.minHumi as double)) {
            state.minHumi = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Humidity Min', value: "${roundedPct}% @ ${ts}",
                      descriptionText: "Min humidity is ${roundedPct}% (at ${ts}, since last reset)")
            logInfo "New min humidity: ${roundedPct}% @ ${ts}"
        }
        if (state.maxHumi == null || value > (state.maxHumi as double)) {
            state.maxHumi = value
            String ts = new Date().format('MM-dd HH:mm', location.timeZone)
            sendEvent(name: 'Humidity Max', value: "${roundedPct}% @ ${ts}",
                      descriptionText: "Max humidity is ${roundedPct}% (at ${ts}, since last reset)")
            logInfo "New max humidity: ${roundedPct}% @ ${ts}"
        }
    }
}

// ── FC11 write helpers ───────────────────────────────────────────────────────

private List<String> writeFC11ComfortThresholds() {
    List<String> cmds = []

    // comfort_temperature_min — INT16, scaled × 100
    Integer tempMinRaw = Math.round(safeDouble(pref_comfortTempMin, 18.0) * 100) as Integer
    cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MIN,
            DataType.INT16, tempMinRaw, [mfgCode: '0x1286'], 200)

    // comfort_temperature_max — INT16, scaled × 100
    Integer tempMaxRaw = Math.round(safeDouble(pref_comfortTempMax, 26.0) * 100) as Integer
    cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_TEMP_MAX,
            DataType.INT16, tempMaxRaw, [mfgCode: '0x1286'], 200)

    // comfort_humidity_min — UINT16, scaled × 100
    Integer humiMinRaw = Math.round(safeInt(pref_comfortHumiMin, 40) * 100) as Integer
    cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MIN,
            DataType.UINT16, humiMinRaw, [mfgCode: '0x1286'], 200)

    // comfort_humidity_max — UINT16, scaled × 100
    Integer humiMaxRaw = Math.round(safeInt(pref_comfortHumiMax, 60) * 100) as Integer
    cmds += zigbee.writeAttribute(CLUSTER_FC11, FC11_ATTR_COMFORT_HUMI_MAX,
            DataType.UINT16, humiMaxRaw, [mfgCode: '0x1286'], 200)

    logDebug "Writing FC11 comfort thresholds: tempMin=${tempMinRaw / 100.0} tempMax=${tempMaxRaw / 100.0} humiMin=${humiMinRaw / 100.0} humiMax=${humiMaxRaw / 100.0}"
    return cmds
}

// ── Health check ──────────────────────────────────────────────────────────────

def setPresent() {
    if ((device.currentValue('healthStatus') ?: 'unknown') != 'online') {
        sendEvent(name: 'healthStatus', value: 'online', type: 'digital')
        logInfo 'device is present (online)'
    }
    state.missedChecks = 0
}

def deviceHealthCheck() {
    state.missedChecks = (state.missedChecks ?: 0) + 1
    if (state.missedChecks > PRESENCE_THRESHOLD) {
        if ((device.currentValue('healthStatus', true) ?: 'unknown') != 'offline') {
            sendEvent(name: 'healthStatus', value: 'offline', type: 'digital')
            logWarn 'device has not reported — marking offline'
        }
    } else {
        logDebug "health check — ok (missed=${state.missedChecks})"
    }
}

private void scheduleHealthCheck() {
    Random rnd = new Random()
    schedule("${rnd.nextInt(59)} ${rnd.nextInt(59)} 1/3 * * ? *", 'deviceHealthCheck')
    rescheduleAutoReset()
}

private void scheduleCommandTimeout() {
    runIn(10, 'commandTimeout')
}

void commandTimeout() {
    logWarn 'command timed out (no response from device)'
    sendEvent(name: 'rtt', value: 'timeout', unit: '', descriptionText: 'No ping response', type: 'digital')
}

// ── Init helpers ─────────────────────────────────────────────────────────────

private void initVars(boolean fullInit) {
    if (fullInit) {
        state.minTemp      = null
        state.maxTemp      = null
        state.minHumi      = null
        state.maxHumi      = null
        state.missedChecks = 0
        state.pingTime     = 0
        state.readingCount = 0
        String ts = new Date().format('yyyy-MM-dd HH:mm:ss', location.timeZone)
        sendEvent(name: 'healthStatus',         value: 'unknown')
        sendEvent(name: 'Last Reading',         value: '--', descriptionText: 'No reading yet')
        sendEvent(name: 'Min/Max Reset',        value: ts,   descriptionText: "Min/max reset at ${ts}")
        sendEvent(name: 'Readings Since Reset', value: 0,    descriptionText: 'Reading counter initialized')
        sendEvent(name: 'Temperature Min',      value: '--')
        sendEvent(name: 'Temperature Max',      value: '--')
        sendEvent(name: 'Humidity Min',         value: '--')
        sendEvent(name: 'Humidity Max',         value: '--')
        // Device-native min/max: '--' keeps them visible in Current States
        // while waiting for the device's hourly FC11 report
        sendEvent(name: 'Device Temp Max',      value: '--', descriptionText: 'Waiting for device report')
        sendEvent(name: 'Device Temp Min',      value: '--', descriptionText: 'Waiting for device report')
        sendEvent(name: 'Device Humidity Max',  value: '--', descriptionText: 'Waiting for device report')
        sendEvent(name: 'Device Humidity Min',  value: '--', descriptionText: 'Waiting for device report')
        // Comfort thresholds: also seed with '--' so they appear before first device report
        sendEvent(name: 'comfortTempMin',       value: '--')
        sendEvent(name: 'comfortTempMax',       value: '--')
        sendEvent(name: 'comfortHumiMin',       value: '--')
        sendEvent(name: 'comfortHumiMax',       value: '--')
    }
    state.driverVersion = "${VERSION} ${TIME_STAMP}"
}

// ── Utilities ─────────────────────────────────────────────────────────────────

private void sendZigbeeCommands(List<String> cmds) {
    if (!cmds) { return }
    logDebug "sendZigbeeCommands: ${cmds}"
    hubitat.device.HubMultiAction allActions = new hubitat.device.HubMultiAction()
    cmds.each { allActions.add(new hubitat.device.HubAction(it, hubitat.device.Protocol.ZIGBEE)) }
    sendHubCommand(allActions)
}

private double safeDouble(val, double defaultVal = 0.0) {
    try { return val != null ? (val as double) : defaultVal } catch (e) { return defaultVal }
}

private int safeInt(val, int defaultVal = 0) {
    try { return val != null ? (val as int) : defaultVal } catch (e) { return defaultVal }
}

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

private void logDebug(String msg) { if (logEnable) { log.debug "${device.displayName} ${msg}" } }
private void logInfo(String msg)  { if (txtEnable) { log.info  "${device.displayName} ${msg}" } }
private void logWarn(String msg)  {                  log.warn  "${device.displayName} ${msg}"   }

Below is a readme regarding the discovered characteristics of the Sonoff SNZB-02DR2 sensor, for those interested in exploring its capabilities and quirks:

=========================================================

Sonoff SNZB-02DR2 AirGuard TH — Hubitat Driver

Driver version: 1.10.0
Device: Sonoff SNZB-02DR2 ("AirGuard TH") Zigbee temperature and humidity sensor
Manufacturer/Model strings: SONOFF / SNZB-02DR2 or eWeLink / SNZB-02DR2
Firmware: Software Build ID 1.0.2, Date Code 20251020, ZCL version 3, HW version 16
Manufacturer code: 0x1286 = SHENZHEN COOLKIT (Sonoff/eWeLink)


Overview

The SNZB-02DR2 is a Zigbee sleepy end-device with a 3.6" LCD display showing current temperature, current humidity, 24-hour min/max extremes, comfort zone icons, and battery level. It uses a mix of standard Zigbee clusters and Sonoff's private FC11 and FC57 clusters for extended features.

This driver was developed through iterative Zigbee sniffing using the Knockturn Alley diagnostic driver. Several of the FC11 attributes described below were not previously documented for this specific device, and some behaviours remain under investigation.


Installation

  1. In Hubitat, go to Drivers Code → New Driver

  2. Paste the full driver code and click Save

  3. The driver's fingerprints will auto-match on pairing, or manually select Sonoff SNZB-02DR2 Temperature and Humidity Sensor from the device's Type dropdown


Device Characteristics

Property Value
Logical type Zigbee End Device (ZED) — not a router
Sleepy device Receiver off when idle; wakes to report then sleeps
Check-in interval 29 minutes (0x00001B30 = 1740 seconds)
Long poll interval 1740 seconds (same as check-in)
Short poll interval 1 second (while awake / fast polling)
Fast poll timeout 10 seconds after wake
Max buffer size 74 bytes
Physical sensor range −40°C to 115°C / battery reports 27–29 min cycle

Health check note: With a 29-minute check-in interval, the driver's health check (which fires every 3 hours and marks offline after 4 missed cycles) gives the device approximately 2 hours of grace before being flagged offline. This is appropriate for normal operation but means the device will not be marked offline quickly after a battery failure or signal loss.


Supported Capabilities

Capability Notes
TemperatureMeasurement Standard ZCL cluster 0x0402
RelativeHumidityMeasurement Standard ZCL cluster 0x0405
Battery Percentage from cluster 0x0001 attr 0x0021; voltage from attr 0x0020
Refresh Reads all current values and FC11 attributes on demand
Health Check Marks device offline after 4 missed health-check cycles
Sensor Base capability

Current States

All temperature values are displayed in the hub's configured scale (°F or °C).

Standard readings

Attribute Description
temperature Current temperature (standard capability attribute)
humidity Current relative humidity % (standard capability attribute)
battery Battery level %

Hubitat-tracked min/max

Calculated by the driver from the stream of incoming readings. Accumulate from driver install or last reset, independent of the device's own 24-hour window.

Each min/max value includes the timestamp of the occurrence embedded directly in the attribute string, in the format <value> @ <MM-dd HH:mm> (e.g. 21.4°C @ 06-03 14:22 or 58% @ 06-03 09:47). The timestamp reflects when that record was set, not when the attribute was last read.

Attribute Description
Temperature Min Lowest temperature reading since last reset, with occurrence timestamp
Temperature Max Highest temperature reading since last reset, with occurrence timestamp
Humidity Min Lowest humidity reading since last reset, with occurrence timestamp
Humidity Max Highest humidity reading since last reset, with occurrence timestamp
Min/Max Reset Timestamp of the last reset (manual or automatic)
Readings Since Reset Count of temperature+humidity reading pairs since last reset

Device-native 24-hour min/max (confirmed via Zigbee sniff)

These come directly from the device firmware via FC11 cluster attributes 0x20080x200D. They are the same values shown on the LCD display. The device pushes these unsolicited roughly once per hour; Refresh while awake also reads them explicitly.

Attribute FC11 Attr Type Notes
Device Temp Max 0x2008 INT16 ×100 °C
Device Temp Min 0x2009 INT16 ×100 °C
Device Humidity Max 0x200B UINT16 ×100 %
Device Humidity Min 0x200C UINT16 ×100 %

Note: Attributes 0x200A (reference temp) and 0x200D (reference humidity) also arrive in the hourly burst. Their meaning is unconfirmed — logged at info level but not exposed as Current States.

Note: Two additional FC11 attributes — 0x200E and 0x600E (both UINT8, both currently 0x00) — were discovered via sniffing. Their meaning is unknown. See the FC11 attribute map and Known Limitations below.

Comfort zone thresholds

Reflect what is currently programmed in the device firmware and control the comfort indicator icons on the LCD. Read from FC11 on every Refresh.

Attribute FC11 Attr Notes
comfortTempMin 0x0004 :snowflake: icon threshold
comfortTempMax 0x0003 :fire: icon threshold
comfortHumiMin 0x0005 Lower humidity comfort bound
comfortHumiMax 0x0006 Upper humidity comfort bound

Note: FC11 comfort threshold attributes (0x00030x0006) and device min/max attributes (0x20080x200D) do not appear in FC11's attribute discovery response. They are only accessible via direct read with manufacturer code 0x1286, or received as unsolicited reports. This is why standard Zigbee tools cannot see them without the manufacturer code.

Other

Attribute Description
Last Reading Timestamp of the most recent temperature+humidity report received from the device
healthStatus online / offline / unknown
rtt Round-trip time in ms (from Ping), or timeout

Preferences

Preference Default Notes
Debug logging enabled Auto-disables after 24 hours
Description text logging enabled Logs readings to Events tab
Min time between temperature reports 10 s Zigbee reporting configuration
Max time between temperature reports 3600 s Heartbeat interval
Temperature reporting threshold 0.2 °C Minimum change to trigger a report
Min time between humidity reports 10 s
Max time between humidity reports 3600 s
Humidity reporting threshold 1 %
Temperature offset 0.0 °C Applied in Hubitat only; does not write to device
Humidity offset 0.0 % Applied in Hubitat only; does not write to device
Comfort temperature minimum (°C) 18.0 Always enter in °C regardless of hub scale
Comfort temperature maximum (°C) 26.0 Always enter in °C regardless of hub scale
Comfort humidity minimum (%) 40
Comfort humidity maximum (%) 60
Auto-reset min/max daily disabled When enabled, driver-tracked min/max resets automatically each day at the time below
Auto-reset time 00:00 Time of day for the daily reset; only active when auto-reset is enabled

Important — sleepy device: The SNZB-02DR2 wakes roughly every 29 minutes. Any write (saving preferences that change FC11 thresholds) will be silently dropped if the device is asleep. Press the button on the back to wake it immediately before saving preferences. The comfort threshold values in Current States confirm whether a write succeeded.


Commands

Command Description
Refresh Reads all current values, comfort thresholds, and device-native 24-hr min/max. Use this to force an immediate read of Device Temp/Humidity Min/Max without waiting for the next unsolicited ~hourly report from the device.
Reset Min/Max Clears the Hubitat-tracked min/max and resets the readings counter. Does not affect device's own 24-hr values. Also triggered automatically when auto-reset fires.
Initialize Re-initializes all driver state and re-runs configure(). Clears all Current State values.
Ping Sends a basic cluster read and reports round-trip time

Known Limitations and Open Questions

Comfort Temp Min does not populate — Fixed in v1.9.0

The FC11 attribute map used in v1.8.0 and earlier had an off-by-one error inherited from an older version of the zigbee-herdsman-converters source. The correct attribute IDs, verified against the current converter source and confirmed by the symptom (a value that looked like a temperature minimum appearing under Comfort Humi Min), are:

Attribute Correct ID Old (wrong) ID
comfortTempMax 0x0003 0x0003 (was correct)
comfortTempMin 0x0004 0x0002 ← was wrong
comfortHumiMin 0x0005 0x0004
comfortHumiMax 0x0006 0x0005
tempUnit 0x0007 0x0001
tempCalibration 0x2003 0x0006
humiCalibration 0x2004 0x0007

All seven attributes now read, write, and decode correctly.

Device-native 24-hr min/max is not remotely resettable

Writing 0 to FC11 attributes 0x20080x200C is silently ignored by the device. Those attributes are read-only from the Zigbee side; the device firmware owns them entirely. The Reset Device Min/Max command introduced in v1.6.0 has been removed and replaced by Read Device Min/Max, which simply re-reads the current values from the device on demand (useful to refresh Hubitat's display without waiting for the next unsolicited ~hourly report).

The Zigbee2MQTT SNZB-02DR2 page does not expose a reset function for these values. SONOFF's product page describes them as "24-hour extremes," consistent with a firmware-maintained rolling window rather than a Hubitat-resettable state.

The only confirmed way to reset the device's 24-hour min/max is a ~10-second hold of the physical button. This is a local firmware action with no Zigbee equivalent.

Device-native min/max reset timing

Not yet confirmed: does the device reset its 24-hour window at midnight, on a rolling 24-hour cycle, or only on button hold? Observation across midnight is needed.

FC11 0x200E — newly discovered, meaning unknown

UINT8, reported value 0x00. Sent in the wake-up burst after humidity calibration. Numerically follows the 0x20080x200D min/max group. Candidates: a reset-occurred flag (would become 1 after the 10-second button hold?), a period counter, or an alarm state. Worth monitoring after a button-hold reset to see if it changes.

FC11 0x600E — newly discovered, meaning unknown

UINT8, reported value 0x00. Sent ~2 seconds before 0x200E in the same wake burst. The 0x60xx address space is well outside the 0x20xx group. May be related to the FC57 cluster (note that FC57 also has an 0x000E attribute, also UINT8, also 0x00).

FC11 0x200A and 0x200D — reference values, meaning unknown

Arrive with the hourly 0x20080x200D min/max report. Plausibly previous-period or rolling-average values. Meaning not yet confirmed.


Zigbee Cluster Summary

Cluster ID Usage
Basic 0x0000 Device info, ping/RTT
Power Configuration 0x0001 Battery % (attr 0x0021) and voltage (attr 0x0020)
Identify 0x0003 Standard
Poll Control 0x0020 Sleepy device check-in; 29-min interval confirmed
Temperature Measurement 0x0402 Current temp; sensor range −40°C to 115°C
Relative Humidity 0x0405 Current humidity
Time 0x000A Out-cluster (device receives time from coordinator)
OTA Upgrade 0x0019 Out-cluster (device receives OTA firmware updates)
Sonoff Private (FC11) 0xFC11 Comfort thresholds, calibration, device min/max
Sonoff Private (FC57) 0xFC57 OTA/device management — see below

FC57 Cluster — Decoded

FC57 is Sonoff/eWeLink's private device management cluster, used across their Zigbee product range. Based on cross-referencing with other Sonoff devices in zigbee2mqtt, combined with the attribute types and values observed, FC57 is most likely the Sonoff OTA / firmware management cluster. Evidence:

  • The device declares 0x0019 (standard OTA Upgrade cluster) as an out-cluster, meaning it expects to receive OTA images from the coordinator. FC57 likely coordinates that process on the Sonoff side.

  • 0x0011 = 0xFF and 0x0012 = 0xFFFF are classic "not set / no limit" sentinel values.

  • 0x0013 = 0 could be a downloaded-bytes counter or OTA sequence number.

  • The bool flags fit a state machine (checking/available/downloading/complete).

  • Cluster Revision = 1 indicates an early internal cluster definition.

All FC57 attributes are read-only (r--). No received commands are defined, meaning this cluster cannot be commanded from Hubitat. It is informational only.

Attribute Type Value Likely meaning
0x0002 bool True OTA check enabled / feature available
0x0003 bool True OTA schedule active / bound to server
0x0004 uint8 3 OTA image type or capability flags
0x0005 uint8 3 OTA image subtype or paired flags
0x0006 bool False Low battery / error flag (False = OK)
0x0007 bool True Reporting active / coordinator bound
0x0008 bool False Alarm or error flag (False = OK)
0x000D bool True Unknown
0x000E uint8 0 Unknown — same attribute number as FC11 0x200E/0x600E low byte
0x000F bool False OTA not currently downloading
0x0011 uint8 0xFF "No limit" / not set
0x0012 uint16 0xFFFF "No limit" / not set
0x0013 uint16 0 Counter / sequence / downloaded bytes
0xFFFD uint16 1 Cluster Revision = 1

FC11 Attribute Map (as understood)

Important note on discoverability: FC11 comfort threshold attributes (0x00030x0006) and device min/max attributes (0x20080x200D) do not appear in FC11's ZCL attribute discovery response. Only 0x0007, 0x200E, and 0x600E are discoverable. The remaining attributes are accessible only via direct reads using manufacturer code 0x1286, or received as unsolicited reports from the device.

Attribute Type R/W Status Description
0x0003 INT16 R/W Confirmed working Comfort temp max (:fire: threshold), °C ×100
0x0004 INT16 R/W Confirmed working Comfort temp min (:snowflake: threshold), °C ×100
0x0005 UINT16 R/W Confirmed working Comfort humidity min, % ×100
0x0006 UINT16 R/W Confirmed working Comfort humidity max, % ×100
0x0007 UINT8 R Confirmed, discoverable Temperature unit (0=°C, 1=°F)
0x2003 INT16 R/W? Not yet observed Temperature calibration offset, °C ×100
0x2004 INT16 R/W? Not yet observed Humidity calibration offset, % ×100
0x2008 INT16 R only Confirmed working Device max temp (24-hr), °C ×100
0x2009 INT16 R only Confirmed working Device min temp (24-hr), °C ×100
0x200A INT16 R only Received, meaning unknown Reference/previous-period temp
0x200B UINT16 R only Confirmed working Device max humidity (24-hr), % ×100
0x200C UINT16 R only Confirmed working Device min humidity (24-hr), % ×100
0x200D UINT16 R only Received, meaning unknown Reference/previous-period humidity
0x200E UINT8 R only? Newly discovered, discoverable, value=0 Unknown — sent in wake burst
0x600E UINT8 R only? Newly discovered, discoverable, value=0 Unknown — sent in wake burst

Changelog

Version Date Notes
1.0.0 2025-06-02 Initial release
1.1.0 2026-06-02 Added Min/Max Reset timestamp and Readings Since Reset counter
1.2.0 2026-06-02 Decoded FC11 0x2008–0x200D device-native 24-hr min/max via Zigbee sniff
1.6.0 2026-06-02 Added comfort threshold sentinels; added resetDeviceMinMax command
1.7.0 2026-06-03 Added Last Reading timestamp; added scheduled daily auto-reset of driver min/max
1.8.0 2026-06-03 Embedded occurrence timestamp in Temperature/Humidity Min/Max values
1.9.0 2026-06-04 Fixed FC11 attribute map (comfortTempMin corrected from 0x0002 to 0x0004; all comfort/calibration/unit IDs updated to match current zigbee-herdsman-converters). Replaced resetDeviceMinMax command with readDeviceMinMax — device 24-hr min/max is not remotely resettable.
1.10.0 2026-06-04 Removed readDeviceMinMax command — fully redundant with Refresh, which already reads device-native min/max explicitly.

License

Licensed under the Apache License, Version 2.0.

I went ahead and bought a couple of these for Prime Day and your driver is working great for them so far. Thank you!

Hi John, looks like you have done a lot of great work on this driver. The device looks good but I’m not clear on a couple of things. 1) Is the 29 minute sleep cycle configurable? I hope to use it as a remote temp sensor for a virtual thermostat and in that scenario 29 mins is too long. 2) Are the temperature display units configurable as a preference? 3) Can the device get firmware updates using Hubitat as the gateway (probably no, but I had to ask.) Thanks for your time on this.

P.S. Claude was working for me and he didn’t mention any moonlighting projects. Going to have to talk to him.