Thanks for the feedback @kkossev.
I recently received the MS605 and it is very promising.
Not played with it much, I have placed it in the shower, and so far it has been great (tested with Aqara M3 hub automations).
I could not get the Matter Advanced Bridge driver to work properly due to the timeout issues that you describe.
Anyway, I've had a go at ChatGPT just now, see test driver below (note: illuminance is not working).
/*
* Meross MS605 Matter Presence Sensor (Motion + Presence + Lux + Battery)
*
* HOLD logic:
* - Any occupied -> Motion ACTIVE immediately, Presence PRESENT immediately.
* - When all clear -> schedule Motion INACTIVE after motionHoldSeconds
* -> schedule Presence NOT PRESENT after presenceHoldSeconds
* - If occupancy returns before timers expire -> pending clears are cancelled.
*
* EP01: 0003,001D,0028,002F,0400 (Illuminance + Power Source/Battery)
* EP02-04: 0003,001D,0028,0406 (Occupancy)
*
* Last edited: 2026-01-04
*/
import hubitat.device.HubAction
import hubitat.device.Protocol
import groovy.transform.Field
@Field static final Integer EP_LUX_BAT = 0x01
@Field static final List<Integer> EP_OCC = [0x02, 0x03, 0x04]
metadata {
definition(name: "Meross MS605 Matter Presence+Lux+Battery", namespace: "community", author: "iEnam + ChatGPT") {
fingerprint endpointId:"01", inClusters:"0003,001D,0028,002F,0400", outClusters:"", model:"Smart Presence Sensor", manufacturer:"Meross", controllerType:"MAT"
fingerprint endpointId:"02", inClusters:"0003,001D,0028,0406", outClusters:"", model:"Smart Presence Sensor", manufacturer:"Meross", controllerType:"MAT"
fingerprint endpointId:"03", inClusters:"0003,001D,0028,0406", outClusters:"", model:"Smart Presence Sensor", manufacturer:"Meross", controllerType:"MAT"
fingerprint endpointId:"04", inClusters:"0003,001D,0028,0406", outClusters:"", model:"Smart Presence Sensor", manufacturer:"Meross", controllerType:"MAT"
capability "Sensor"
capability "Initialize"
capability "Refresh"
capability "MotionSensor"
capability "PresenceSensor"
capability "IlluminanceMeasurement"
capability "Battery"
}
preferences {
input name: "txtEnable", type: "bool", title: "Enable descriptionText logging", defaultValue: true
input name: "logEnable", type: "bool", title: "Enable debug logging", defaultValue: false
input name: "luxDivisor", type: "number",
title: "Lux divisor (100 for lux*100, 1 for raw lux)",
defaultValue: 100, range: "1..1000"
// Requested tighter ranges:
input name: "motionHoldSeconds", type: "number",
title: "Motion hold time (seconds) after clear (0 = immediate)",
defaultValue: 10, range: "0..120"
input name: "presenceHoldSeconds", type: "number",
title: "Presence hold time (seconds) after clear",
defaultValue: 30, range: "5..120"
// Optional failsafe for devices that never send inactive
input name: "autoClearSeconds", type: "number",
title: "Failsafe auto-clear after N seconds from occupied (0 = disabled)",
defaultValue: 0, range: "0..3600"
}
}
void installed() { if (logEnable) log.debug "installed()"; initialize() }
void updated() {
if (logEnable) log.debug "updated()"
if (logEnable) runIn(1800, "logsOff")
initialize()
}
void logsOff() {
device.updateSetting("logEnable", [value: "false", type: "bool"])
log.warn "Debug logging disabled"
}
void initialize() {
if (logEnable) log.debug "initialize()"
subscribeToAttributes()
refresh()
}
void refresh() {
if (logEnable) log.debug "refresh()"
List<Map<String, String>> paths = []
paths.add(matter.attributePath(EP_LUX_BAT, 0x0400, 0x0000)) // lux
paths.add(matter.attributePath(EP_LUX_BAT, 0x002F, 0x000C)) // battery %
paths.add(matter.attributePath(EP_LUX_BAT, 0x002F, 0x0000)) // power source status
EP_OCC.each { ep ->
paths.add(matter.attributePath(ep, 0x0406, 0x0000)) // occupancy
}
sendHubCommand(new HubAction(matter.readAttributes(paths), Protocol.MATTER))
}
private void subscribeToAttributes() {
if (logEnable) log.debug "subscribeToAttributes()"
List<Map<String, String>> paths = []
paths.add(matter.attributePath(EP_LUX_BAT, 0x0400, 0x0000))
paths.add(matter.attributePath(EP_LUX_BAT, 0x002F, 0x000C))
paths.add(matter.attributePath(EP_LUX_BAT, 0x002F, 0x0000))
EP_OCC.each { ep ->
paths.add(matter.attributePath(ep, 0x0406, 0x0000))
}
sendHubCommand(new HubAction(matter.cleanSubscribe(1, 0xFFFF, paths), Protocol.MATTER))
if (txtEnable) log.info "Subscribed to MS605 occupancy + lux + battery"
}
def parse(String description) {
if (logEnable) log.debug "parse: ${description}"
Map msg = matter.parseDescriptionAsMap(description)
if (!msg) return
Integer ep = safeHexToInt(msg.endpoint)
Integer clus = safeHexToInt(msg.cluster)
Integer attrId = safeHexToInt(msg.attrId)
String value = msg.value?.toString()
if (ep == null || clus == null || attrId == null) return
// Illuminance
if (ep == EP_LUX_BAT && clus == 0x0400 && attrId == 0x0000) {
Integer raw = extractMatterInt(value)
if (raw != null) {
Integer div = ((settings?.luxDivisor ?: 100) as Integer)
Integer lux = (div > 1) ? (raw / div) as Integer : raw
if (device.currentValue("illuminance")?.toString() != lux.toString()) {
String descText = "Illuminance is ${lux} lx"
sendEvent(name: "illuminance", value: lux, unit: "lx", descriptionText: txtEnable ? descText : null)
if (txtEnable) log.info descText
}
}
return
}
// Battery %
if (ep == EP_LUX_BAT && clus == 0x002F && attrId == 0x000C) {
Integer raw = extractMatterInt(value) // 04C8 -> C8 (200)
if (raw != null) {
Integer pct = (raw > 100) ? (raw / 2) : raw
pct = Math.max(0, Math.min(100, pct))
if (device.currentValue("battery")?.toString() != pct.toString()) {
String descText = "Battery is ${pct}%"
sendEvent(name: "battery", value: pct, unit: "%", descriptionText: txtEnable ? descText : null)
if (txtEnable) log.info "${descText} (raw:${toHexByte(raw)})"
} else if (logEnable) {
log.debug "Battery unchanged: ${pct}% (raw:${toHexByte(raw)})"
}
}
return
}
// Power Source status (deduped)
if (ep == EP_LUX_BAT && clus == 0x002F && attrId == 0x0000) {
Integer raw = extractMatterInt(value)
if (raw != null) {
if (state.lastPowerSource != raw) {
state.lastPowerSource = raw
if (txtEnable) log.info "Power source status is ${raw == 1 ? 'active' : 'unknown'} (raw:${toHexByte(raw)})"
} else if (logEnable) {
log.debug "Power source unchanged (raw:${toHexByte(raw)})"
}
}
return
}
// Occupancy
if (EP_OCC.contains(ep) && clus == 0x0406 && attrId == 0x0000) {
Integer occ = extractMatterInt(value) // 0401->01, 0400->00
if (occ != null) {
boolean occupied = ((occ & 0x01) != 0)
state["occ_${ep}"] = occupied
if (occupied) {
unschedule("clearMotionIfStillClear")
unschedule("clearPresenceIfStillClear")
setMotion(true)
setPresence(true)
Integer failsafe = (settings?.autoClearSeconds ?: 0) as Integer
if (failsafe > 0) runIn(failsafe, "forceClear")
} else {
scheduleClearsIfAllClear()
}
}
return
}
if (logEnable && !(clus == 0x001D && attrId == 0x0003)) {
log.debug "Unhandled Matter report: ${msg}"
}
}
/* ----------------- HOLD LOGIC ----------------- */
private void scheduleClearsIfAllClear() {
boolean anyOccupied = EP_OCC.any { ep -> state["occ_${ep}"] == true }
if (anyOccupied) return
Integer mHold = (settings?.motionHoldSeconds ?: 0) as Integer
Integer pHold = (settings?.presenceHoldSeconds ?: 5) as Integer // min 5
if (mHold <= 0) {
setMotion(false)
} else {
runIn(mHold, "clearMotionIfStillClear")
}
if (pHold <= 0) {
setPresence(false)
} else {
runIn(pHold, "clearPresenceIfStillClear")
}
}
void clearMotionIfStillClear() {
boolean anyOccupied = EP_OCC.any { ep -> state["occ_${ep}"] == true }
if (anyOccupied) return
setMotion(false)
}
void clearPresenceIfStillClear() {
boolean anyOccupied = EP_OCC.any { ep -> state["occ_${ep}"] == true }
if (anyOccupied) return
setPresence(false)
}
private void setMotion(boolean active) {
if (state.motionState == active) return
state.motionState = active
String motionVal = active ? "active" : "inactive"
String mTxt = "Motion is ${motionVal}"
sendEvent(name: "motion", value: motionVal, descriptionText: txtEnable ? mTxt : null)
if (txtEnable) log.info mTxt
}
private void setPresence(boolean present) {
if (state.presenceState == present) return
state.presenceState = present
String presVal = present ? "present" : "not present"
String pTxt = "Presence is ${presVal}"
sendEvent(name: "presence", value: presVal, descriptionText: txtEnable ? pTxt : null)
if (txtEnable) log.info pTxt
}
/* ----------------- MANUAL / FAILSAFE CLEAR ----------------- */
void forceClear() {
EP_OCC.each { ep -> state.remove("occ_${ep}") }
unschedule("clearMotionIfStillClear")
unschedule("clearPresenceIfStillClear")
setMotion(false)
setPresence(false)
}
/* ----------------- HELPERS ----------------- */
private Integer extractMatterInt(String v) {
if (!v) return null
String s = v.trim()
.replace("0x", "").replace("0X", "")
.replaceAll(/[^0-9A-Fa-f]/, "")
if (s.size() == 0) return null
if (s.size() == 4) {
String last2 = s[-2..-1]
try { return Integer.parseUnsignedInt(last2, 16) } catch (ignored) { return null }
}
if (s.size() > 4) {
String last4 = s[-4..-1]
try { return Integer.parseUnsignedInt(last4, 16) } catch (ignored) {}
String last2 = s[-2..-1]
try { return Integer.parseUnsignedInt(last2, 16) } catch (ignored) { return null }
}
try { return Integer.parseUnsignedInt(s, 16) } catch (ignored) { return null }
}
private Integer safeHexToInt(Object hex) {
if (hex == null) return null
String s = hex.toString().trim()
if (s.startsWith("0x") || s.startsWith("0X")) s = s.substring(2)
if (s == "") return null
try { return Integer.parseUnsignedInt(s, 16) } catch (Exception ignored) { return null }
}
private static String toHexByte(Integer v) {
if (v == null) return "??"
return String.format("%02X", (v & 0xFF))
}