Driver for Shelly BLU RC Button 4 ZB -- UPDATED 2026-05-02

I bought one of the Shelly BLU RC Button 4 ZB scene controllers some months ago, but only got to playing with it today.
image

When paired (and it can be difficult to pair, Modified Double Luck Voodoo is your friend [RELEASE] Tuya Zigbee Contact Sensor++ w/ healthStatus - #70 by John_Land), the device is assigned the Nue Zigbee Scene Controller driver, which I could not make work -- no button presses ever logged. I could NOT find a working Hubitat driver for it anywhere.

So Claude and I worked for a couple of hours, based on my feeding Claude this information site: Shelly BLU RC Button 4 ZB | Shelly Technical Documentation

The result is the attached driver, which I hope saves someone else some time.

2026-05-01 UPDATE: Added scheduled health monitoring, a lastPushed timestamp attribute for pushed/held/doubleTapped events, and a "Last Physical Button Pushed" in Current States.

2026-05-02 UPDATE: Added Current States fields to show Triple Tapped and Double Long Held events rather than coding those events into the Pushed field.
image

/*
 * Shelly BLU RC Button 4 ZB — Hubitat Driver  v1.7
 * Device short name : SBBT-104CUS
 * Manufacturer      : Shelly  (confirmed)
 * Zigbee profile    : 0x0104 (Home Automation)
 * Primary endpoint  : 02  (confirmed; Power Config cluster is on EP1)
 *
 * Features and changes:
 *   - Removed "Device Mode" preference. Driver is hard-wired to On/Off Mode,
 *     which is the device's factory default and the only mode confirmed working.
 *     Toggle Mode required the device to be explicitly reconfigured via a
 *     Zigbee SwitchActions attribute write, and offered no practical advantage.
 *   - Added Health Check capability and healthStatus attribute.
 *   - Added scheduled health monitoring; device is marked online on any inbound
 *     Zigbee message and offline after repeated missed 3-hour check cycles.
 *   - Added lastPushed timestamp attribute for pushed/held/doubleTapped events.
 *   - Added Current State attribute "Last Physical Button Pushed".
 *     Values are 1-4, including logical buttons 5-8 mapped back to their
 *     physical Shelly button number.
 *   - Battery: two strategies combined:
 *       1. configure() now explicitly reads cluster 0x0001 from EP1 (the
 *          endpoint documented for Power Config) rather than EP2.
 *          Also tries voltage (0x0020) in addition to percentage (0x0021).
 *       2. Opportunistic read: after any button press the device is awake,
 *          so a battery read is queued immediately — best chance of a response
 *          from a sleepy end device.
 *
 * ── Button mapping ──────────────────────────────────────────────────────────
 *
 *   Btn1 single (On  EP1)  → button 1  pushed
 *   Btn2 single (Off EP1)  → button 2  pushed
 *   Btn3 single (On  EP2)  → button 3  pushed
 *   Btn4 single (Off EP2)  → button 4  pushed
 *   Hold btn k             → button k  held
 *   Double click btn k     → button k  doubleTapped
 *   Triple click btn k     → button k  tripleTapped    (custom attribute)
 *   Dbl long press btn k   → button k  doubleLongHeld  (custom attribute; 3s press followed quickly by 2s press)
 *   Triple long press btn k→ logged only
 */

import groovy.transform.Field

@Field static final Integer HEALTH_STATUS_COUNT_THRESHOLD = 4

metadata {
    definition(
        name        : "Shelly BLU RC Button 4 ZB",
        namespace   : "shellyzb v. 1.7",
        author      : "Community",
        importUrl   : ""
    ) {
        capability "Battery"
        capability "PushableButton"
        capability "HoldableButton"
        capability "DoubleTapableButton"
        capability "Configuration"
        capability "Refresh"
        capability "Health Check"

        attribute "healthStatus", "enum", ["offline", "online", "unknown"]
        attribute "lastPushed", "string"
        attribute "Last Physical Button Pushed", "number"
        attribute "tripleTapped", "number"
        attribute "doubleLongHeld", "number"

        fingerprint profileId: "0104", endpointId: "02",
            inClusters : "0000,0001,0003",
            outClusters: "0003,0005,0006,0008",
            manufacturer: "Shelly", model: "SBBT-104CUS",
            deviceJoinName: "Shelly BLU RC Button 4 ZB"

        fingerprint profileId: "0104", endpointId: "02",
            inClusters : "0000,0001,0003",
            outClusters: "0003,0005,0006,0008",
            manufacturer: "Shelly",
            deviceJoinName: "Shelly BLU RC Button 4 ZB"
    }

    preferences {
        input name: "logEnable", type: "bool",
            title: "Enable debug logging (auto-off after 30 min)", defaultValue: true
        input name: "txtEnable", type: "bool",
            title: "Enable description text logging", defaultValue: true
    }
}

// ════════════════════════════════════════════════════════════════════════════
// Lifecycle
// ════════════════════════════════════════════════════════════════════════════

def installed() {
    log.info "${device.displayName}: installed"
    state.notPresentCounter = 0
    initialize()
}

def updated() {
    log.info "${device.displayName}: preferences updated"
    initialize()
}

def initialize() {
    sendEvent(name: "numberOfButtons", value: 4)

    if (state.notPresentCounter == null) state.notPresentCounter = 0
    if (device.currentValue("healthStatus") == null) setHealthStatusValue("unknown")

    scheduleDeviceHealthCheck()

    if (logEnable) {
        unschedule("logsOff")
        runIn(1800, "logsOff")
    }
}

// ════════════════════════════════════════════════════════════════════════════
// Zigbee commands sent TO the device
// ════════════════════════════════════════════════════════════════════════════

def configure() {
    log.info "${device.displayName}: configure"
    initialize()
    List cmds = []

    // ── ZDO bindings — all 4 endpoints, On/Off + Scenes clusters ──────────
    ["01","02","03","04"].each { ep ->
        cmds += "zdo bind 0x${device.deviceNetworkId} 0x${ep} 0x01 0x0006 {${device.zigbeeId}} {}"
        cmds += "delay 200"
        cmds += "zdo bind 0x${device.deviceNetworkId} 0x${ep} 0x01 0x0005 {${device.zigbeeId}} {}"
        cmds += "delay 200"
    }

    // ── Battery — target EP1 explicitly ───────────────────────────────────
    // The Power Config cluster (0x0001) is documented on EP1 of this device.
    // device.endpointId is 02, so zigbee.readAttribute() would target EP2 by
    // default; we use raw "he rattr" commands to force EP1 instead.
    // Tries both percentage (0x0021) and voltage (0x0020) since 0x0021
    // returned UNSUP_ATTRIBUTE on EP2 — EP1 may respond differently.
    cmds += "he rattr 0x${device.deviceNetworkId} 0x01 0x0001 0x0021 {}"  // % remaining
    cmds += "delay 300"
    cmds += "he rattr 0x${device.deviceNetworkId} 0x01 0x0001 0x0020 {}"  // voltage
    cmds += "delay 300"

    // ── Configure reporting for battery percentage on EP1 ─────────────────
    // Report on >=1% change, min 30 s, max 1 hr.
    // Raw configure-reporting ZCL frame targeting EP1:
    //   frame control (0x00) + seq (0x01) + command Configure Reporting (0x06)
    //   + direction (0x00) + attr (0x0021 LE) + type UINT8 (0x20)
    //   + min interval (0x001E = 30s LE) + max interval (0x0E10 = 3600s LE)
    //   + reportable change (0x01)
    cmds += "he raw 0x${device.deviceNetworkId} 0x01 0x01 0x0001 {10 01 06 00 21 00 20 1E 00 10 0E 01}"
    cmds += "delay 300"

    // ── Identity reads ─────────────────────────────────────────────────────
    cmds += zigbee.readAttribute(0x0000, 0x0004)   // Manufacturer (EP2, works)
    cmds += "delay 200"
    cmds += zigbee.readAttribute(0x0000, 0x0005)   // Model (EP2, returns UNSUP but harmless)

    log.info "${device.displayName}: sending ${cmds.size()} commands — press a button to wake device"
    return cmds
}

def refresh() {
    log.info "${device.displayName}: refresh — queuing battery reads for next wake"
    List cmds = []
    cmds += "he rattr 0x${device.deviceNetworkId} 0x01 0x0001 0x0021 {}"
    cmds += "delay 300"
    cmds += "he rattr 0x${device.deviceNetworkId} 0x01 0x0001 0x0020 {}"
    return cmds
}

// ════════════════════════════════════════════════════════════════════════════
// Main parse
// ════════════════════════════════════════════════════════════════════════════

def parse(String description) {
    if (logEnable) log.debug "${device.displayName}: raw → ${description}"
    setHealthStatusOnline()

    Map dm = zigbee.parseDescriptionAsMap(description)
    if (!dm) return
    if (logEnable) log.debug "${device.displayName}: map → ${dm}"

    // Hubitat uses "clusterId" for catchAll, "cluster" for attr reports
    String cluster = dm.cluster ?: dm.clusterId

    switch (cluster) {
        case "0000": handleBasicCluster(dm);                                  break
        case "0001": handlePowerCluster(dm);                                  break
        case "0005": if (dm.command == "05") handleRecallScene(dm);           break
        case "0006": handleOnOffCluster(dm);                                  break
        case "0008": if (dm.command == "02") handleLevelStep(dm);             break
        default:
            if (logEnable) log.debug "${device.displayName}: unhandled cluster ${cluster}"
    }
}

// ════════════════════════════════════════════════════════════════════════════
// Cluster handlers
// ════════════════════════════════════════════════════════════════════════════

private void handleBasicCluster(Map dm) {
    if (dm.attrId == "0004") {
        log.info "${device.displayName}: Manufacturer = '${dm.value}'"
        updateDataValue("manufacturer", dm.value)
    } else if (dm.attrId == "0005") {
        log.info "${device.displayName}: Model = '${dm.value}'"
        updateDataValue("model", dm.value)
    }
}

private void handlePowerCluster(Map dm) {
    if (dm.attrId == "0021") {
        // BatteryPercentageRemaining: stored as 2x actual %, divide by 2
        try {
            int raw = Integer.parseInt(dm.value, 16)
            int pct = Math.round(raw / 2.0)
            if (txtEnable) log.info "${device.displayName}: battery ${pct}% (from percentage attr)"
            sendEvent(name: "battery", value: pct, unit: "%")
        } catch (e) {
            log.warn "${device.displayName}: could not parse battery percentage value '${dm.value}'"
        }
    } else if (dm.attrId == "0020") {
        // BatteryVoltage: stored in units of 100mV
        // CR2032 approximate range: 2.0V (0%) to 3.0V (100%)
        try {
            int raw = Integer.parseInt(dm.value, 16)
            float volts = raw / 10.0
            int pct = Math.min(100, Math.max(0, Math.round((volts - 2.0) / 1.0 * 100)))
            if (txtEnable) log.info "${device.displayName}: battery ${volts}V → ${pct}% (from voltage attr)"
            sendEvent(name: "battery", value: pct, unit: "%")
        } catch (e) {
            log.warn "${device.displayName}: could not parse battery voltage value '${dm.value}'"
        }
    }
}

private void handleOnOffCluster(Map dm) {
    int ep = parseEndpoint(dm)
    if (ep == 0) return

    String cmd = dm.command

    // On/Off Mode (hard-wired):
    //   EP1 On  → button 1     EP1 Off → button 2
    //   EP2 On  → button 3     EP2 Off → button 4
    if (ep == 1) {
        if (cmd == "01")      btnEvent("pushed", 1, "single click [On,  EP1]")
        else if (cmd == "00") btnEvent("pushed", 2, "single click [Off, EP1]")
        else if (logEnable)   log.debug "${device.displayName}: EP1 unexpected cmd ${cmd}"
    } else if (ep == 2) {
        if (cmd == "01")      btnEvent("pushed", 3, "single click [On,  EP2]")
        else if (cmd == "00") btnEvent("pushed", 4, "single click [Off, EP2]")
        else if (logEnable)   log.debug "${device.displayName}: EP2 unexpected cmd ${cmd}"
    } else {
        if (logEnable) log.debug "${device.displayName}: On/Off no mapping — EP${ep} cmd ${cmd}"
    }

    // Opportunistic battery read: device is awake right now after a button press
    sendHubCommand(new hubitat.device.HubMultiAction(
        ["he rattr 0x${device.deviceNetworkId} 0x01 0x0001 0x0021 {}"],
        hubitat.device.Protocol.ZIGBEE
    ))
}

private void handleRecallScene(Map dm) {
    int ep = parseEndpoint(dm)
    if (ep < 1 || ep > 4) return

    List data = dm.data
    if (!data || data.size() < 3) {
        log.warn "${device.displayName}: RecallScene EP${ep} — payload too short: ${data}"
        return
    }
    int scene = Integer.parseInt(data[2], 16)

    if (scene == 1)       btnEvent("doubleTapped",   ep,     "double click   [Scene 1,  EP${ep}]")
    else if (scene == 2)  btnEvent("tripleTapped",   ep,     "triple click   [Scene 2,  EP${ep}]")
    else if (scene == 11) btnEvent("held",            ep,     "long press     [Scene 11, EP${ep}]")
    else if (scene == 12) btnEvent("doubleLongHeld",  ep,     "dbl long press [Scene 12, EP${ep}]")
    else if (scene == 13) {
        if (txtEnable) log.info "${device.displayName}: triple long press btn${ep} — not mapped"
    } else {
        log.warn "${device.displayName}: unknown scene ID ${scene} on EP${ep}"
    }

    // Opportunistic battery read on scene events too
    sendHubCommand(new hubitat.device.HubMultiAction(
        ["he rattr 0x${device.deviceNetworkId} 0x01 0x0001 0x0021 {}"],
        hubitat.device.Protocol.ZIGBEE
    ))
}

private void handleLevelStep(Map dm) {
    int ep = parseEndpoint(dm)
    if (ep == 0) return
    List data = dm.data
    if (!data || data.isEmpty()) return

    int stepMode = Integer.parseInt(data[0], 16)
    int btn = (ep == 1) ? ((stepMode == 0) ? 1 : 2) : ((stepMode == 0) ? 3 : 4)
    btnEvent("held", btn, "hold ${stepMode == 0 ? 'Up' : 'Down'} [Level Step, EP${ep}]")
}

// ════════════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════════════

private int parseEndpoint(Map dm) {
    String epStr = dm.sourceEndpoint ?: dm.endpoint
    if (!epStr) {
        log.warn "${device.displayName}: no endpoint in map: ${dm}"
        return 0
    }
    return Integer.parseInt(epStr, 16)
}

private void btnEvent(String eventName, int btnNum, String desc) {
    if (txtEnable) log.info "${device.displayName}: button ${btnNum} ${eventName} — ${desc}"
    updateLastPushed(btnNum, eventName)
    sendEvent(name: eventName, value: btnNum, isStateChange: true)
}

private void updateLastPushed(int btnNum, String eventName) {
    TimeZone tz = location?.timeZone ?: TimeZone.getDefault()
    String ts = new Date().format("yyyy-MM-dd HH:mm:ss", tz)
    int physicalBtn = ((btnNum - 1) % 4) + 1

    sendEvent(
        name: "lastPushed",
        value: ts,
        descriptionText: "button ${btnNum} was ${eventName} at ${ts}",
        isStateChange: true
    )
    sendEvent(
        name: "Last Physical Button Pushed",
        value: physicalBtn,
        descriptionText: "physical button ${physicalBtn} was ${eventName} at ${ts}",
        isStateChange: true
    )
}

void scheduleDeviceHealthCheck() {
    Random rnd = new Random()
    unschedule("deviceHealthCheck")
    // Run once every 3 hours at a randomized second/minute to avoid hub load spikes.
    schedule("${rnd.nextInt(60)} ${rnd.nextInt(60)} 1/3 * * ? *", "deviceHealthCheck")
}

// Called every 3 hours. Each inbound Zigbee message resets this counter.
void deviceHealthCheck() {
    state.notPresentCounter = (state.notPresentCounter ?: 0) + 1
    if (state.notPresentCounter > HEALTH_STATUS_COUNT_THRESHOLD) {
        if (!(device.currentValue("healthStatus", true) in ["offline"])) {
            setHealthStatusValue("offline")
            log.warn "${device.displayName}: is offline"
        }
    } else {
        if (logEnable) log.debug "${device.displayName}: health check OK so far (notPresentCounter=${state.notPresentCounter})"
    }
}

void setHealthStatusOnline() {
    state.notPresentCounter = 0
    if (!(device.currentValue("healthStatus", true) in ["online"])) {
        setHealthStatusValue("online")
        if (txtEnable) log.info "${device.displayName}: is online"
    }
}

void setHealthStatusValue(String value) {
    sendEvent(
        name: "healthStatus",
        value: value,
        descriptionText: "${device.displayName} healthStatus set to ${value}",
        isStateChange: true
    )
}

// Required by the Health Check capability. Sleeping button devices usually cannot
// respond immediately, so real health is inferred from incoming reports/events.
void ping() {
    if (logEnable) log.debug "${device.displayName}: ping() requested; waiting for next device report/button event"
}

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

1 Like

Thanks John, either your timing or my timing is very good. I today received two Shelly BLU RC Button 4 ZB remote controls and was just beginning to get frustrated with setting them up when I checked the community and found your driver. I added it to my set-up and it enabled me to use three options for each button (pushed, held and double clicked). The Shelly documentation mentions that the device supports triple click - are there any any plans to include this in your driver? I am only asking out of curiosity as I do not need it for my use. Thanks again for your timely provision of the driver.

Triple click is supported, but appears in the Pushed field as button numbers 5-8. So, triple clicking physical button 1 shows in the Current States field as "5", which is the value you would use in a rule. See the Button Mapping comments at the top of the driver file.

Having said that, triple clicking is pushing things, because if you click 4 times, you put the device in Bluetooth pairing mode!

You might want to take a look at the revised driver in post 1.

It adds scheduled health monitoring, a lastPushed timestamp attribute for pushed/held/doubleTapped events, and a "Last Physical Button Pushed" in Current States.

I revised the revised driver, the latest version added Current States fields to show Triple Tapped and Double Long Held events rather than coding those events into the Pushed field.

1 Like