My sensor arrived today from Aliexpress 
First thing I did was to take to AI to write a code (ChatGPT first then Claude to get it working, then back to ChatGPT!), after several hours, I have managed to write a driver for Hubitat - working so far, but will need further testing.
My first impression of the sensor… it will not be replacing any of my other presence sensors, will do further tests to see how useful it will be!
// hubitat start
// type: device
// hubitat end
/*
* Seeed Studio MR60BHA2 MQTT Driver for Hubitat
*
* Independent Hubitat driver that subscribes directly to MQTT topics from a
* Seeed Studio MR60BHA2 60GHz mmWave ESPHome device. It does not require
* Home Assistant.
*
* Version: 1.0.7
*
* Changelog:
* 1.0.7 - Add defensive clear handling: zero target/health/distance payloads
* can clear Hubitat motion/presence when the presence topic is missed.
* 1.0.6 - Make presence/state authoritative for Hubitat motion/presence.
* Target number is now informational by default and no longer keeps
* motion/presence alive after the presence topic has cleared.
* 1.0.5 - Fix null numeric preference handling after driver upgrade.
* 1.0.4 - Reduce Hubitat event queue pressure:
* - Do not write lastTopic/lastMessage events unless diagnostics are enabled
* - Suppress duplicate topic+payload bursts arriving within a short window
* - Use sendEventIfChanged() for all normal attributes
* - Suppress unchanged rounded numeric events
* - Quiet per-message parse debug logs by default
* 1.0.3 - Fix duplicate message processing:
* - subscribeTopics(): when wildcard is enabled, skip individual topic
* subscriptions; broker was delivering every message twice (once per
* subscription), causing double sendEvent() and erratic presence timing
* - Added sendEventIfChanged() helper to suppress redundant events for
* lastTopic/lastMessage which were firing on every retained-message flood
* 1.0.2 - Fix parse() never firing / states not updating:
* - subscribeTopics() now runs via runIn(2) after connect to avoid a Hubitat
* platform race where immediate wildcard subscriptions don't deliver to parse()
* - mqttClientStatus() guards against spurious disconnect events after a
* successful connect that were triggering a reconnect loop
* - Added state.mqttConnected flag to prevent double-reconnect scheduling
* - asBoolean(): added 'none','no one','nobody','undetected' for ESPHome
* mmWave cleared-presence payloads
* - handleTopic(): light/state now parses ESPHome JSON payloads
* - parse(): added top-level debug log so message receipt is always visible
*/
import groovy.transform.Field
@Field static final String DEFAULT_TOPIC_PREFIX = 'mr60bha2_kit'
@Field static final Map<String, String> DEFAULT_TOPIC_SUFFIXES = [
availability: 'status',
presence: 'presence/state',
heartRate: 'heart_rate/state',
breathRate: 'respiratory_rate/state',
distance: 'distance/state',
targetNumber: 'target_number/state',
illuminance: 'illuminance/state',
lightState: 'light/state',
lightCommand: 'light/command'
]
metadata {
definition(name: 'Seeed Studio MR60BHA2 MQTT', namespace: 'iEnam', author: 'iEnam') {
capability 'Sensor'
capability 'MotionSensor'
capability 'PresenceSensor'
capability 'IlluminanceMeasurement'
capability 'Switch'
capability 'Initialize'
capability 'Refresh'
attribute 'heartRate', 'number'
attribute 'respiratoryRate', 'number'
attribute 'detectionDistance', 'number'
attribute 'targetNumber', 'number'
attribute 'personInformation', 'string'
attribute 'mqttStatus', 'string'
attribute 'sensorStatus', 'enum', ['online', 'offline']
attribute 'lastMessage', 'string'
attribute 'lastTopic', 'string'
attribute 'lastError', 'string'
command 'connect'
command 'disconnect'
command 'reconnect'
command 'publishTestCommand'
command 'publishPresenceTest'
}
preferences {
input name: 'mqttBrokerHost', type: 'text', title: 'MQTT broker host', required: true, defaultValue: '192.168.1.239'
input name: 'mqttBrokerPort', type: 'number', title: 'MQTT broker port', required: true, defaultValue: 1883
input name: 'mqttUsername', type: 'text', title: 'MQTT username', required: false, defaultValue: 'hubitat'
input name: 'mqttPassword', type: 'password', title: 'MQTT password', required: false
input name: 'topicPrefix', type: 'text', title: 'ESPHome MQTT topic prefix', required: true, defaultValue: DEFAULT_TOPIC_PREFIX
input name: 'availabilityTopic', type: 'text', title: 'Topic suffix: availability', required: false, defaultValue: DEFAULT_TOPIC_SUFFIXES.availability
input name: 'presenceTopic', type: 'text', title: 'Topic suffix: person/presence', required: true, defaultValue: DEFAULT_TOPIC_SUFFIXES.presence
input name: 'heartRateTopic', type: 'text', title: 'Topic suffix: heart rate', required: true, defaultValue: DEFAULT_TOPIC_SUFFIXES.heartRate
input name: 'breathRateTopic', type: 'text', title: 'Topic suffix: respiratory rate', required: true, defaultValue: DEFAULT_TOPIC_SUFFIXES.breathRate
input name: 'distanceTopic', type: 'text', title: 'Topic suffix: distance', required: true, defaultValue: DEFAULT_TOPIC_SUFFIXES.distance
input name: 'targetNumberTopic', type: 'text', title: 'Topic suffix: target number', required: true, defaultValue: DEFAULT_TOPIC_SUFFIXES.targetNumber
input name: 'illuminanceTopic', type: 'text', title: 'Topic suffix: illuminance', required: false, defaultValue: DEFAULT_TOPIC_SUFFIXES.illuminance
input name: 'lightStateTopic', type: 'text', title: 'Topic suffix: RGB light state', required: false, defaultValue: DEFAULT_TOPIC_SUFFIXES.lightState
input name: 'lightCommandTopic', type: 'text', title: 'Topic suffix: RGB light command', required: false, defaultValue: DEFAULT_TOPIC_SUFFIXES.lightCommand
input name: 'presenceHoldSeconds', type: 'number', title: 'Hold motion active after last detected target (seconds)', required: true, defaultValue: 10
input name: 'targetNumberControlsPresence', type: 'bool', title: 'Use target number as fallback presence source', required: true, defaultValue: false
input name: 'zeroValuesClearPresence', type: 'bool', title: 'Clear presence when target, distance, heart rate, or respiratory rate reaches 0', required: true, defaultValue: true
input name: 'reconnectSeconds', type: 'number', title: 'Reconnect delay after MQTT disconnect (seconds)', required: true, defaultValue: 30
input name: 'subscribeToPrefixWildcard', type: 'bool', title: 'Also subscribe to all topics under prefix', required: true, defaultValue: true
input name: 'duplicateWindowMs', type: 'number', title: 'Ignore duplicate topic payloads within milliseconds', required: true, defaultValue: 1500
input name: 'diagnosticEvents', type: 'bool', title: 'Store last MQTT topic/message as device events', required: true, defaultValue: false
input name: 'traceMqttMessages', type: 'bool', title: 'Trace every incoming MQTT message in logs', required: true, defaultValue: false
input name: 'logEnable', type: 'bool', title: 'Enable info logging', required: true, defaultValue: true
input name: 'debugEnable', type: 'bool', title: 'Enable debug logging', required: true, defaultValue: false
}
}
void installed() {
logInfo 'Installed'
initialize()
}
void updated() {
logInfo 'Settings saved'
initialize()
}
void initialize() {
unschedule()
state.lastPayloadByTopic = [:]
state.lastPayloadAtByTopic = [:]
sendEventIfChanged([name: 'mqttStatus', value: 'initializing', displayed: false])
sendEventIfChanged([name: 'sensorStatus', value: 'offline', displayed: false])
sendEventIfChanged([name: 'presence', value: 'not present', displayed: false])
sendEventIfChanged([name: 'motion', value: 'inactive', displayed: false])
connect()
}
void refresh() {
reconnect()
}
void connect() {
String host = (settings.mqttBrokerHost ?: '').toString().trim()
if (!host) {
recordError('MQTT broker host is not configured')
return
}
Integer port = safeInteger(settings.mqttBrokerPort, 1883)
String brokerUri = "tcp://${host}:${port}"
String clientId = "hubitat-seeeds-mr60bha2-${device.id}"
String username = (settings.mqttUsername ?: '').toString()
String password = (settings.mqttPassword ?: '').toString()
try {
interfaces.mqtt.disconnect()
} catch (Exception ignored) {
}
try {
logInfo "Connecting to MQTT broker ${brokerUri}"
sendEventIfChanged([name: 'mqttStatus', value: 'connecting', displayed: false])
if (username) {
interfaces.mqtt.connect(brokerUri, clientId, username, password)
} else {
interfaces.mqtt.connect(brokerUri, clientId)
}
} catch (Exception e) {
recordError("MQTT connect failed: ${e.message}")
scheduleReconnect()
}
}
void disconnect() {
unschedule('connect')
unschedule('reconnect')
try {
interfaces.mqtt.disconnect()
} catch (Exception e) {
logDebug "MQTT disconnect ignored: ${e.message}"
}
sendEventIfChanged([name: 'mqttStatus', value: 'disconnected', displayed: false])
}
void reconnect() {
disconnect()
runIn(1, 'connect')
}
void mqttClientStatus(String status) {
String statusText = status ?: 'unknown'
logInfo "MQTT status: ${statusText}"
sendEventIfChanged([name: 'mqttStatus', value: statusText.take(255), displayed: false])
String lowerStatus = statusText.toLowerCase()
if (lowerStatus.contains('connected') || lowerStatus.contains('succeeded')) {
unschedule('connect')
unschedule('reconnect')
clearError()
state.mqttConnected = true
sendEventIfChanged([name: 'sensorStatus', value: 'online', displayed: false])
// Delay subscription by 2 s; Hubitat has a platform race where wildcard
// subscriptions registered immediately after connect do not fire parse()
runIn(2, 'subscribeTopics')
return
}
// Ignore transient status callbacks that arrive after a successful connect
// (Hubitat sometimes delivers a benign disconnect notice right after connect)
if (state.mqttConnected) {
logDebug "Ignoring transient MQTT status after successful connect: ${statusText}"
state.mqttConnected = false
sendEventIfChanged([name: 'sensorStatus', value: 'offline', displayed: false])
scheduleReconnect()
return
}
sendEventIfChanged([name: 'sensorStatus', value: 'offline', displayed: false])
scheduleReconnect()
}
void parse(String description) {
if (settings.traceMqttMessages == true) {
logDebug "parse() called: ${description?.take(120)}"
}
Map message
try {
message = interfaces.mqtt.parseMessage(description)
} catch (Exception e) {
recordError("MQTT message parse failed: ${e.message}")
return
}
String topic = message?.topic?.toString()
String payload = message?.payload?.toString()
if (!topic) {
logDebug "Ignoring MQTT message without topic: ${description}"
return
}
handleTopic(topic, payload ?: '')
}
void on() {
publishLightCommand(true)
}
void off() {
publishLightCommand(false)
}
void publishTestCommand() {
publishLightCommand(true)
runIn(2, 'off')
}
void publishPresenceTest() {
String topic = topicMap().presence
try {
logDebug "Publishing ON to ${topic}"
interfaces.mqtt.publish(topic, 'on')
} catch (Exception e) {
recordError("MQTT presence test publish failed: ${e.message}")
}
}
void subscribeTopics() {
if (settings.subscribeToPrefixWildcard != false) {
// Wildcard covers all topics; do NOT also subscribe individually or every
// message will be delivered twice (once per subscription), causing double
// sendEvent() calls and erratic presence/motion timing
String wildcard = "${normalisedPrefix()}/#"
logDebug "Subscribing to wildcard ${wildcard} (individual topic subscriptions skipped)"
interfaces.mqtt.subscribe(wildcard)
return
}
// Wildcard disabled; subscribe to each topic individually
topicMap().each { String key, String topic ->
if (topic && key != 'lightCommand') {
logDebug "Subscribing to ${topic}"
interfaces.mqtt.subscribe(topic)
}
}
}
private void handleTopic(String topic, String payload) {
Map<String, String> topics = topicMap()
if (isDuplicatePayload(topic, payload)) return
if (settings.traceMqttMessages == true) {
logDebug "MQTT ${topic} = ${payload}"
}
if (settings.diagnosticEvents == true) {
sendEventIfChanged([name: 'lastTopic', value: topic.take(255), displayed: false])
sendEventIfChanged([name: 'lastMessage', value: payload.take(255), displayed: false])
}
if (topic == topics.availability) {
String value = payload.trim().toLowerCase()
sendEventIfChanged([name: 'sensorStatus', value: value == 'online' ? 'online' : 'offline'])
return
}
if (topic == topics.presence) {
Boolean detected = asBoolean(payload)
if (detected != null) {
sendEventIfChanged([name: 'personInformation', value: detected ? 'Detected' : 'Clear'])
updatePresence(detected, false)
markOnline()
}
return
}
if (topic == topics.heartRate) {
BigDecimal value = sendNumericEvent('heartRate', payload, 0, 'bpm')
clearPresenceIfZero(value)
markOnline()
return
}
if (topic == topics.breathRate) {
BigDecimal value = sendNumericEvent('respiratoryRate', payload, 0, 'rpm')
clearPresenceIfZero(value)
markOnline()
return
}
if (topic == topics.distance) {
BigDecimal value = sendNumericEvent('detectionDistance', payload, 2, 'cm')
clearPresenceIfZero(value)
markOnline()
return
}
if (topic == topics.targetNumber) {
BigDecimal value = asDecimal(payload)
if (value != null) {
Integer targets = Math.max(0, value.intValue())
sendEventIfChanged([name: 'targetNumber', value: targets])
if (settings.targetNumberControlsPresence == true) {
updatePresence(targets > 0, true)
} else if (targets == 0 && settings.zeroValuesClearPresence != false) {
updatePresence(false, false)
}
markOnline()
}
return
}
if (topic == topics.illuminance) {
sendNumericEvent('illuminance', payload, 1, 'lx')
markOnline()
return
}
if (topic == topics.lightState) {
Boolean isOn
// ESPHome RGB lights publish JSON: {"state":"ON","brightness":255,...}
// Handle both JSON and plain ON/OFF strings
if (payload.trim().startsWith('{')) {
try {
Map json = new groovy.json.JsonSlurper().parseText(payload)
isOn = asBoolean(json?.state)
logDebug "Light state JSON parsed: state=${json?.state} -> isOn=${isOn}"
} catch (Exception e) {
logDebug "Light state JSON parse failed, falling back to raw: ${e.message}"
isOn = asBoolean(payload)
}
} else {
isOn = asBoolean(payload)
}
if (isOn != null) {
sendEventIfChanged([name: 'switch', value: isOn ? 'on' : 'off'])
markOnline()
} else {
logDebug "Light state payload not recognised: ${payload}"
}
return
}
logDebug "Unhandled MQTT topic ${topic}"
}
private void publishLightCommand(Boolean turnOn) {
String topic = topicMap().lightCommand
if (!topic) {
recordError('RGB light command topic is not configured')
return
}
String payload = turnOn ? 'ON' : 'OFF'
try {
logDebug "Publishing ${payload} to ${topic}"
interfaces.mqtt.publish(topic, payload)
sendEventIfChanged([name: 'switch', value: turnOn ? 'on' : 'off'])
} catch (Exception e) {
recordError("MQTT publish failed: ${e.message}")
}
}
private void updatePresence(Boolean detected, Boolean useTimeout = true) {
if (detected) {
unschedule('markNotPresent')
state.lastDetectedAt = now()
sendEventIfChanged([name: 'presence', value: 'present'])
sendEventIfChanged([name: 'motion', value: 'active'])
Integer hold = useTimeout ? safeInteger(settings.presenceHoldSeconds, 10) : 0
if (hold > 0) runIn(hold, 'markNotPresent')
return
}
Integer hold = useTimeout ? safeInteger(settings.presenceHoldSeconds, 10) : 0
Long lastSeen = state.lastDetectedAt as Long
if (hold > 0 && lastSeen) {
Long elapsedMs = now() - lastSeen
if (elapsedMs < hold * 1000L) {
Integer remaining = Math.max(1, hold - Math.floor(elapsedMs / 1000D).intValue())
runIn(remaining, 'markNotPresent')
return
}
}
markNotPresent()
}
void markNotPresent() {
sendEventIfChanged([name: 'presence', value: 'not present'])
sendEventIfChanged([name: 'motion', value: 'inactive'])
}
private BigDecimal sendNumericEvent(String attributeName, String payload, Integer scale, String unit) {
BigDecimal value = asDecimal(payload)
if (value == null) return null
BigDecimal rounded = value.setScale(scale, BigDecimal.ROUND_HALF_UP)
sendEventIfChanged([name: attributeName, value: rounded, unit: unit])
return rounded
}
private void clearPresenceIfZero(BigDecimal value) {
if (settings.zeroValuesClearPresence == false || value == null) return
if (value.compareTo(BigDecimal.ZERO) == 0) {
updatePresence(false, false)
}
}
private void markOnline() {
sendEventIfChanged([name: 'sensorStatus', value: 'online', displayed: false])
clearError()
}
// Only fire sendEvent when the value has actually changed to avoid event spam
private void sendEventIfChanged(Map args) {
if (device.currentValue(args.name)?.toString() != args.value?.toString()) {
sendEvent(args)
}
}
private Boolean isDuplicatePayload(String topic, String payload) {
Long nowMs = now()
Integer windowMs = Math.max(0, safeInteger(settings.duplicateWindowMs, 1500))
Map lastPayloads = (state.lastPayloadByTopic ?: [:]) as Map
Map lastTimes = (state.lastPayloadAtByTopic ?: [:]) as Map
String lastPayload = lastPayloads[topic]?.toString()
Long lastAt = lastTimes[topic] as Long
lastPayloads[topic] = payload
lastTimes[topic] = nowMs
state.lastPayloadByTopic = lastPayloads
state.lastPayloadAtByTopic = lastTimes
return windowMs > 0 && lastPayload == payload && lastAt != null && (nowMs - lastAt) < windowMs
}
private void scheduleReconnect() {
Integer seconds = Math.max(5, safeInteger(settings.reconnectSeconds, 30))
runIn(seconds, 'connect')
}
private Map<String, String> topicMap() {
return [
availability: fullTopic(settings.availabilityTopic ?: DEFAULT_TOPIC_SUFFIXES.availability),
presence: fullTopic(settings.presenceTopic ?: DEFAULT_TOPIC_SUFFIXES.presence),
heartRate: fullTopic(settings.heartRateTopic ?: DEFAULT_TOPIC_SUFFIXES.heartRate),
breathRate: fullTopic(settings.breathRateTopic ?: DEFAULT_TOPIC_SUFFIXES.breathRate),
distance: fullTopic(settings.distanceTopic ?: DEFAULT_TOPIC_SUFFIXES.distance),
targetNumber: fullTopic(settings.targetNumberTopic ?: DEFAULT_TOPIC_SUFFIXES.targetNumber),
illuminance: fullTopic(settings.illuminanceTopic ?: DEFAULT_TOPIC_SUFFIXES.illuminance),
lightState: fullTopic(settings.lightStateTopic ?: DEFAULT_TOPIC_SUFFIXES.lightState),
lightCommand: fullTopic(settings.lightCommandTopic ?: DEFAULT_TOPIC_SUFFIXES.lightCommand)
]
}
private String fullTopic(Object suffixValue) {
String suffix = (suffixValue ?: '').toString().trim()
if (!suffix) return ''
suffix = suffix.replaceFirst(/^\/+/, '')
String prefix = normalisedPrefix()
if (!prefix || suffix.startsWith("${prefix}/")) return suffix
return "${prefix}/${suffix}"
}
private String normalisedPrefix() {
String prefix = (settings.topicPrefix ?: DEFAULT_TOPIC_PREFIX).toString().trim()
return prefix.replaceFirst(/^\/+/, '').replaceFirst(/\/+$/, '')
}
private BigDecimal asDecimal(Object value) {
if (value == null) return null
try {
if (value instanceof Number) return new BigDecimal(value.toString())
String cleaned = value.toString().replaceAll(/[^0-9.\-]/, '')
if (!cleaned) return null
return new BigDecimal(cleaned)
} catch (Exception ignored) {
return null
}
}
private Boolean asBoolean(Object value) {
if (value == null) return null
if (value instanceof Boolean) return value
if (value instanceof Number) return ((Number) value).intValue() != 0
String text = value.toString().trim().toLowerCase()
if (['true', 'on', 'yes', '1', 'detected', 'present', 'active', 'online'].contains(text)) return true
// Added: 'none','no one','nobody','undetected' are ESPHome mmWave cleared-presence values
if (['false', 'off', 'no', '0', 'clear', 'not detected', 'not present', 'inactive', 'offline',
'none', 'no one', 'nobody', 'undetected'].contains(text)) return false
return null
}
private Integer safeInteger(Object value, Integer fallback) {
if (value == null) return fallback
try {
Integer parsed = value as Integer
return parsed == null ? fallback : parsed
} catch (Exception ignored) {
return fallback
}
}
private void recordError(String message) {
log.warn message
sendEventIfChanged([name: 'lastError', value: message.take(255), displayed: false])
}
private void clearError() {
if (device.currentValue('lastError')) {
sendEventIfChanged([name: 'lastError', value: '', displayed: false])
}
}
private void logInfo(String message) {
if (settings.logEnable != false) log.info message
}
private void logDebug(String message) {
if (settings.debugEnable == true) log.debug message
}