/** * Sonoff Zigbee Metering Mini Dimmer (MINI-ZBDIM) driver for Hubitat Elevation * * Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except * in compliance with the License. You may obtain a copy of the License at: * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software distributed under the License is distributed * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License * for the specific language governing permissions and limitations under the License. * * * This is a Hubitat Elevation device driver for the Sonoff MINI-ZBDIM module. It supports power metering, external * switch mode, power-on behavior, and fade durations for both Zigbee commands and from the external switch. * * An explanation of some of the preferences and commands: * * - Standby ghost power: * The device seems to always report a small power draw even when the output is switched off. It might be measuring * its own power draw, or it might just be inaccurate. There are two preferences to stop this: * * 1. "Standby ghost power": this is the power, in Watts, that will be subtracted from the reported power sent by the * device. Default is 0.07. * 2. "Standby power threshold": any power report sent by the device that's less than this amount (in Watts) will be * set to zero, so it appears fully off. Default is 0.05, 0.0 disables it. * * - Fade transition times: * The device seems to ignore the fade duration when setting the brightness level. It does, however, have a setting * for a global fade duration. By sending the command to set this before the brightness/on/off command we can * achieve the same effect. This is DISABLED BY DEFAULT because every time the duration is changed: * * 1. The device will write the setting to its NVRAM, which could wear it out quite quickly. * 2. There will be a slight delay while the command is sent before the lights change. * * The duration command is only sent if it's different from the last duration used, so if you rarely set a duration * when you change brightness, it's probably safe to enable this with the "Allow dynamic transition speeds" setting. * The "Default transition speed" setting is the duration for brightness changes with no duration included, and for * switching on and off. The default is 2.5 seconds. * * Unlike some other dimmers, this device treats the duration as how long it should take if changing from 0% to 100% * (or 100% to 0%). If you change from 100% to 50%, for example, it will take half that amount of time. * * There's a separate setting, "External switch fade rate", to control how quickly the brightness changes when using * the external switch. It's a multiplier that speeds it up or slows it down, rather than a specific duration. * * - Calibration: * The device can run a calibration to detect the properties of the bulbs connected to it. To trigger this use the * "Start Calibration" command. After a few seconds the calibration will start and, if you have descriptionText * logging enabled, you'll start to see the calibration progress percentage appear in the logs. The calibration * process might take one or two minutes. * */ import groovy.transform.Field @Field static final String VERSION = "1.0.1" @Field static final String SONOFF_MFG_ID = "0x128C" @Field static final String SONOFF_CLUSTER_ID = "FC11" @Field static final Integer SONOFF_CLUSTER_ID_HEX = 0xFC11 @Field static final Integer SONOFF_CLUSTER_INT = 64529 @Field static final String POWER_ON_BEHAVIOR_PREVIOUS = "Previous State" @Field static final String POWER_ON_BEHAVIOR_OFF = "Off" @Field static final String POWER_ON_BEHAVIOR_ON = "On" @Field static final String POWER_ON_BEHAVIOR_TOGGLE = "Toggle Previous" @Field static final String SWITCH_MODE_TRIPLE = "Triple Button" @Field static final String SWITCH_MODE_DUAL = "Dual Button" @Field static final String SWITCH_MODE_MOMENTARY = "Single Momentary" @Field static final String SWITCH_MODE_TOGGLE = "Single Toggle" @Field static final String SWITCH_RATE_1X = "1x" @Field static final String SWITCH_RATE_2X = "2x" @Field static final String SWITCH_RATE_3X = "3x" @Field static final String SWITCH_RATE_4X = "4x" @Field static final String SWITCH_RATE_5X = "5x" @Field static final Float DEFAULT_TRANSITION_RATE = 2.5 @Field static final Float DEFAULT_STANDBY_GHOST_POWER = 0.07 @Field static final Float DEFAULT_STANDBY_THRESHOLD = 0.05 metadata { definition (name: "Sonoff Zigbee Metering Mini Dimmer", namespace: "kevinwilsondev.hubitat", author: "Kevin Wilson", importUrl: "https://raw.githubusercontent.com/kevinwilsondev/hubitat/main/devices/Sonoff%20MINI-ZBDIM/Sonoff%20Zigbee%20Metering%20Mini%20Dimmer.groovy") { capability "Switch" capability "SwitchLevel" capability "PowerMeter" capability "VoltageMeasurement" capability "CurrentMeter" capability "Configuration" capability "Refresh" command "startCalibration" command "stopCalibration" attribute "calibrationStatus", "string" attribute "calibrationProgress", "number" attribute "powerOnBehavior", "string" attribute "switchMode", "string" attribute "switchRate", "number" attribute "lastCheckIn", "number" fingerprint profileId: "0104", endpointId: "01", inClusters: "0000,0003,0004,0005,0006,0008,FC11", outClusters: "0019", model: "MINI-ZBDIM", manufacturer: "SONOFF" } preferences { input name: "switchMode", type: "enum", title: "External switch type", defaultValue: SWITCH_MODE_TRIPLE, options: [SWITCH_MODE_TRIPLE, SWITCH_MODE_DUAL, SWITCH_MODE_MOMENTARY, SWITCH_MODE_TOGGLE] input name: "switchRate", type: "enum", title: "External switch fade rate", defaultValue: SWITCH_RATE_4X, options: [SWITCH_RATE_1X, SWITCH_RATE_2X, SWITCH_RATE_3X, SWITCH_RATE_4X, SWITCH_RATE_5X] input name: "powerOnBehavior", type: "enum", title: "Power-on behavior", defaultValue: POWER_ON_BEHAVIOR_PREVIOUS, options: [POWER_ON_BEHAVIOR_PREVIOUS, POWER_ON_BEHAVIOR_OFF, POWER_ON_BEHAVIOR_ON, POWER_ON_BEHAVIOR_TOGGLE] input name: "defaultTransition", type: "number", title: "Default transition speed (seconds)", defaultValue: DEFAULT_TRANSITION_RATE, range: "0..60" input name: "dynamicTransitions", type: "bool", title: "Allow dynamic transition speeds (not recommended)", description: "Increases NVRAM wear: read README before enabling", defaultValue: false input name: "standbyGhostPower", type: "number", title: "Standby ghost power (Watts)", defaultValue: DEFAULT_STANDBY_GHOST_POWER, range: "0..5" input name: "standbyPowerThreshold", type: "number", title: "Standby power threshold (Watts)", defaultValue: DEFAULT_STANDBY_THRESHOLD, range: "0..5" input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: false } } // Parse incoming Zigbee messages def parse(String description) { logDebug("Parse description: ${description}") sendEvent(name: "lastCheckIn", value: now(), displayed: false) // Check if this is the custom Sonoff cluster if (description?.contains("cluster: ${SONOFF_CLUSTER_ID}") || description?.contains("clusterInt: ${SONOFF_CLUSTER_INT}")) { parseCustomSonoffCluster(description) return } // Attempt standard Zigbee extraction patterns def map = zigbee.getEvent(description) if (map) { if (map.name == "switch") { logText("${device.displayName} is ${map.value}") sendEvent(map) } else if (map.name == "level") { handleLevelReporting(map.value.toInteger()) } } else { def descMap = zigbee.parseDescriptionAsMap(description) logDebug("Parsed map: ${descMap}") if ((descMap.clusterInt == SONOFF_CLUSTER_INT || descMap.clusterId == SONOFF_CLUSTER_ID) && description.startsWith("catchall:")) { logDebug("Sonoff catchall") return } // Power-on behavior (genOnOff cluster, standard attribute 0x4003 "startUpOnOff") - arrives either as an attribute report (command 0A) or a read attributes // response (command 01) depending on whether it was pushed or explicitly read via refresh()/configure(). if (descMap.clusterInt == 6 && descMap.attrId == "4003") { String behaviorText = ["00": POWER_ON_BEHAVIOR_OFF, "01": POWER_ON_BEHAVIOR_ON, "02": POWER_ON_BEHAVIOR_TOGGLE, "FF": POWER_ON_BEHAVIOR_PREVIOUS][descMap.value?.toUpperCase()] ?: "unknown (${descMap.value})" logText("${device.displayName} power-on behavior is ${behaviorText}") sendEvent(name: "powerOnBehavior", value: behaviorText) } // On/Off reporting else if (descMap.clusterInt == 6 && descMap.command == "0A") { def value = descMap.value == "01" ? "on" : "off" logText("${device.displayName} is ${value}") sendEvent(name: "switch", value: value) } // Level reporting else if (descMap.clusterInt == 8 && descMap.command == "0A") { int rawLevel = Integer.parseInt(descMap.value, 16) int level = Math.round(rawLevel * 100 / 254) handleLevelReporting(level) } } } private void handleLevelReporting(int level) { if (level > 0) { state.lastLevel = level // Cache the active level value logText("${device.displayName} level is ${level}%") sendEvent(name: "level", value: level, unit: "%") } else { // Device reports 0% because it turned off. Do not update 'level' event attribute. logDebug("Intercepted 0% level report. Preserving level at ${state.lastLevel ?: 100}%") } } private void parseCustomSonoffCluster(String description) { def map = zigbee.parseDescriptionAsMap(description) logDebug("Parsing Sonoff cluster: ${map}") def attributes = [] if (map.attrId) { attributes << [id: map.attrId, val: map.value] } if (map.additionalAttrs) { map.additionalAttrs.each { attributes << [id: it.attrId, val: it.value] } } double currentVoltage = device.currentValue("voltage") ?: 235.0 double currentPower = device.currentValue("power") ?: 0.0 float ghostPower = standbyGhostPower ? standbyGhostPower.toFloat() : DEFAULT_STANDBY_GHOST_POWER float powerThreshold = standbyPowerThreshold ? standbyPowerThreshold.toFloat() : DEFAULT_STANDBY_THRESHOLD boolean powerUpdated = false boolean voltageUpdated = false attributes.each { attr -> try { long rawValue = Long.parseLong(attr.val, 16) logDebug("Sonoff cluster, ${attr.id?.toUpperCase()}=${rawValue} (${attr.val})") switch (attr.id?.toUpperCase()) { // External switch mode case "0016": String statusText = ["0": SWITCH_MODE_TOGGLE, "1": SWITCH_MODE_MOMENTARY, "3": SWITCH_MODE_DUAL, "4": SWITCH_MODE_TRIPLE]["${rawValue}"] ?: "unknown (${rawValue})" logText("${device.displayName} external switch mode is ${statusText}") sendEvent(name: "switchMode", value: statusText) break // External switch rate case "4003": logText("${device.displayName} external switch rate is ${rawValue}") sendEvent(name: "switchRate", value: rawValue) break // Calibration status (0 = uncalibrated, 1 = calibrating, 2 = calibration_failed, 3 = calibrated) case "001E": String statusText = ["0": "uncalibrated", "1": "calibrating", "2": "calibration failed", "3": "calibrated"]["${rawValue}"] ?: "unknown (${rawValue})" logText("${device.displayName} calibration status is ${statusText}") sendEvent(name: "calibrationStatus", value: statusText) break // Calibration progress, percent case "0020": logText("${device.displayName} calibration progress is ${rawValue}%") sendEvent(name: "calibrationProgress", value: rawValue, unit: "%") break // Active power (Watts, scaled by 1000) case "7006": currentPower = (rawValue / 1000.0).toDouble().round(2) logDebug("Power: reported=${currentPower}, ghost=${ghostPower}, threshold=${powerThreshold}") currentPower = (currentPower - ghostPower).toDouble().round(2) logDebug("Power adjusted: ${currentPower}") if (currentPower <= powerThreshold) { logDebug("Power below threshold, suppressing to zero") currentPower = 0.0 } logText("${device.displayName} power is ${currentPower} W") sendEvent(name: "power", value: currentPower, unit: "W") powerUpdated = true break // Voltage (scaled by 1000) case "7005": currentVoltage = (rawValue / 1000.0).toDouble().round(1) logText("${device.displayName} voltage is ${currentVoltage} V") sendEvent(name: "voltage", value: currentVoltage, unit: "V") voltageUpdated = true break case "7004": logDebug("Raw attribute 7004: ${rawValue}") break } } catch (Exception e) { logError("Failed handling Sonoff attribute: ${e.message}") } } if ((powerUpdated || voltageUpdated) && currentVoltage > 0) { double calculatedAmps = (currentPower / currentVoltage).toDouble().round(3) logText("${device.displayName} current is ${calculatedAmps} A") sendEvent(name: "amperage", value: calculatedAmps, unit: "A") } } def on() { logDebug("Turning on") List cmds = [] float transitionSeconds = defaultTransition != null ? defaultTransition.toFloat() : DEFAULT_TRANSITION_RATE int tenthsOfSecond = Math.round(transitionSeconds * 10) if (state.currentDuration != transitionSeconds && dynamicTransitions) { logDebug("Dynamic transition change triggered: adjusting hardware configuration to ${tenthsOfSecond} (${transitionSeconds} s)") cmds += zigbee.writeAttribute(0xFC11, 0x001F, 0x23, tenthsOfSecond) // Push configuration commands ahead of the dim command state.currentDuration = transitionSeconds } cmds += zigbee.on() return cmds } def off() { logDebug("Turning off") List cmds = [] float transitionSeconds = defaultTransition != null ? defaultTransition.toFloat() : DEFAULT_TRANSITION_RATE int tenthsOfSecond = Math.round(transitionSeconds * 10) if (state.currentDuration != transitionSeconds && dynamicTransitions) { logDebug("Dynamic transition change triggered: adjusting hardware configuration to ${tenthsOfSecond} (${transitionSeconds} s)") cmds += zigbee.writeAttribute(0xFC11, 0x001F, 0x23, tenthsOfSecond) // Push configuration commands ahead of the dim command state.currentDuration = transitionSeconds } cmds += zigbee.off() return cmds } def setLevel(level, duration = null) { if (level < 0) { level = 0 } if (level > 100) { level = 100 } logDebug("Setting level to ${level}%") List cmds = [] float targetSeconds = (duration != null) ? duration.toFloat() : ((defaultTransition != null) ? defaultTransition.toFloat() : DEFAULT_TRANSITION_RATE) int tenthsOfSecond = Math.round(targetSeconds * 10) if (state.currentDuration != targetSeconds && dynamicTransitions) { logDebug("Dynamic transition change triggered: adjusting hardware configuration to ${tenthsOfSecond} (${targetSeconds} s)") cmds += zigbee.writeAttribute(0xFC11, 0x001F, 0x23, tenthsOfSecond) // Push configuration commands ahead of the dim command state.currentDuration = targetSeconds } cmds += zigbee.setLevel(level) // Keep local UI states aligned state.lastLevel = level sendEvent(name: "level", value: level, unit: "%") if (level > 0) { sendEvent(name: "switch", value: "on") } return cmds } def startCalibration() { logDebug("Starting calibration") return ["he wattr 0x${device.deviceNetworkId} 0x01 0xFC11 0x001D 0x42 {03010101}"] } def stopCalibration() { logDebug("Stopping calibration") return ["he wattr 0x${device.deviceNetworkId} 0x01 0xFC11 0x001D 0x42 {03010102}"] } def runRefresh() { logDebug("Scheduled refresh") refresh() } def refresh() { logDebug("Refreshing...") List cmds = zigbee.onOffRefresh() + zigbee.levelRefresh() + zigbee.readAttribute(SONOFF_CLUSTER_ID_HEX, [0x7004, 0x7005, 0x7006]) + // Power reporting zigbee.readAttribute(SONOFF_CLUSTER_ID_HEX, 0x001E) + // Calibration status zigbee.readAttribute(SONOFF_CLUSTER_ID_HEX, 0x0020) + // Calibration progress zigbee.readAttribute(SONOFF_CLUSTER_ID_HEX, 0x0016) + // Switch mode zigbee.readAttribute(SONOFF_CLUSTER_ID_HEX, 0x4003) + // Switch duration zigbee.readAttribute(0x0006, 0x4003) // Power-on behavior return delayBetween(cmds, 50) } def installed() { configure() } def configure() { logDebug("Configuring...") unschedule(runRefresh) schedule("0 5 */2 ? * * *", "runRefresh") // Normal core control configurations mixed with manufacturer registration hooks def configCmds = zigbee.onOffConfig() + zigbee.levelConfig() + //zigbee.configureReporting(SONOFF_CLUSTER_ID_HEX, 0x7004, 0x23, 0xFFFF, 0xFFFF, 0xFFFFFFFF, [mfgCode: SONOFF_MFG_ID]) + //"delay 200" + //zigbee.configureReporting(SONOFF_CLUSTER_ID_HEX, 0x7005, 0x23, 0xFFFF, 0xFFFF, 0xFFFFFFFF, [mfgCode: SONOFF_MFG_ID]) + //"delay 200" + //zigbee.configureReporting(SONOFF_CLUSTER_ID_HEX, 0x7006, 0x23, 5, 1800, 5000, [mfgCode: SONOFF_MFG_ID]) + "delay 5000" return configCmds + refresh() } def updated() { logDebug("Updated...") logDebug("${device.displayName} Debug logging is ${logEnable}, description text logging is ${txtEnable}") if (logEnable) { runIn(86400, logsOff, [overwrite: true]) // Turn off debug logging after 24 hours logDebug("Debug logging will be automatically switched off after 24 hours") } else { unschedule(logsOff) } List cmds = [] String powerOnString = powerOnBehavior ?: POWER_ON_BEHAVIOR_PREVIOUS int powerOnVal = 0xFF switch (powerOnString) { case POWER_ON_BEHAVIOR_OFF: powerOnVal = 0x00 break case POWER_ON_BEHAVIOR_ON: powerOnVal = 0x01 break case POWER_ON_BEHAVIOR_TOGGLE: powerOnVal = 0x02 break } logDebug("Power-on state: ${powerOnString} (${powerOnVal})") cmds += zigbee.writeAttribute(0x0006, 0x4003, 0x30, powerOnVal) float transitionSeconds = defaultTransition != null ? defaultTransition.toFloat() : DEFAULT_TRANSITION_RATE int tenthsOfSecond = Math.round(transitionSeconds * 10) logDebug("Default transition speed: ${tenthsOfSecond} (${transitionSeconds} s)") cmds += zigbee.writeAttribute(SONOFF_CLUSTER_ID_HEX, 0x001F, 0x23, tenthsOfSecond) state.currentDuration = transitionSeconds String switchModeString = switchMode ?: SWITCH_MODE_TRIPLE int switchModeVal = 0x04 switch (switchModeString) { case SWITCH_MODE_DUAL: switchModeVal = 0x03 break case SWITCH_MODE_MOMENTARY: switchModeVal = 0x01 break case SWITCH_MODE_TOGGLE: switchModeVal = 0x00 break } logDebug("External switch mode: ${switchModeString} (${switchModeVal})") cmds += zigbee.writeAttribute(SONOFF_CLUSTER_ID_HEX, 0x0016, 0x20, switchModeVal) String switchRateString = switchRate ?: SWITCH_RATE_4X int switchRateVal = 4 switch (switchRateString) { case SWITCH_RATE_1X: switchRateVal = 1 break case SWITCH_RATE_2X: switchRateVal = 2 break case SWITCH_RATE_3X: switchRateVal = 3 break case SWITCH_RATE_4X: switchRateVal = 4 break case SWITCH_RATE_5X: switchRateVal = 5 break } logDebug("External switch fade rate: ${switchRateVal}") cmds += zigbee.writeAttribute(SONOFF_CLUSTER_ID_HEX, 0x4003, 0x20, switchRateVal) cmds += "delay 5000" cmds += refresh() return cmds } def logsOff() { logDebug("Debug logging disabled") device.updateSetting("logEnable", [value:"false", type:"bool"]) } def logDebug(msg) { if (logEnable) { log.debug "${msg}" } } def logError(msg) { log.error "${msg}" } def logText(msg) { if (txtEnable) { log.info "${msg}" } }