Zen Thermostat Bug - fails to cleanly switch from "Fan On" to "Fan Auto" modes

Hi @mike.maxwell I've been noticing an issue with the Zen driver where it fails to cleanly switch from "Fan On" to "Fan Auto" modes. I run an hourly ventilation rule that switches my HVAC to "fan on" and then a few mins later, switches back to "fan auto" mode.

The issue is that quite often the thermostat operating state seems to get stuck in "fan only" mode even after the driver shows it's in auto mode (and the fan is still running). eg:

The solution seems to be to send a bunch of refresh's + "fan auto" commands and eventually it sorts itself out. Im not using Command retry because its been broken for ages (the original command retry work great, but later versions did not).

My Zen's are battery powered - I'm on HE 2.4.4.156, but this has been happening for a while and im not sure exactly when it started. To make the problem more fun, it's not consistently happening every time.

Btw, Im having difficulty getting logs from the Zen, the debug logging keeps expiring at inconvenient times. :weary:

Argh, and today everything is fine, no issues.

Seems like a good use of AI to me. I just asked free DeepSeek to make a driver for the Zen Thermostat.

I don't have one, so I can't test it. Below is what it produced. Generally, AI does not get it right the first time. I've found I just try the code, and paste any errors from the debug logs right back into the session, and it figures out why those errors were produced. Once you have the code working, you can just paste the code back into DeepSeek and ask to to fix other things you notice are not working right, like your post here. Use any AI, but DeepSeek seems to do a good job with Hubitat code for me.

Zen Thermostat
/**
 *  Zen Within Zigbee Thermostat (Zen‑01‑W) Driver for Hubitat Elevation
 *  Automatically detects supported thermostat and fan modes.
 *
 *  Version: 1.2.0
 *  Date: 2026-04-13
 *
 *  Changes:
 *  - Removed user preferences for supported modes
 *  - Added automatic detection of supported modes by reading Control Sequence
 *    of Operation (0x001B) and Fan Control Sequence (0x000B) from the thermostat cluster
 *  - Publishes supportedThermostatModes and supportedThermostatFanModes events
 *    based on device capabilities
 */

import hubitat.zigbee.zcl.DataType
import hubitat.zigbee.clusters.Cluster
import hubitat.device.HubAction

metadata {
    definition (name: "Zen Zigbee Thermostat", namespace: "yournamespace", author: "Your Name", importUrl: "https://raw.githubusercontent.com/.../zen-thermostat.groovy") {
        capability "Thermostat"
        capability "TemperatureMeasurement"
        capability "Battery"
        capability "Refresh"
        capability "Configuration"
        capability "Sensor"

        command "setHeatingSetpoint", [[name:"Heating Setpoint", type:"NUMBER", description:"Heating setpoint in °C/°F", range:"10..30"]]
        command "setCoolingSetpoint", [[name:"Cooling Setpoint", type:"NUMBER", description:"Cooling setpoint in °C/°F", range:"10..31"]]
        command "setThermostatMode", [[name:"Mode", type:"ENUM", description:"Thermostat mode", constraints:["off","auto","heat","cool","emergency heating"]]]
        command "setThermostatFanMode", [[name:"Fan Mode", type:"ENUM", description:"Fan mode", constraints:["auto","on"]]]
        command "setTemperatureCalibration", [[name:"Calibration", type:"NUMBER", description:"Temperature calibration offset in °C/°F", range:"-2.5..2.5", step:"0.1"]]
        command "refresh"
        command "configure"

        fingerprint profileId: "0104", inClusters: "0000,0001,0003,0004,0005,0201,0202,0402", outClusters: "0019", manufacturer: "Zen", model: "Zen-01", deviceJoinName: "Zen Zigbee Thermostat"
    }

    preferences {
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
        input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
        input name: "tempUnitPref", type: "enum", title: "Temperature Unit", options: ["C":"Celsius", "F":"Fahrenheit"], defaultValue: "C"
        input name: "tempCalibration", type: "decimal", title: "Temperature Calibration Offset", description: "Adjust the reported temperature (±2.5°C/±4.5°F)", range: "-2.5..2.5", defaultValue: 0.0
        input name: "heatingSetpointMin", type: "decimal", title: "Minimum Heating Setpoint", defaultValue: 10.0, range: "5..30"
        input name: "heatingSetpointMax", type: "decimal", title: "Maximum Heating Setpoint", defaultValue: 30.0, range: "5..30"
        input name: "coolingSetpointMin", type: "decimal", title: "Minimum Cooling Setpoint", defaultValue: 10.0, range: "5..31"
        input name: "coolingSetpointMax", type: "decimal", title: "Maximum Cooling Setpoint", defaultValue: 31.0, range: "5..31"
        input name: "tempReportInterval", type: "number", title: "Temperature Report Interval (seconds)", description: "How often the device reports temperature", defaultValue: 300, range: "30..86400"
        input name: "tempMinReportInterval", type: "number", title: "Minimum Temperature Report Interval (seconds)", description: "Minimum time between reports", defaultValue: 30, range: "5..3600"
    }
}

def installed() {
    logDebug "installed()"
    refresh()
    configure()
}

def updated() {
    logDebug "updated()"
    if (logEnable) runIn(1800, "logsOff")
    configure()
    refresh()
}

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

private logDebug(msg) {
    if (logEnable) log.debug msg
}

private logInfo(msg) {
    if (txtEnable) log.info msg
}

private def sendZigbeeCommands(commands) {
    if (commands) {
        if (commands instanceof List) {
            sendHubCommand(commands)
        } else {
            sendHubCommand([commands])
        }
    }
}

// Publish the lists of supported modes to the Thermostat capability
private void updateSupportedModes() {
    def modes = state.supportedThermostatModes ?: ["off","auto","heat","cool"]
    def fanModes = state.supportedThermostatFanModes ?: ["auto","on"]
    
    sendEvent(name: "supportedThermostatModes", value: modes, displayed: false)
    sendEvent(name: "supportedThermostatFanModes", value: fanModes, displayed: false)
    
    logDebug "Detected supported thermostat modes: ${modes}"
    logDebug "Detected supported fan modes: ${fanModes}"
}

// Map Control Sequence of Operation (0x001B) to thermostat mode list
private List<String> mapControlSequenceToModes(int ctrlSeq) {
    // Values from Zigbee Cluster Library specification
    switch (ctrlSeq) {
        case 0x00: // Cooling only
            return ["off", "cool"]
        case 0x01: // Cooling with reheat
            return ["off", "cool", "emergency heating"]  // reheat can be considered emergency heat
        case 0x02: // Heating only
            return ["off", "heat"]
        case 0x03: // Heating with reheat
            return ["off", "heat", "emergency heating"]
        case 0x04: // Cooling and heating
            return ["off", "cool", "heat", "auto"]
        case 0x05: // Cooling and heating with reheat
            return ["off", "cool", "heat", "auto", "emergency heating"]
        default:
            logDebug "Unknown Control Sequence value: ${ctrlSeq}, using default full set"
            return ["off", "auto", "heat", "cool"]
    }
}

// Map Fan Control Sequence (0x000B) to fan mode list
private List<String> mapFanSequenceToFanModes(int fanSeq) {
    // Values from ZCL spec:
    // 0x00 = No fan control
    // 0x01 = Multi-speed fan control (auto/on)
    // 0x02 = Single-speed fan control (on only? Usually auto/on as well)
    // For simplicity, we assume if fanSeq != 0x00, then both auto and on are supported.
    if (fanSeq == 0x00) {
        return []  // no fan control
    } else {
        return ["auto", "on"]
    }
}

def parse(String description) {
    if (description?.startsWith("catchall:")) {
        logDebug "Catchall: ${description}"
        return
    }
    
    Map descMap = zigbee.parseDescriptionAsMap(description)
    logDebug "Parsed: ${descMap}"
    
    if (!descMap) return
    
    if (descMap.clusterId == "0201") {
        handleThermostatCluster(descMap)
    } else if (descMap.clusterId == "0402") {
        handleTemperatureMeasurement(descMap)
    } else if (descMap.clusterId == "0001") {
        handlePowerConfiguration(descMap)
    }
}

private void handleThermostatCluster(Map descMap) {
    // Handle attribute 0x001B: Control Sequence of Operation
    if (descMap.attrId == "001B") {
        int ctrlSeq = zigbee.convertHexToInt(descMap.value)
        state.supportedThermostatModes = mapControlSequenceToModes(ctrlSeq)
        updateSupportedModes()
        return
    }
    
    // Handle attribute 0x000B: Fan Control Sequence
    if (descMap.attrId == "000B") {
        int fanSeq = zigbee.convertHexToInt(descMap.value)
        state.supportedThermostatFanModes = mapFanSequenceToFanModes(fanSeq)
        updateSupportedModes()
        return
    }
    
    // Handle other thermostat attributes (heating setpoint, cooling setpoint, etc.)
    if (descMap.attrId == "0000") {
        def temp = zigbee.convertHexToInt(descMap.value) / 100.0
        if (tempUnitPref == "F") temp = (temp * 9/5) + 32
        sendEvent(name: "heatingSetpoint", value: temp, unit: getTemperatureScale(), descriptionText: "Heating setpoint is ${temp}${getTemperatureScale()}", displayed: true)
    }
    else if (descMap.attrId == "0011") {
        def temp = zigbee.convertHexToInt(descMap.value) / 100.0
        if (tempUnitPref == "F") temp = (temp * 9/5) + 32
        sendEvent(name: "coolingSetpoint", value: temp, unit: getTemperatureScale(), descriptionText: "Cooling setpoint is ${temp}${getTemperatureScale()}", displayed: true)
    }
    else if (descMap.attrId == "0001") {
        def temp = zigbee.convertHexToInt(descMap.value) / 100.0
        if (tempUnitPref == "F") temp = (temp * 9/5) + 32
        def calibratedTemp = temp + (tempCalibration as Double)
        sendEvent(name: "temperature", value: calibratedTemp, unit: getTemperatureScale(), descriptionText: "Temperature is ${calibratedTemp}${getTemperatureScale()}", displayed: true)
    }
    else if (descMap.attrId == "001C") {
        def mode = descMap.value
        // Optional validation against supported modes, but still send event
        sendEvent(name: "thermostatMode", value: mode, descriptionText: "Thermostat mode is ${mode}", displayed: true)
    }
    else if (descMap.attrId == "001D") {
        def stateVal = descMap.value
        def operatingState = "idle"
        if (stateVal == "01") operatingState = "heat"
        else if (stateVal == "02") operatingState = "cool"
        sendEvent(name: "thermostatOperatingState", value: operatingState, descriptionText: "Operating state is ${operatingState}", displayed: true)
    }
    else if (descMap.attrId == "000A") {
        def fanMode = descMap.value == "01" ? "on" : "auto"
        sendEvent(name: "thermostatFanMode", value: fanMode, descriptionText: "Fan mode is ${fanMode}", displayed: true)
    }
}

private void handleTemperatureMeasurement(Map descMap) {
    if (descMap.attrId == "0000") {
        def temp = zigbee.convertHexToInt(descMap.value) / 100.0
        if (tempUnitPref == "F") temp = (temp * 9/5) + 32
        def calibratedTemp = temp + (tempCalibration as Double)
        sendEvent(name: "temperature", value: calibratedTemp, unit: getTemperatureScale(), descriptionText: "Temperature is ${calibratedTemp}${getTemperatureScale()}", displayed: true)
    }
}

private void handlePowerConfiguration(Map descMap) {
    if (descMap.attrId == "0020") {
        def voltage = zigbee.convertHexToInt(descMap.value)
        def battery = voltage >= 3000 ? 100 : Math.round((voltage - 2400) / 600 * 100)
        battery = Math.max(0, Math.min(100, battery))
        sendEvent(name: "battery", value: battery, unit: "%", descriptionText: "Battery is ${battery}%", displayed: true)
    }
}

def setHeatingSetpoint(temp) {
    logDebug "setHeatingSetpoint(${temp})"
    def minTemp = (heatingSetpointMin ?: 10.0) as Double
    def maxTemp = (heatingSetpointMax ?: 30.0) as Double
    temp = Math.max(minTemp, Math.min(maxTemp, temp as Double))
    if (tempUnitPref == "F") temp = (temp - 32) * 5/9
    def tempInt = Math.round(temp * 100)
    sendZigbeeCommands(zigbee.writeAttribute(0x0201, 0x0011, 0x29, tempInt))
    runIn(2, "refresh")
}

def setCoolingSetpoint(temp) {
    logDebug "setCoolingSetpoint(${temp})"
    def minTemp = (coolingSetpointMin ?: 10.0) as Double
    def maxTemp = (coolingSetpointMax ?: 31.0) as Double
    temp = Math.max(minTemp, Math.min(maxTemp, temp as Double))
    if (tempUnitPref == "F") temp = (temp - 32) * 5/9
    def tempInt = Math.round(temp * 100)
    sendZigbeeCommands(zigbee.writeAttribute(0x0201, 0x0012, 0x29, tempInt))
    runIn(2, "refresh")
}

def setThermostatMode(mode) {
    logDebug "setThermostatMode(${mode})"
    def modeValue
    switch (mode) {
        case "off":
            modeValue = 0x00
            break
        case "auto":
            modeValue = 0x04
            break
        case "heat":
            modeValue = 0x01
            break
        case "cool":
            modeValue = 0x03
            break
        case "emergency heating":
            modeValue = 0x05
            break
        default:
            logDebug "Unknown mode: ${mode}"
            return
    }
    sendZigbeeCommands(zigbee.writeAttribute(0x0201, 0x001C, 0x30, modeValue))
    runIn(2, "refresh")
}

def setThermostatFanMode(fanMode) {
    logDebug "setThermostatFanMode(${fanMode})"
    def fanModeValue = fanMode == "on" ? 0x01 : 0x00
    sendZigbeeCommands(zigbee.writeAttribute(0x0201, 0x000A, 0x30, fanModeValue))
    runIn(2, "refresh")
}

def setTemperatureCalibration(offset) {
    logDebug "setTemperatureCalibration(${offset})"
    device.updateSetting("tempCalibration", [value: offset, type: "decimal"])
    sendEvent(name: "temperatureCalibration", value: offset, descriptionText: "Temperature calibration set to ${offset}${getTemperatureScale()}", displayed: true)
    refresh()
}

def refresh() {
    logDebug "refresh()"
    List<HubAction> refreshCmds = []
    refreshCmds += zigbee.readAttribute(0x0201, 0x0000) // Local temperature
    refreshCmds += zigbee.readAttribute(0x0201, 0x0011) // Heating setpoint
    refreshCmds += zigbee.readAttribute(0x0201, 0x0012) // Cooling setpoint
    refreshCmds += zigbee.readAttribute(0x0201, 0x001C) // System mode
    refreshCmds += zigbee.readAttribute(0x0201, 0x001D) // Running state
    refreshCmds += zigbee.readAttribute(0x0201, 0x000A) // Fan mode
    refreshCmds += zigbee.readAttribute(0x0402, 0x0000) // Temperature measurement
    refreshCmds += zigbee.readAttribute(0x0001, 0x0020) // Battery voltage
    // Also re-read the capability attributes in case they change
    refreshCmds += zigbee.readAttribute(0x0201, 0x001B) // Control Sequence of Operation
    refreshCmds += zigbee.readAttribute(0x0201, 0x000B) // Fan Control Sequence
    sendZigbeeCommands(refreshCmds)
}

def configure() {
    logDebug "configure()"
    List<HubAction> configCmds = []
    // Configure reporting for temperature, battery
    configCmds += zigbee.configureReporting(0x0201, 0x0000, 0x29, tempMinReportInterval ?: 30, tempReportInterval ?: 300, 100)
    configCmds += zigbee.configureReporting(0x0402, 0x0000, 0x29, tempMinReportInterval ?: 30, tempReportInterval ?: 300, 100)
    configCmds += zigbee.configureReporting(0x0001, 0x0020, 0x20, 300, 3600, 100)
    
    // Read capability attributes to determine supported modes
    configCmds += zigbee.readAttribute(0x0201, 0x001B) // Control Sequence of Operation
    configCmds += zigbee.readAttribute(0x0201, 0x000B) // Fan Control Sequence
    
    sendZigbeeCommands(configCmds)
    
    // If the device does not respond within a reasonable time, use default values
    runIn(10, "fallbackSupportedModes")
}

def fallbackSupportedModes() {
    if (!state.supportedThermostatModes) {
        logDebug "No response from device for control sequence, using default modes"
        state.supportedThermostatModes = ["off","auto","heat","cool"]
        state.supportedThermostatFanModes = ["auto","on"]
        updateSupportedModes()
    }
}

I use Claude as it gives me the best results and the Sub is reasonable given I use it almost every day.

I haven't seen this issue in a couple of days, and I adjusted my automations to detect and recover from it.

Good enough. I'm surprised you haven't already used AI to fix it then, but it sounds like you are fine just dealing with the problem, unless Hubitat wants to fix the built-in. Not the approach I would take.

My HVAC automations are apps written using AI tools, so close enough :smiley: