[DRIVER] Sonoff MINI-ZB2GS-L (MINI-DUO-L) Dual Relay – reliable pairing + full dual-channel support

Hi all,

After a fair bit of head-scratching, I’ve ended up with a fully working, reliable Hubitat driver for the Sonoff MINI-ZB2GS-L (also sold as MINI-DUO-L).

This device can work well on Hubitat, but the sticking point is pairing. The relay is very sensitive to what the driver does during the Zigbee join process. If the driver is too “chatty” during join, Hubitat can silently leave the device half-joined, even though it appears in the UI.

This driver solves that by combining pairing-safe behaviour and full functionality in a single driver.


What this driver does

Pairing Safe Mode (default)

  • Keeps the driver deliberately quiet during join
  • Avoids child creation and heavy Zigbee traffic
  • Allows the device to complete key exchange and endpoint discovery cleanly
  • Prevents the slow-blinking “not really joined” state

Automatically unlocks full mode

  • As soon as the driver sees a real device response (OnOff or Basic cluster)
  • No manual driver switching required

Full functionality once paired

  • Two child switches (Channel 1 and Channel 2)
  • Parent switch is ON if either channel is ON
  • Parent ON turns both channels ON
  • Parent OFF turns both channels OFF
  • Explicit Both On and Both Off commands
  • Works like a typical dual-relay Z-Wave device

Manual escape hatch

  • Includes a command: unlockFullModeNow
  • Forces full mode if Hubitat does something odd during join
  • Creates children and runs Configure in one step

Recommended pairing process

  1. Install this driver on your hub
  2. Put Hubitat into Zigbee pairing
  3. Reset the Sonoff (hold button during power-up until rapid blink)
  4. Let it pair using this driver
  5. Open the device page and hit Refresh once

The driver will automatically unlock full mode as soon as it sees a valid response.

If it ever looks stuck, run unlockFullModeNow once.


Tested behaviour

  • Model: MINI-ZB2GS-L
  • Endpoints: 0x01 and 0x02
  • Standard OnOff cluster (0x0006)
  • No manufacturer-specific commands required
  • Stable on Hubitat once joined

Why this exists

The device itself isn’t broken. The issue is that Hubitat’s Zigbee join process is fragile when a driver sends too much traffic during pairing.

This driver explicitly works around that by separating pairing safety from normal operation, automatically.


Driver code###

:arrow_down: Full driver code below :arrow_down:

/**
 *  Sonoff MINI-DUO-L, Dual Relay (Unified Pairing + Full Use)
 *
 *  Pairing Safe Mode defaults ON for clean Zigbee joins on Hubitat.
 *  After first successful device response, driver unlocks full functionality.
 *
 *  Manual escape hatch:
 *   - unlockFullModeNow: forces full mode, creates children, runs Configure
 *
 *  Features in full mode:
 *   - Two child switches for endpoints 01 and 02
 *   - Parent switch is ON if either child is ON
 *   - Parent On turns both ON, Parent Off turns both OFF
 *   - Both On and Both Off commands
 *
 *  Namespace: myhouse
 *  Author: ChatGPT & Mike Bradshaw
 */

import hubitat.zigbee.zcl.DataType
import hubitat.device.HubAction
import hubitat.device.Protocol

metadata {
    definition(
        name: "Sonoff MINI-DUO-L, Dual Relay (Unified Pairing + Full Use)",
        namespace: "myhouse",
        author: "ChatGPT & Mike Bradshaw",
        importUrl: "",
        iconUrl: "https://raw.githubusercontent.com/hubitat/HubitatPublic/master/examples/images/zigbee.png",
        iconX2Url: "https://raw.githubusercontent.com/hubitat/HubitatPublic/master/examples/images/zigbee@2x.png"
    ) {
        capability "Actuator"
        capability "Configuration"
        capability "Initialize"
        capability "Refresh"
        capability "Switch"

        command "bothOn"
        command "bothOff"
        command "unlockFullModeNow"

        fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006", outClusters: "0019"
        fingerprint profileId: "0104", inClusters: "0000,0003,0004,0005,0006,0008", outClusters: "0019"
    }

    preferences {
        input name: "pairingSafeMode", type: "bool", title: "Pairing Safe Mode (leave on for joining, auto unlocks after first device response)", defaultValue: true
        input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
        input name: "txtEnable", type: "bool", title: "Enable description text logging", defaultValue: true
        input name: "createChildren", type: "bool", title: "Create child switches (Channel 1 and 2)", defaultValue: true
    }
}

void installed() {
    logInfo("Installed")
    initialize()
}

void updated() {
    logInfo("Updated")
    if (logEnable) {
        runIn(1800, "logsOff")
    }
    initialize()
}

void initialize() {
    logInfo("Initialize")
    if (isFullModeReady()) {
        if (settings?.createChildren) {
            createOrRepairChildren()
        }
        recomputeParentSwitch()
    } else {
        logInfo("Pairing Safe Mode active, keeping driver quiet until device responds")
    }
}

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

/* -------------------------- Mode Logic -------------------------- */

private boolean isFullModeReady() {
    if (settings?.pairingSafeMode == false) {
        return true
    }
    return (state?.pairedOk == true)
}

private void markPairedOk(String reason) {
    if (state?.pairedOk == true) {
        return
    }
    state.pairedOk = true
    logInfo("Device communication confirmed, enabling full mode, reason: ${reason}")

    if (settings?.createChildren) {
        createOrRepairChildren()
    }
    recomputeParentSwitch()
}

/**
 * Manual override in case Hubitat does something odd during join.
 * This forces full mode and runs Configure.
 */
void unlockFullModeNow() {
    logInfo("Unlock Full Mode NOW requested (manual override)")

    state.pairedOk = true

    // Persistently disable safe mode so it stays unlocked
    if (settings?.pairingSafeMode != false) {
        device.updateSetting("pairingSafeMode", [value: "false", type: "bool"])
    }

    if (settings?.createChildren) {
        createOrRepairChildren()
    }

    // Run configure to set reporting + read states
    configure()
}

/* -------------------------- Parent Switch -------------------------- */

void on() {
    if (!isFullModeReady()) {
        logInfo("Pairing Safe Mode active, ignoring Parent ON to avoid breaking join")
        return
    }
    logInfo("Parent ON (turns BOTH channels ON)")
    bothOn()
}

void off() {
    if (!isFullModeReady()) {
        logInfo("Pairing Safe Mode active, ignoring Parent OFF to avoid breaking join")
        return
    }
    logInfo("Parent OFF (turns BOTH channels OFF)")
    bothOff()
}

void bothOn() {
    if (!isFullModeReady()) {
        logInfo("Pairing Safe Mode active, ignoring Both ON to avoid breaking join")
        return
    }
    logInfo("Both ON requested")
    List<String> cmds = []
    cmds += buildOnOffCmds(true, 0x01)
    cmds += buildOnOffCmds(true, 0x02)
    sendZigbeeCommands(cmds)
}

void bothOff() {
    if (!isFullModeReady()) {
        logInfo("Pairing Safe Mode active, ignoring Both OFF to avoid breaking join")
        return
    }
    logInfo("Both OFF requested")
    List<String> cmds = []
    cmds += buildOnOffCmds(false, 0x01)
    cmds += buildOnOffCmds(false, 0x02)
    sendZigbeeCommands(cmds)
}

/* -------------------------- Configure / Refresh -------------------------- */

void configure() {
    if (!isFullModeReady()) {
        logInfo("Pairing Safe Mode active, skipping Configure")
        return
    }

    logInfo("Configure requested")
    if (settings?.createChildren) {
        createOrRepairChildren()
    }

    List<String> cmds = []
    cmds += zigbee.configureReporting(0x0006, 0x0000, DataType.BOOLEAN, 0, 3600, null, [destEndpoint: 0x01])
    cmds += zigbee.configureReporting(0x0006, 0x0000, DataType.BOOLEAN, 0, 3600, null, [destEndpoint: 0x02])
    cmds += refreshCmds()

    sendZigbeeCommands(cmds)
}

void refresh() {
    logDebug("Refresh requested")
    sendZigbeeCommands(refreshCmds())
}

private List<String> refreshCmds() {
    List<String> cmds = []
    cmds += zigbee.readAttribute(0x0006, 0x0000, [destEndpoint: 0x01])
    cmds += zigbee.readAttribute(0x0006, 0x0000, [destEndpoint: 0x02])
    cmds += zigbee.readAttribute(0x0000, 0x0004, [destEndpoint: 0x01])
    cmds += zigbee.readAttribute(0x0000, 0x0005, [destEndpoint: 0x01])
    return cmds
}

/* -------------------------- Child Devices -------------------------- */

void createOrRepairChildren() {
    String parentDni = device.deviceNetworkId
    String childDni1 = "${parentDni}-ep01"
    String childDni2 = "${parentDni}-ep02"

    if (getChildDevice(childDni1) == null) {
        addChildDevice(
            "hubitat",
            "Generic Component Switch",
            childDni1,
            [name: "${device.displayName} CH1", label: "${device.displayName} CH1", isComponent: true]
        )
        logInfo("Created child switch CH1")
    }

    if (getChildDevice(childDni2) == null) {
        addChildDevice(
            "hubitat",
            "Generic Component Switch",
            childDni2,
            [name: "${device.displayName} CH2", label: "${device.displayName} CH2", isComponent: true]
        )
        logInfo("Created child switch CH2")
    }
}

void componentOn(cd) {
    if (!isFullModeReady()) {
        logInfo("Pairing Safe Mode active, ignoring child ON to avoid breaking join")
        return
    }
    Integer ep = endpointFromChildDni(cd?.deviceNetworkId)
    if (ep == null) {
        log.warn "componentOn: could not determine endpoint for child ${cd?.deviceNetworkId}"
        return
    }
    logInfo("Child ON (${cd.deviceNetworkId}) -> endpoint 0x${hex2(ep)}")
    sendZigbeeCommands(buildOnOffCmds(true, ep))
}

void componentOff(cd) {
    if (!isFullModeReady()) {
        logInfo("Pairing Safe Mode active, ignoring child OFF to avoid breaking join")
        return
    }
    Integer ep = endpointFromChildDni(cd?.deviceNetworkId)
    if (ep == null) {
        log.warn "componentOff: could not determine endpoint for child ${cd?.deviceNetworkId}"
        return
    }
    logInfo("Child OFF (${cd.deviceNetworkId}) -> endpoint 0x${hex2(ep)}")
    sendZigbeeCommands(buildOnOffCmds(false, ep))
}

void componentRefresh(cd) {
    Integer ep = endpointFromChildDni(cd?.deviceNetworkId)
    if (ep == null) {
        log.warn "componentRefresh: could not determine endpoint for child ${cd?.deviceNetworkId}"
        return
    }
    logDebug("Child Refresh (${cd.deviceNetworkId}) -> endpoint 0x${hex2(ep)}")
    List<String> cmds = []
    cmds += zigbee.readAttribute(0x0006, 0x0000, [destEndpoint: ep])
    sendZigbeeCommands(cmds)
}

private Integer endpointFromChildDni(String dni) {
    if (dni == null) return null
    if (dni.endsWith("-ep01")) return 0x01
    if (dni.endsWith("-ep02")) return 0x02
    return null
}

/* -------------------------- Zigbee Command Building / Sending -------------------------- */

private List<String> buildOnOffCmds(boolean turnOn, int endpoint) {
    int cmd = turnOn ? 0x01 : 0x00
    List<String> cmds = []
    cmds += zigbee.command(0x0006, cmd, [destEndpoint: endpoint], 0)
    cmds += zigbee.readAttribute(0x0006, 0x0000, [destEndpoint: endpoint])
    return cmds
}

private void sendZigbeeCommands(List<String> cmds) {
    if (cmds == null || cmds.isEmpty()) {
        logDebug("sendZigbeeCommands: nothing to send")
        return
    }

    cmds.each { String cmd ->
        if (cmd != null && cmd.trim().length() > 0) {
            HubAction action = new HubAction(cmd, Protocol.ZIGBEE)
            sendHubCommand(action)
            pauseExecution(60)
        }
    }
}

/* -------------------------- Parsing -------------------------- */

void parse(String description) {
    if (logEnable) {
        log.debug "parse: ${description}"
    }

    Map descMap = zigbee.parseDescriptionAsMap(description)
    if (descMap == null || descMap.isEmpty()) return

    if (descMap.clusterInt == 0x0006 && descMap.attrInt == 0x0000) {
        markPairedOk("OnOff response")
        handleOnOffAttribute(descMap)
        return
    }

    if (descMap.clusterInt == 0x0000 && (descMap.attrInt == 0x0004 || descMap.attrInt == 0x0005)) {
        markPairedOk("Basic identity response")
        handleBasicIdentity(descMap)
        return
    }

    if (descMap.clusterId != null && descMap.attrId != null) {
        logDebug("ZCL: ep=${descMap.endpoint} cluster=${descMap.clusterId} attr=${descMap.attrId} value=${descMap.value}")
    }
}

private void handleOnOffAttribute(Map descMap) {
    String epHex = (descMap.endpoint ?: "").toString()
    boolean isOn = (descMap.value == "01")
    String eventValue = isOn ? "on" : "off"

    if (txtEnable) {
        log.info "${device.displayName} endpoint ${epHex} is ${eventValue}"
    }

    String childDni = null
    if (epHex.equalsIgnoreCase("01")) childDni = "${device.deviceNetworkId}-ep01"
    if (epHex.equalsIgnoreCase("02")) childDni = "${device.deviceNetworkId}-ep02"

    if (childDni != null) {
        def child = getChildDevice(childDni)
        if (child != null) {
            child.sendEvent(name: "switch", value: eventValue, descriptionText: "${child.displayName} is ${eventValue}")
        }
    }

    if (isFullModeReady() && settings?.createChildren) {
        recomputeParentSwitch()
    } else {
        if (epHex.equalsIgnoreCase("01")) {
            sendEvent(name: "switch", value: eventValue, descriptionText: "${device.displayName} is ${eventValue}")
        }
    }
}

private void recomputeParentSwitch() {
    def ch1 = getChildDevice("${device.deviceNetworkId}-ep01")
    def ch2 = getChildDevice("${device.deviceNetworkId}-ep02")

    String v1 = ch1?.currentValue("switch")?.toString()
    String v2 = ch2?.currentValue("switch")?.toString()

    boolean eitherOn = (v1 == "on") || (v2 == "on")
    String newVal = eitherOn ? "on" : "off"

    if (device.currentValue("switch")?.toString() != newVal) {
        if (txtEnable) {
            log.info "${device.displayName} parent is ${newVal} (derived from CH1=${v1}, CH2=${v2})"
        }
        sendEvent(name: "switch", value: newVal, descriptionText: "${device.displayName} is ${newVal}")
    }
}

private void handleBasicIdentity(Map descMap) {
    int attr = descMap.attrInt
    String value = descMap.value
    if (attr == 0x0004) logInfo("Manufacturer (raw): ${value}")
    if (attr == 0x0005) logInfo("Model (raw): ${value}")
}

/* -------------------------- Logging Helpers -------------------------- */

private String hex2(int val) {
    String s = Integer.toHexString(val).toUpperCase()
    return s.padLeft(2, "0")
}

private void logInfo(String msg) {
    log.info msg
}

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

###END OF CODE###

Final notes

This has been running solidly once paired. Hopefully this saves others from the same trial-and-error.