Hello All,
I have a bunch of Aqara Zigbee contact sensors MCCGQ11LM that were not working correctly. They would get stuck open or close, when they worked they would bounce and still report open when they were closed.
I tried the built in drivers and the community drivers but none seemed to work like I wanted. So using AI I built upon the base that Markus built GitHub - markus-li/Hubitat: Apps and Drivers for Hubitat Elevation · GitHub and built on top of it.
My additions are a different debounce logic and one that is adjustable. Also adjustable check in time,
It seems with my additions it seems to work correctly. I just wanted others to have it if they want to use it.
Let me know if you have any questions.
Edit: 5/24/2067 - small fix to bit shift misalignment
* Advanced Aqara Contact Sensor Driver (Debounced, Extended, & Health Checked)
*
* 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
*
* Change History:
* 1.3.4 (2026-05-24) - Fixed Zigbee TLV type 0x24 length definition from 6 to 5 bytes to prevent parse loop bit-shift misalignment.
* 1.3.3 (2026-05-22) - Eliminated cross-thread state race conditions by routing debounce payloads directly through runInMillis options maps.
* 1.3.2 (2026-05-21) - Rewrote handleContactState to track current target state dynamically, preventing state-inversion on fast rattles.
* - Fixed parseAqaraLifeline loop alignment to correctly skip the payload length prefix byte.
* 1.3.1 (2026-05-21) - Converted PresenceSensor capability to a custom attribute to fix Hubitat Device List icon priority.
*/
metadata {
definition (name: "Advanced Aqara Contact Sensor (Debounced)", namespace: "Languy99", author: "Languy99@hubitat") {
capability "ContactSensor"
capability "Battery"
capability "Sensor"
capability "Configuration"
capability "Refresh"
capability "Health Check"
// Custom Attributes for UI/Dashboard tracking
attribute "batteryLastReplaced", "string"
attribute "restoredCounter", "number"
attribute "presence", "string"
// Custom Commands matching legacy diagnostic tools
command "resetBatteryReplacedDate"
command "resetRestoredCounter"
command "resetToClosed"
command "resetToOpen"
// Fingerprints for Original, T1, and E1 variants
fingerprint profileId: "0104", inClusters: "0000,0003,FFFF,0006", outClusters: "0000,0004", manufacturer: "LUMI", model: "lumi.sensor_magnet", deviceJoinName: "Aqara Contact Sensor (Original)"
fingerprint profileId: "0104", inClusters: "0000,0003,0001", outClusters: "0019", manufacturer: "LUMI", model: "lumi.magnet.agl02", deviceJoinName: "Aqara Contact Sensor T1"
fingerprint profileId: "0104", inClusters: "0000,0003,0001,0500", outClusters: "0019", manufacturer: "LUMI", model: "lumi.magnet.acn001", deviceJoinName: "Aqara Contact Sensor E1"
}
preferences {
input name: "debounceTime", type: "number", title: "Physical Debounce Time (ms)", description: "Filters out frame rattling/rebound. (Default: 800ms)", defaultValue: 800
input name: "ignoreLifelineState", type: "bool", title: "Ignore Contact State in Lifeline Report", description: "Highly Recommended. Prevents the 0xFF01 heartbeat report from overwriting real-time hardware transitions.", defaultValue: true
input name: "presenceTimeout", type: "number", title: "Offline Timeout (Hours)", description: "Number of hours without a check-in before marking device as Not Present. (Default: 3)", defaultValue: 3
input name: "logEnable", type: "bool", title: "Enable Debug Logging", defaultValue: true
}
}
// Main Parser
def parse(String description) {
if (logEnable) log.debug "Incoming raw description: ${description}"
markDevicePresent()
def map = [:]
try {
if (description.startsWith("read attr -") || description.startsWith("catchall:")) {
if (description.contains("cluster: 0000") && (description.contains("attrId: FF01") || description.contains("attrId: 00F7"))) {
map.cluster = "0000"
map.attrId = description.contains("attrId: FF01") ? "FF01" : "00F7"
def valueMatch = description =~ /value:\s*([0-9a-fA-F]+)/
if (valueMatch) map.value = valueMatch[0][1]
} else {
map = zigbee.parseDescriptionAsMap(description)
}
} else {
if (logEnable) log.debug "Dropped non-standard parsing description frame."
return null
}
} catch (Exception e) {
if (logEnable) log.warn "Shielded native Hubitat string boundary crash. Switched to emergency regex extractor."
map = emergencyRegexSplit(description)
}
if (!map) return null
if (map.cluster == "0013" || map.clusterId == "0013") {
int currentCount = (state.restoredCount ?: 0) + 1
state.restoredCount = currentCount
if (logEnable) log.info "${device.displayName} verified a Zigbee topology restoration/rejoin event."
sendEvent(name: "restoredCounter", value: currentCount, descriptionText: "Device processed a hardware network rejoin.")
return null
}
if (map.cluster == "0006" && map.attrId == "0000") {
def value = Integer.parseInt(map.value, 16)
def currentState = (value == 1) ? "open" : "closed"
handleContactState(currentState, "Physical Trigger (Cluster 0006)")
return null
}
if (map.cluster == "0500" && map.value) {
def status = Integer.parseInt(map.value, 16)
def currentState = (status & 1) ? "open" : "closed"
handleContactState(currentState, "IAS Physical Trigger (Cluster 0500)")
return null
}
if (map.cluster == "0000" && (map.attrId == "FF01" || map.attrId == "00F7")) {
parseAqaraLifeline(map.value)
return null
}
return null
}
def emergencyRegexSplit(String desc) {
def emMap = [:]
def clusterMatch = desc =~ /cluster:\s*([0-9a-fA-F]+)/
def attrMatch = desc =~ /attrId:\s*([0-9a-fA-F]+)/
def valMatch = desc =~ /value:\s*([0-9a-fA-F]+)/
if (clusterMatch) emMap.cluster = clusterMatch[0][1]
if (attrMatch) emMap.attrId = attrMatch[0][1]
if (valMatch) emMap.value = valMatch[0][1]
return emMap
}
// --- Presence / Health Monitoring ---
def markDevicePresent() {
if (device.currentValue("presence") != "present") {
if (logEnable) log.info "${device.displayName} has checked in. Marking as PRESENT."
sendEvent(name: "presence", value: "present", descriptionText: "Device is present on the Zigbee mesh.")
}
def timeoutHours = (presenceTimeout != null) ? presenceTimeout.toInteger() : 3
def timeoutSeconds = timeoutHours * 3600
runIn(timeoutSeconds, markDeviceOffline)
}
def markDeviceOffline() {
log.warn "${device.displayName} has not checked in for several hours. Marking as NOT PRESENT."
sendEvent(name: "presence", value: "not present", descriptionText: "Device has fallen off the Zigbee mesh.")
}
// --- Thread-Safe State Engine & Debounce Logic ---
def handleContactState(String newState, String source) {
def now = now()
def lastTime = state.lastStateTime ?: 0
def lastState = device.currentValue("contact")
def debounceMs = (debounceTime != null) ? debounceTime.toInteger() : 800
long delta = now - lastTime
// Catch rapid bouncing inside the active execution block
if (delta < debounceMs) {
if (logEnable) log.warn "Debounce Warning: Rapid oscillation detected from ${source}. Rescheduling target state settlement to: '${newState}'."
// Pass payload directly through options map to prevent cross-thread state reading errors
runInMillis(debounceMs, "processPendingState", [data: [state: newState]])
return
}
// Suppress clean out-of-window duplicates
if (lastState == newState) {
if (logEnable) log.debug "State is already recognized as '${newState}'. Suppressing duplicate frame."
return
}
// Legitimate transition outside of debounce window: commit instantly
state.lastStateTime = now
if (logEnable) log.info "${device.displayName} verified transition to: ${newState} [Source: ${source}]"
sendEvent(name: "contact", value: newState, descriptionText: "${device.displayName} is ${newState}")
// Safeguard clearance of any remaining scheduled trailing edges
unschedule("processPendingState")
}
def processPendingState(Map data) {
def settledState = data?.state
if (settledState) {
def lastState = device.currentValue("contact")
if (lastState != settledState) {
if (logEnable) log.info "${device.displayName} processing settled trailing-edge debounce state: ${settledState}"
state.lastStateTime = now()
sendEvent(name: "contact", value: settledState, descriptionText: "${device.displayName} settled to ${settledState}")
} else {
if (logEnable) log.debug "Debounce timer settled on '${settledState}', which matches current live status. Clean suppression applied."
}
}
}
def isHex(String str) {
return str && str.matches("^[0-9a-fA-F]+\$")
}
// --- Safe breakdown of proprietary hex data strings ---
def parseAqaraLifeline(String hexString) {
if (!hexString || hexString.length() < 4) return
try {
int i = 2
while (i < hexString.length()) {
if (i + 4 > hexString.length()) break
String tagHex = hexString.substring(i, i + 2)
String typeHex = hexString.substring(i + 2, i + 4)
i += 4
if (!isHex(tagHex) || !isHex(typeHex)) {
if (logEnable) log.debug "Skipped malformed or non-hex string frame segment at token headers."
break
}
int tag = Integer.parseInt(tagHex, 16)
int len = 0
if (typeHex == "10") len = 1
else if (typeHex == "20") len = 1
else if (typeHex == "21") len = 2
else if (typeHex == "24") len = 5 // FIXED: Changed from 6 to 5. Zigbee DataType 0x24 is uint40 (5 bytes).
else if (typeHex == "28") len = 1
else {
if (logEnable) log.debug "Encountered unknown/unsupported TLV type [${typeHex}] at index ${i}. Halting parse loop."
break
}
if (i + (len * 2) > hexString.length()) {
if (logEnable) log.debug "Short frame packet boundary identified. Safely stopping parse sequence at index ${i}."
break
}
String valHex = hexString.substring(i, i + (len * 2))
i += (len * 2)
if (!isHex(valHex)) {
if (logEnable) log.debug "Skipped processing payload value string [${valHex}] because it contains non-hex string structures."
continue
}
if (tag == 1) {
int v = Integer.parseInt(valHex.substring(2,4) + valHex.substring(0,2), 16)
handleBatteryCalculation(v)
}
else if (tag == 3) {
int t = Integer.parseInt(valHex, 16)
if (t > 127) t -= 256
}
else if (tag == 100) {
if (ignoreLifelineState == true) {
// Ignored processing per driver setting optimization
} else {
int stateVal = Integer.parseInt(valHex, 16)
String derivedState = (stateVal == 1) ? "open" : "closed"
handleContactState(derivedState, "Lifeline Heartbeat Parser")
}
}
}
} catch (Exception e) {
log.error "Failed executing explicit breakdown of custom payload string: ${e.message}"
}
}
def handleBatteryCalculation(int millivolts) {
double volts = millivolts / 1000.0
int pct = 100
if (volts >= 3.0) pct = 100
else if (volts <= 2.6) pct = 0
else pct = (int) ((volts - 2.6) / 0.4 * 100)
if (device.currentValue("battery") != pct) {
if (logEnable) log.info "${device.displayName} Power Analysis updated: ${pct}% (${volts}V)"
sendEvent(name: "battery", value: pct, unit: "%", descriptionText: "${device.displayName} battery level reads at ${pct}%")
}
}
def resetBatteryReplacedDate() {
def nowDate = new Date().format("yyyy-MM-dd HH:mm:ss", location.timeZone)
if (logEnable) log.info "Executing date reset: Tracking battery installation timeline to current system time."
sendEvent(name: "batteryLastReplaced", value: nowDate, descriptionText: "Battery installation calendar date manually updated.")
}
def resetRestoredCounter() {
if (logEnable) log.info "Clearing network topology restoration audit records."
state.restoredCount = 0
sendEvent(name: "restoredCounter", value: 0, descriptionText: "Mesh restoration statistics reset to zero.")
}
def resetToClosed() {
log.warn "Manual Administrative override invoked: Forcing device state database entry to CLOSED."
state.lastStateTime = now()
sendEvent(name: "contact", value: "closed", descriptionText: "State forced to closed via manual administrative command override.")
}
def resetToOpen() {
log.warn "Manual Administrative override invoked: Forcing device state database entry to OPEN."
state.lastStateTime = now()
sendEvent(name: "contact", value: "open", descriptionText: "State forced to open via manual administrative command override.")
}
def installed() {
log.info "Advanced Aqara Driver Initialized."
initialize()
}
def updated() {
log.info "Driver execution preferences modified."
initialize()
}
def initialize() {
state.lastStateTime = 0
if (state.restoredCount == null) state.restoredCount = 0
if (device.currentValue("restoredCounter") == null) sendEvent(name: "restoredCounter", value: 0)
if (device.currentValue("batteryLastReplaced") == null) sendEvent(name: "batteryLastReplaced", value: "Not Initialized")
sendEvent(name: "checkInterval", value: 60 * 60 * 4, displayed: false, data: [protocol: "zigbee", hubHardwareId: device.hub.hardwareID])
markDevicePresent()
}
def configure() {
log.info "Executing core Zigbee enrollment tasks..."
return zigbee.readAttribute(0x0000, 0x0005) + zigbee.configureReporting(0x0006, 0x0000, 0x18, 0, 3600, 1)
}
def refresh() {
if (logEnable) log.debug "Polling hardware identifiers..."
return zigbee.readAttribute(0x0000, 0x0001) + zigbee.readAttribute(0x0006, 0x0000)
}
def ping() {
if (logEnable) log.debug "System Health Check ping requested."
return zigbee.readAttribute(0x0000, 0x0001)
}