I recently came across an Aqara Door & Window sensor T1
It seems to be an older device and none of the Xiaomi or Aqara drivers worked so I threw together one with help of chatgpt.
I’m sharing a fully featured custom Hubitat Zigbee driver for the Aqara Door/Window Sensor (lumi.magnet.agl02
), built to handle all of the quirks this device has — including its tendency to drop off, ignore refresh commands, and require special enrollment.
This driver includes not just open/close detection, but enhanced battery monitoring, duration tracking, and timestamps for meaningful event history.
Feature | Description |
---|---|
![]() |
Real-time detection via IAS Zone (Cluster 0x0500) |
![]() |
Auto-calculated from voltage (Cluster 0x0001 Attr 0x0020) |
![]() |
Separate batteryVoltage attribute (e.g., 3.0V) |
![]() lastOpened / lastClosed |
Human-readable timestamps |
![]() lastContactChange |
Last time the contact changed (open or closed) |
![]() contactDuration |
Seconds the device stayed in its previous state |
![]() lastBatteryReport |
When the battery was last updated |
![]() |
Ensures open/close reporting works reliably |
![]() |
Toggleable in Preferences |
How to Use
- Install the Driver
- Go to Hubitat → Drivers Code
- Click + New Driver
- Paste the driver code from [GitHub Gist or this post]
- Save
- Assign the Driver
- Go to your Devices list
- Open your
lumi.magnet.agl02
device - In the "Type" dropdown, select Aqara Door/Window Sensor Advanced
- Click Save
- Click Configure
- This sends the custom IAS Zone enrollment required by Aqara devices
- Click Refresh
- This attempts to manually read battery and contact status
Aqara often ignores manual refresh — see quirks below
Aqara Quirks You Should Know
Aqara sensors use a non-standard Zigbee implementation:
They don’t reliably respond to
readAttribute()
commands for batteryThey may fall off the network if polled too frequently
They do report battery voltage when:
- You press the physical button on the device
- Sometimes automatically every ~6–12 hours
That’s why we track
lastBatteryReport
, so you can monitor how stale the battery data is.
Recommended Rules or Automations
1. Alert if battery hasn't reported in 48 hours
Use Rule Machine with:
- Condition:
lastBatteryReport is older than 48 hours
- Action: Send push/email notification
2. Trigger automations with contactDuration
Examples:
- "If contact has been open for more than 300 seconds, send alert"
- "If door just closed and
contactDuration > 60
, log a ‘long open’ event"
Attribute | Type | Notes |
---|---|---|
contact |
open / closed |
Core |
battery |
Percentage (%) | Estimated |
batteryVoltage |
Float (e.g., 2.9V) | Raw |
lastOpened |
Timestamp | "2025-05-03 13:25:00" |
lastClosed |
Timestamp | "" |
lastContactChange |
Timestamp | When either open or close occurred |
contactDuration |
Integer (seconds) | Duration of previous state |
lastBatteryReport |
Timestamp | When battery was last updated |
Download the Driver
You can find the driver source here:
[https://gist.githubusercontent.com/enishoca/44ad9c0bf75e50c6ae311db196c688de/raw/702be985ca7b919bbc0e85eeca4ffc0bbb141e67/AqaraDoorWindowSensorT1.groovy]
/**
* Aqara Door/Window Sensor T1
*
* https://gist.githubusercontent.com/enishoca/44ad9c0bf75e50c6ae311db196c688de/raw/702be985ca7b919bbc0e85eeca4ffc0bbb141e67/AqaraDoorWindowSensorT1.groovy
*
*
* Copyright 2025 Enis Hoca
*
* 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.
*
* Change History:
*
* Date Who What
* ---- --- ----
* 2017-04-19 Enis Hoca Original Creation
*
*
*/
metadata {
definition(name: "Aqara Door/Window Sensor T1", namespace: "custom", author: "enishoca") {
capability "Contact Sensor"
capability "Battery"
capability "Configuration"
capability "Refresh"
capability "Sensor"
attribute "batteryVoltage", "number"
attribute "lastBatteryReport", "string"
attribute "lastOpened", "string"
attribute "lastClosed", "string"
attribute "lastContactChange", "string"
attribute "contactDuration", "number"
fingerprint profileId:"0104", endpointId:"01", inClusters:"0000,0001,0003,0500", outClusters:"0019", model:"lumi.magnet.agl02", manufacturer:"LUMI"
}
preferences {
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: true
}
}
def parse(String description) {
if (logEnable) log.debug "RAW: ${description}"
if (description?.startsWith("zone status")) {
return handleZoneStatus(description)
}
def descMap = zigbee.parseDescriptionAsMap(description)
if (logEnable) log.debug "descMap: ${descMap}"
if (descMap?.clusterInt == 0x0001 && descMap.attrInt == 0x0020 && descMap.value) {
try {
def rawVolts = Integer.parseInt(descMap.value, 16)
def volts = rawVolts / 10.0
def minVolts = 2.5
def maxVolts = 3.0
def pct = ((volts - minVolts) / (maxVolts - minVolts) * 100).toInteger()
pct = Math.max(0, Math.min(pct, 100))
def nowStr = new Date().format("yyyy-MM-dd HH:mm:ss", location.timeZone)
if (logEnable) log.debug "Battery voltage: ${volts}V → ${pct}%"
sendEvent(name: "battery", value: pct, unit: "%")
sendEvent(name: "batteryVoltage", value: volts, unit: "V")
sendEvent(name: "lastBatteryReport", value: nowStr)
} catch (Exception e) {
log.warn "Failed to parse battery voltage: ${descMap.value}"
}
}
return []
}
def handleZoneStatus(description) {
def zoneStatus = description.replace("zone status", "").trim().split(" ")[0]
def statusInt = Integer.parseInt(zoneStatus.replace("0x", ""), 16)
def contactState = (statusInt & 0x0001) ? "open" : "closed"
def now = new Date()
def nowStr = now.format("yyyy-MM-dd HH:mm:ss", location.timeZone)
sendEvent(name: "contact", value: contactState)
sendEvent(name: "lastContactChange", value: nowStr)
if (contactState == "open") {
sendEvent(name: "lastOpened", value: nowStr)
} else {
sendEvent(name: "lastClosed", value: nowStr)
}
// Duration tracking
def lastChange = device.currentValue(contactState == "open" ? "lastClosed" : "lastOpened")
if (lastChange) {
try {
def lastTime = Date.parse("yyyy-MM-dd HH:mm:ss", lastChange)
def duration = ((now.time - lastTime.time) / 1000).toInteger()
sendEvent(name: "contactDuration", value: duration)
} catch (e) {
log.warn "Failed to parse last state time: ${lastChange}"
}
}
if (logEnable) log.debug "Parsed contact state: ${contactState} at ${nowStr}"
}
def configure() {
if (logEnable) log.debug "Sending custom IAS enroll response..."
def dni = device.deviceNetworkId
def cmds = []
cmds += [
"he raw 0x${dni} 0x01 0x0500 0x00 {01 00}",
"delay 200"
]
cmds += zigbee.readAttribute(0x0001, 0x0020)
return cmds
}
def refresh() {
if (logEnable) log.debug "Requesting battery and contact status..."
return zigbee.readAttribute(0x0001, 0x0020) + zigbee.readAttribute(0x0500, 0x0002)
}
def updated() {
if (logEnable) log.debug "Driver updated"
runIn(1800, logsOff)
configure()
}
def logsOff() {
log.warn "Debug logging disabled."
device.updateSetting("logEnable", [value: "false", type: "bool"])
}