Seeed Studio Heartbeat and Breathing Detector (works but watch out for discounted models)

Just added this via Home Assistant. Plug and Play - two minutes. Say goodbye to other radar human presence detectors. This one can detect your heartbeat and breathing. The lights will not turn off until you are dead. I would write something for Hubitat if I had a clue.

$40 on AliExpress

See comment below about discounted devices!

It'd be good if they turn off when I leave the room :laughing:

There is a switch on the wall for that.

That sounds like something my wife would say. :wink:

@Hal3,
I’m tempted to get one…

What’s your first impression so far?

Is the presence/absence detection accurate and fast?

Same thing seems to be on sale from seeed directly for about $29 plus tax and shipping.

My issue is the range-- it appears to have a range of around 1.5m? That's not too high. That said, it would be something fun to play with...

I am completely amazed. You connect it to your wifi network with your phone. Give it your SSID and password. Home Assistant found it with devices and services and added it immediately. It was ready to add to a dashboard and all the values were displayed in real time. It was tracking my heartbeat and breathing rate as I sat at my desk from about three feet away. Way beyond anything I was expecting.

The second picture in the post above is my Home Assistant dashboard showing my heart rate and breathing in real time.

$24.99 on Amazon but the reviews aren't great.

I didn't have to update or flash anything. I suspect someone is selling off an older version of the product that needs updating for a lower price. However, I am just making this up.

My $40 specimen worked perfectly.

I was going to buy another one for $24.99 until I read the reviews.

Thanks for the alert!

Thanks for the info.

I have several mmWave sensors (Aqara FP2, FP300, Linptech etc), so my main priority before I purchase is if it is a good presence sensor, i.e. does it detect presence straight away when someone enters a room, and does it show absence straight away when the room is vacant?

I will update the title to be more specific.
Do any of those other devices detect and count heartbeats from three feet away? That is what is amazing to me.

Maybe it's not actually microwave but something else...Do be careful - 'don't make me angry; you wouldn't like it when I'm angry' :laughing:

hulk smash GIF

If clock pendulums mess it up its of no use to me

There's a song about that ....

(Unless you are old and saw the Beatles on Ed Sullivan in 1964 and heard this song on the radio in 1965 and are developing mental loose association with age you are likely not amused.)

Excellent, and the flag is in the correct orentation

My sensor arrived today from Aliexpress :grinning_face:

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
}

Note: the above driver is still dependent on Home Assistant which is acting as a bridge … will need to reflash with ESPHome YAML to break free from HA … I might do it as I have been slowly moving everything from HA :sweat_smile:

Done it, free from HA! :grinning_face:

Will update details, including updated code later…

Stupid question: Is MQTT local in this case?

Yes, it's fully local. The MQTT broker is Hubitat's built-in MQTT integration running on Local LAN.

ESPHome publishes sensor data to Hubitat (I had to programme ESPHome to do that from HA).

The Hubitat driver subscribes directly to that same local broker.