Philips Hue Tap Dial - Updated Device driver with control of the dial

Hello all, building on the original work by @andywebs - see thread at

I had a conversation with chatGPT about making the rotation work - essentially I have it move up/down at a quasi fixed value which you can adjust to your liking - Also I recommend hardcoding one of the buttons to reset the level to 0 (or mute if you are using it as a volume control as I am)

       /*
    Philips Hue Tap Dial
    2022-11-11 A.Webster
        -First WIP
        - 1.03 - fixed configure/registration
        - v3 - Fixed level change based on rotation
*/

metadata {
    definition(name: "Philips Hue Tap Dial v3", namespace: "boundry", author: "Boris Tsipenyuk", component: true) {
        capability "Refresh"
        capability "Actuator"
        capability "Configuration"
        capability "PushableButton"
        capability "HoldableButton"
        capability "SwitchLevel"        
        capability "ChangeLevel"
        
        command "push", ["NUMBER"]
        command "hold", ["NUMBER"]
        
        fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0003,FC00,1000", outClusters:"0019,0000,0003,0004,0006,0008,0005,1000", model:"RDM002", manufacturer:"Signify Netherlands B.V."
    }
    
    preferences {
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true        
        input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
    }
}

void updated() {
    log.info "Updated..."
    log.warn "description logging is: ${txtEnable == true}"
}

void installed() {
    log.info "Installed..."
    device.updateSetting("txtEnable",[type:"bool",value:true])
    refresh()
}

void parse(List description) { log.warn "parse(List description) not implemented" }

// raw, clusterId, sourceEndpoint, destinationEndpoint, options, messageType, dni, isClusterSpecific, 
// isManufacturerSpecific, manufacturerId, command, direction, data
def parse(String description) {
    if (logEnable) log.debug "parse description: ${description}" 

    def descMap = zigbee.parseDescriptionAsMap(description)
    def descriptionText
    def rawValue = descMap.raw
    def value = descMap.command
    def data = descMap.data
    switch (descMap.clusterInt){
        case 5:
            //if (logEnable) log.debug "Case 5: ${descMap.clusterInt}:${descMap.attrInt}${descMap.attrId} Value:${value}  Data:${data}"
            switch(data[2]) {
                case "00":
                    push(2)
                    //if (logEnable) log.debug "Button 2"
                break
                case "01":
                    push(1)
                    //if (logEnable) log.debug  "Button 1"                        
                break
                case "04":
                    push(4)
                    //if (logEnable) log.debug  "Button 4"                    
                break
                case "05":
                    push(3)
                    //if (logEnable) log.debug  "Button 3"                    
                break
                default:
                    if (logEnable) log.debug "Cluster 5: Unknown Button.   Value:${value}  Data:${data}"
                break
            }
        break
        case 8:
            if (logEnable) log.debug "Case 8: ${descMap.clusterInt}: Value:${value}  Data:${data}"
            if(data != null && data[1] != null)
            {
                if (data[1] == "FF")
                {
                    // For now, any held button sends out this command
                    hold(5)
                    return
                }
                else if(data[2] != null)
                {                
                    speed1 = Integer.parseInt(data[2],16)
                    speed2 = Integer.parseInt(data[1],16)
                    //speed3 = ((speed1 -4) * 16 + speed2) / 8
                    speed3 = speed1 * 16 + speed2
        
                    int speed4 = ((speed3 - 72) / 6) + 1
                    if(speed4 > 1)
                    {    
                        speed = speed4 * (speed4 - 1)
                    }            
                    else
                    {
                        speed = 1
                    }

                    // Dial
                    switch(data[0]) {
                        case "00":
                            // Increase
                            dialClockwise(speed)
                            return
                            //if (logEnable) log.debug "Increase ${speed} ( ${speed3}   ${speed1} )"
                        break
                        case "01":
                            // Decrease
                            dialCounterclockwise(speed)
                            return
                            //if (logEnable) log.debug  "Decrease ${speed} ( ${speed3}   ${speed1} )"                        
                        break
                    }
                }
            }
        break
        
        default:
            if (logEnable) log.debug "${descMap.clusterInt}:${descMap.attrInt}:${descMap.attrId}${rawValue}"
        break
    }
    
    if (descriptionText){
        if (txtEnable) log.info "${descriptionText}"
        sendEvent(name:"Undefined",value:value,descriptionText:descriptionText)
    }
}

void push(button){
    sendButtonEvent("pushed", button, "physical")
}

void hold(button){
    sendButtonEvent("held", button, "physical")
}

// Possibly do this programatically
//void doubleTap(button){
//    sendButtonEvent("doubleTapped", button, "digital")
//}

void dialClockwise(number){
    String descriptionText = "${device.displayName} [Clockwise]"
    if (txtEnable) log.info descriptionText

    adjustLevel(10) // Move level up by 10
}

void dialCounterclockwise(number){
    String descriptionText = "${device.displayName} [CounterClockwise]"
    if (txtEnable) log.info descriptionText

    adjustLevel(-10) // Move level down by 10
}

void adjustLevel(int change){
    def currentLevel = device.currentValue("level").toInteger()
    int newLevel = currentLevel + change
    
    // Ensure new level is within bounds (0-100)
    newLevel = Math.max(0, Math.min(100, newLevel))
    
    if (logEnable) log.debug "Adjusting level from ${currentLevel} to ${newLevel} by a fixed value of ${change}"
    
    setLevel(newLevel) // Directly use the new level as it's already an integer
}


void dial(number){    
    def dimmer = device.currentValue("level")
 
     if (dimmer == null) {
        dimmer = 0
    }
      if (level == null) {
        level = 0
    }
       log.debug "${level} ${number} ${dimmer}"
    if (dimmer != null) {
        if (logEnable) log.debug "Dial Value from:  ${dimmer}  by ${number}"
        
        // Assuming setLevel expects an Integer, you might need to convert dimmer to Integer explicitly
        setLevel((dimmer as Integer) + number)
    } else {
        if (logEnable) log.warn "Current value of 'level' is null"
        // You might want to handle this case according to your requirements
    }
}

void sendButtonEvent(action, button, type){
    String descriptionText = "${device.displayName} button ${button} was ${action} [${type}]"
    if (txtEnable) log.info descriptionText
    sendEvent(name:action, value:button, descriptionText:descriptionText, isStateChange:true, type:type)
}

def setLevel(value, rate = null) {
    if (value == null) return
    Integer level = limitIntegerRange(value,0,100)
    
    if (level == 0) {
        sendEvent(name:"level", value:level, descriptionText:verb, isStateChange:true)
        off()        
        return
    }
    if (device.currentValue("switch") != "on") on()
    
    sendEvent(name:"level", value:level, descriptionText:"set to", isStateChange:true)
}

def configure(){
    log.warn "configure..."
    //runIn(1800,logsOff)
    sendEvent(name: "numberOfButtons", value: 5)
    sendEvent(name: "pushed", value: 1)
    sendEvent(name: "level", value: 0)
    sendEvent(name: "held", value: 5)

    state."${1}" = 0
    state."${2}" = 0
    runIn(5, "refresh")
    def cmds = [
            //bindings
            "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0006 {${device.zigbeeId}} {}", "delay 200",
            "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0008 {${device.zigbeeId}} {}", "delay 200",
            "zdo bind 0x${device.deviceNetworkId} 0x${device.endpointId} 0x01 0x0005 {${device.zigbeeId}} {}", "delay 200",

            //reporting
            "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0006 0 0x10 0 0xFFFF {}","delay 200",
            "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0008 0 0x20 0 0xFFFF {}", "delay 200",
            "he cr 0x${device.deviceNetworkId} 0x${device.endpointId} 0x0005 0 0x30 0 0xFFFF {}", "delay 200",        
    ] + refresh()    
    return cmds
}

void on() {
    parent?.componentOn(this.device)
}

void off() {
    parent?.componentOff(this.device)
}

void refresh() {
    parent?.componentRefresh(this.device)
}

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

Integer limitIntegerRange(value,min,max) {
    Integer limit = value.toInteger()
    return (limit < min) ? min : (limit > max) ? max : limit
}
1 Like

Not Ideal but this is my first stab at a Sonos controller based on this code, I am sure rule machine experts can tell me a better way with a global variable to track the dimmer level across different rules etc