/* * * Xiaomi Aqara Wireless Mini Switch WXKG11LM Driver v1.03 (11th January 2022) * */ import groovy.transform.Field @Field boolean debugMode = false @Field int reportIntervalMinutes = 50 metadata { definition (name: "Xiaomi Aqara Wireless Mini Switch WXKG11LM", namespace: "BirdsLikeWires", author: "Andrew Davison", importUrl: "https://raw.githubusercontent.com/birdslikewires/hubitat/master/xiaomi/drivers/xiaomi_aqara_wireless_mini_switch_wxkg11lm.groovy") { capability "Battery" capability "Configuration" capability "DoubleTapableButton" capability "PresenceSensor" capability "PushableButton" //capability "TemperatureMeasurement" // Just because you can doesn't mean you should. attribute "batteryState", "string" attribute "batteryVoltage", "string" attribute "batteryVoltageWithUnit", "string" attribute "batteryWithUnit", "string" //attribute "temperatureWithUnit", "string" if (debugMode) { command "checkPresence" command "testCommand" } fingerprint profileId: "0104", inClusters: "0000,FFFF,0006", outClusters: "0000,0004,FFFF", manufacturer: "LUMI", model: "lumi.sensor_switch.aq2", deviceJoinName: "WXKG11LM", application: "03" } } preferences { input name: "infoLogging", type: "bool", title: "Enable logging", defaultValue: true input name: "debugLogging", type: "bool", title: "Enable debug logging", defaultValue: false input name: "traceLogging", type: "bool", title: "Enable trace logging", defaultValue: false } def testCommand() { logging("${device} : Test Command", "info") } def installed() { // Runs after first installation. logging("${device} : Installed", "info") configure() } def configure() { // Tidy up. unschedule() state.clear() state.presenceUpdated = 0 sendEvent(name: "presence", value: "present", isStateChange: false) // Set default preferences. device.updateSetting("infoLogging", [value: "true", type: "bool"]) device.updateSetting("debugLogging", [value: "${debugMode}", type: "bool"]) device.updateSetting("traceLogging", [value: "${debugMode}", type: "bool"]) // Schedule reporting and presence checking. int randomSixty int checkEveryMinutes = 10 randomSixty = Math.abs(new Random().nextInt() % 60) schedule("${randomSixty} 0/${checkEveryMinutes} * * * ? *", checkPresence) // Set device name. device.name = "Xiaomi Aqara Wireless Mini Switch WXKG11LM" // Notify. sendEvent(name: "configuration", value: "complete", isStateChange: false) logging("${device} : Configuration complete.", "info") updated() } void updated() { // Runs when preferences are saved. unschedule(infoLogOff) unschedule(debugLogOff) unschedule(traceLogOff) if (!debugMode) { runIn(2400,debugLogOff) runIn(1200,traceLogOff) } logging("${device} : Preferences Updated", "info") loggingStatus() } void push(buttonId) { sendEvent(name:"pushed", value: buttonId, isStateChange:true) } void doubleTap(buttonId) { sendEvent(name:"doubleTapped", value: buttonId, isStateChange:true) } def parse(String description) { // Primary parse routine. logging("${device} : Parse : $description", "debug") updatePresence() Map descriptionMap = null if (description.indexOf('encoding: 10') >= 0 || description.indexOf('encoding: 20') >= 0) { // Normal encoding should bear some resemblance to the Zigbee Cluster Library Specification descriptionMap = zigbee.parseDescriptionAsMap(description) } else { // Anything else is specific to Xiaomi, so we'll just slice and dice the string we receive. descriptionMap = description.split(', ').collectEntries { entry -> def pair = entry.split(': ') [(pair.first()): pair.last()] } } if (descriptionMap) { processMap(descriptionMap) } else { logging("${device} : Parse : Failed to parse received data. Please report these messages to the developer.", "warn") logging("${device} : Splurge! : ${description}", "warn") } } void processMap(Map map) { logging("${device} : processMap() : ${map}", "trace") String[] receivedData = map.data if (map.cluster == "0006") { // Here we handle the button presses. int buttonNumber = map.value[-1..-1].toInteger() buttonNumber = buttonNumber == 0 ? 1 : buttonNumber if (buttonNumber == 2) { logging("${device} : Trigger : Button 1 Double Tapped", "info") sendEvent(name: "doubleTapped", value: 1, isStateChange: true) } logging("${device} : Trigger : Button ${buttonNumber} Pressed", "info") sendEvent(name: "pushed", value: buttonNumber, isStateChange: true) } else if (map.cluster == "0000") { if (map.attrId == "0005" || map.attrId == "FF01") { def deviceData = "" if (map.attrId == "0005") { // Grab battery data triggered by short press of the reset button. deviceData = map.value.split('FF42')[1] // Scrounge more value! It's another button, so as these can quad-click we'll call this button five. logging("${device} : Trigger : Button 5 Pressed", "info") sendEvent(name: "pushed", value: 5, isStateChange: true) } else { // We get data with an attrId of FF01 when pressed more than twice, but only the check-in reports contain battery info. def dataSize = map.value.size() if (dataSize > 20) { deviceData = map.value } else { logging("${device} : deviceData : No battery information in this report.", "debug") return } } // Report the battery voltage and calculated percentage. def batteryVoltageHex = "undefined" BigDecimal batteryVoltage = 0 batteryVoltageHex = deviceData[8..9] + deviceData[6..7] logging("${device} : batteryVoltageHex : ${batteryVoltageHex}", "trace") batteryVoltage = zigbee.convertHexToInt(batteryVoltageHex) logging("${device} : batteryVoltage sensor value : ${batteryVoltage}", "debug") batteryVoltage = batteryVoltage.setScale(2, BigDecimal.ROUND_HALF_UP) / 1000 logging("${device} : batteryVoltage : ${batteryVoltage}", "debug") sendEvent(name: "batteryVoltage", value: batteryVoltage, unit: "V") sendEvent(name: "batteryVoltageWithUnit", value: "${batteryVoltage} V") BigDecimal batteryPercentage = 0 BigDecimal batteryVoltageScaleMin = 2.1 BigDecimal batteryVoltageScaleMax = 3.0 if (batteryVoltage >= batteryVoltageScaleMin) { state.batteryOkay = true batteryPercentage = ((batteryVoltage - batteryVoltageScaleMin) / (batteryVoltageScaleMax - batteryVoltageScaleMin)) * 100.0 batteryPercentage = batteryPercentage.setScale(0, BigDecimal.ROUND_HALF_UP) batteryPercentage = batteryPercentage > 100 ? 100 : batteryPercentage if (batteryPercentage > 20) { logging("${device} : Battery : $batteryPercentage% ($batteryVoltage V)", "info") } else { logging("${device} : Battery : $batteryPercentage% ($batteryVoltage V)", "warn") } sendEvent(name: "battery", value:batteryPercentage, unit: "%") sendEvent(name: "batteryWithUnit", value:"${batteryPercentage} %") sendEvent(name: "batteryState", value: "discharging") } else { // Very low voltages indicate an exhausted battery which requires replacement. state.batteryOkay = false batteryPercentage = 0 logging("${device} : Battery : Exhausted battery requires replacement.", "warn") logging("${device} : Battery : $batteryPercentage% ($batteryVoltage V)", "warn") sendEvent(name: "battery", value:batteryPercentage, unit: "%") sendEvent(name: "batteryWithUnit", value:"${batteryPercentage} %") sendEvent(name: "batteryState", value: "exhausted") } // Report the temperature in celsius. // def temperatureValue = "undefined" // temperatureValue = deviceData[14..15] // logging("${device} : temperatureValue : ${temperatureValue}", "trace") // BigDecimal temperatureCelsius = hexToBigDecimal(temperatureValue) // logging("${device} : temperatureCelsius sensor value : ${temperatureCelsius}", "trace") // logging("${device} : Temperature : $temperatureCelsius °C", "info") // sendEvent(name: "temperature", value: temperatureCelsius, unit: "C") // sendEvent(name: "temperatureWithUnit", value: "${temperatureCelsius} °C") } else { // processBasic(map) reportToDev(map) } } else { reportToDev(map) } } // Library v1.01 (11th January 2022) void sendZigbeeCommands(List cmds) { // All hub commands go through here for immediate transmission and to avoid some method() weirdness. logging("${device} : sendZigbeeCommands received : ${cmds}", "trace") sendHubCommand(new hubitat.device.HubMultiAction(cmds, hubitat.device.Protocol.ZIGBEE)) } void updatePresence() { long millisNow = new Date().time state.presenceUpdated = millisNow sendEvent(name: "presence", value: "present") } void checkPresence() { // Check how long ago the presence state was updated. int uptimeAllowanceMinutes = 20 // The hub takes a while to settle after a reboot. if (state.presenceUpdated > 0) { long millisNow = new Date().time long millisElapsed = millisNow - state.presenceUpdated long presenceTimeoutMillis = ((reportIntervalMinutes * 2) + 20) * 60000 long reportIntervalMillis = reportIntervalMinutes * 60000 BigInteger secondsElapsed = BigDecimal.valueOf(millisElapsed / 1000) BigInteger hubUptime = location.hub.uptime if (millisElapsed > presenceTimeoutMillis) { if (hubUptime > uptimeAllowanceMinutes * 60) { sendEvent(name: "presence", value: "not present") logging("${device} : Presence : Not Present! Last report received ${secondsElapsed} seconds ago.", "warn") } else { logging("${device} : Presence : Ignoring overdue presence reports for ${uptimeAllowanceMinutes} minutes. The hub was rebooted ${hubUptime} seconds ago.", "debug") } } else { sendEvent(name: "presence", value: "present") logging("${device} : Presence : Last presence report ${secondsElapsed} seconds ago.", "debug") } logging("${device} : checkPresence() : ${millisNow} - ${state.presenceUpdated} = ${millisElapsed}", "trace") logging("${device} : checkPresence() : Report interval is ${reportIntervalMillis} ms, timeout is ${presenceTimeoutMillis} ms.", "trace") } else { logging("${device} : Presence : Waiting for first presence report.", "warn") } } void processBasic(Map map) { // Process the basic descriptors normally received from Zigbee Cluster 0000 into device data values. if (map.attrId == "0001") { updateDataValue("application", "${map.value}") logging("${device} : Application : ${map.value}", "debug") } else if (map.attrId == "0004") { updateDataValue("manufacturer", map.value) logging("${device} : Manufacturer : ${map.value}", "debug") } else if (map.attrId == "0005") { updateDataValue("model", map.value) logging("${device} : Model : ${map.value}", "debug") } else if (map.attrId == "4000") { updateDataValue("softwareBuild", "${map.value}") logging("${device} : Firmware : ${map.value}", "debug") } } void processDescriptors(Map map) { // Process the simple descriptors normally received from Zigbee Cluster 8004 into device data values. String[] receivedData = map.data if (receivedData[1] == "00") { // Received simple descriptor data. //updateDataValue("endpointId", receivedData[5]) // can lead to a weird duplicate updateDataValue("profileId", receivedData[6..7].reverse().join()) Integer inClusterNum = Integer.parseInt(receivedData[11], 16) Integer position = 12 Integer positionCounter = null String inClusters = "" if (inClusterNum > 0) { (1..inClusterNum).each() {b-> positionCounter = position+((b-1)*2) inClusters += receivedData[positionCounter..positionCounter+1].reverse().join() if (b < inClusterNum) { inClusters += "," } } } position += inClusterNum*2 Integer outClusterNum = Integer.parseInt(receivedData[position], 16) position += 1 String outClusters = "" if (outClusterNum > 0) { (1..outClusterNum).each() {b-> positionCounter = position+((b-1)*2) outClusters += receivedData[positionCounter..positionCounter+1].reverse().join() if (b < outClusterNum) { outClusters += "," } } } updateDataValue("inClusters", inClusters) updateDataValue("outClusters", outClusters) logging("${device} : Received $inClusterNum inClusters : $inClusters", "debug") logging("${device} : Received $outClusterNum outClusters : $outClusters", "debug") } else { reportToDev(map) } } void reportToDev(map) { String[] receivedData = map.data def receivedDataCount = "" if (receivedData != null) { receivedDataCount = "${receivedData.length} bits of " } logging("${device} : UNKNOWN DATA! Please report these messages to the developer.", "warn") logging("${device} : Received : endpoint: ${map.endpoint}, cluster: ${map.cluster}, clusterId: ${map.clusterId}, attrId: ${map.attrId}, command: ${map.command} with value: ${map.value} and ${receivedDataCount}data: ${receivedData}", "warn") logging("${device} : Splurge! : ${map}", "trace") } @Field static Boolean debouncingParentState = false void debounceParentState(String attribute, String state, String message, String level, Integer duration) { if (debouncingParentState) return debouncingParentState = true sendEvent(name: "$attribute", value: "$state") logging("${device} : $message", "$level") pauseExecution duration debouncingParentState = false } def fetchChild(String type, String endpoint) { // Creates and retrieves child devices matched to endpoints. def childDevice = getChildDevice("${device.id}-${endpoint}") if (endpoint != "null") { if (!childDevice) { logging("${device} : Creating child device $device.id-$endpoint", "debug") childDevice = addChildDevice("hubitat", "Generic Component ${type}", "${device.id}-${endpoint}", [name: "${device.displayName} ${type} ${endpoint}", label: "${device.displayName} ${type} ${endpoint}", isComponent: false]) if (type == "Switch") { // We could use this as an opportunity to set all the relays to a known state, but we don't. Just in case. childDevice.parse([[name: "switch", value: 'off']]) } else { logging("${device} : fetchChild() : I don't know what to do with the '$type' device type.", "error") } childDevice.updateSetting("txtEnable", false) } logging("${device} : Retrieved child device $device.id-$endpoint", "debug") } else { logging("${device} : Received null endpoint for device $device.id", "error") } return childDevice } def fetchChildStates(String state, String requestor) { // Retrieves requested states of child devices. logging("${device} : fetchChildStates() : Called by $requestor", "debug") def childStates = [] def children = getChildDevices() children.each {child-> // Give things a chance! pauseExecution(100) // Grabs the requested state from the child device. String childState = child.currentValue("${state}") if ("${child.id}" != "${requestor}") { // Don't include the requestor's state in the results, as we're likely in the process of updating it. childStates.add("${childState}") logging("${device} : fetchChildStates() : Found $child.id is '$childState'", "debug") } } return childStates } void deleteChildren() { // Deletes children we may have created. logging("${device} : deleteChildren() : Deleting rogue children.", "debug") def children = getChildDevices() children.each {child-> deleteChildDevice(child.deviceNetworkId) } } void componentRefresh(com.hubitat.app.DeviceWrapper childDevice) { logging("componentRefresh() from $childDevice.deviceNetworkId", "debug") sendZigbeeCommands(["he rattr 0x${device.deviceNetworkId} 0x${childDevice.deviceNetworkId.split("-")[1]} 0x0006 0x00 {}"]) } void componentOn(com.hubitat.app.DeviceWrapper childDevice) { logging("componentOn() from $childDevice.deviceNetworkId", "debug") sendZigbeeCommands(["he cmd 0x${device.deviceNetworkId} 0x${childDevice.deviceNetworkId.split("-")[1]} 0x0006 0x01 {}"]) } void componentOff(com.hubitat.app.DeviceWrapper childDevice) { logging("componentOff() from $childDevice.deviceNetworkId", "debug") sendZigbeeCommands(["he cmd 0x${device.deviceNetworkId} 0x${childDevice.deviceNetworkId.split("-")[1]} 0x0006 0x00 {}"]) } private String flipLittleEndian(Map map, String attribute) { String bigEndianAttribute = "" for (int v = map."${attribute}".length(); v > 0; v -= 2) { bigEndianAttribute += map."${attribute}".substring(v - 2, v) } return bigEndianAttribute } private String[] millisToDhms(int millisToParse) { long secondsToParse = millisToParse / 1000 def dhms = [] dhms.add(secondsToParse % 60) secondsToParse = secondsToParse / 60 dhms.add(secondsToParse % 60) secondsToParse = secondsToParse / 60 dhms.add(secondsToParse % 24) secondsToParse = secondsToParse / 24 dhms.add(secondsToParse % 365) return dhms } private String percentageToHex(Integer pc) { BigDecimal safePc = pc > 0 ? (pc*2.55) : 0 safePc = safePc > 255 ? 255 : safePc return Integer.toHexString(safePc.intValue()) } private Integer hexToPercentage(String hex) { String safeHex = hex.take(2) Integer pc = Integer.parseInt(safeHex, 16) << 21 >> 21 return pc / 2.55 } private BigDecimal hexToBigDecimal(String hex) { int d = Integer.parseInt(hex, 16) << 21 >> 21 return BigDecimal.valueOf(d) } private String hexToBinary(String thisByte, Integer size = 8) { String binaryValue = new BigInteger(thisByte, 16).toString(2); return String.format("%${size}s", binaryValue).replace(' ', '0') } void loggingStatus() { log.info "${device} : Info Logging : ${infoLogging == true}" log.debug "${device} : Debug Logging : ${debugLogging == true}" log.trace "${device} : Trace Logging : ${traceLogging == true}" } void traceLogOff(){ log.trace "${device} : Trace Logging : Automatically Disabled" device.updateSetting("traceLogging",[value:"false",type:"bool"]) } void debugLogOff(){ log.debug "${device} : Debug Logging : Automatically Disabled" device.updateSetting("debugLogging",[value:"false",type:"bool"]) } void infoLogOff(){ log.info "${device} : Info Logging : Automatically Disabled" device.updateSetting("infoLogging",[value:"false",type:"bool"]) } private boolean logging(String message, String level) { boolean didLog = false if (level == "error") { log.error "$message" didLog = true } if (level == "warn") { log.warn "$message" didLog = true } if (traceLogging && level == "trace") { log.trace "$message" didLog = true } if (debugLogging && level == "debug") { log.debug "$message" didLog = true } if (infoLogging && level == "info") { log.info "$message" didLog = true } return didLog }