Hubitat, Ring and MQTT Person Detection

Lightning-Fast Ring Person Detection using MQTT & Docker

I wanted to share a recent project that completely transformed how my Hubitat hub handles Ring camera events. I know folks have used similar approaches but this is rock solid for person detection and avoids using Alexa or similar to accomplish this.

For a long time, I relied on a clunky, cloud-dependent setup for Ring person detection (listening for phone notifications to trigger Hubitat events). It was prone to lag and unreliable. My goal was to build a system that was as local as possible, incredibly fast, and filtered out false alarms (like trees or cars) so that Hubitat only announced real people.

Here is a breakdown of what I built, how the pieces fit together, and the custom driver that makes it all work.

The Architecture: How It Works

To get away from phone notifications, I spun up a local Docker stack on a Minisforum mini-PC to act as a bridge between the Ring Cloud and Hubitat. The data flows like this:

Ring Cloud ➔ ring-mqtt ➔ Mosquitto Broker ➔ Hubitat

Here is what each piece does:

1. The ring-mqtt Container
This is a brilliant community Docker container. You authenticate it with your Ring account, and it actively listens to Ring's servers. When a camera detects an event, ring-mqtt instantly grabs that data and translates it into standard MQTT messages.

2. Eclipse Mosquitto (The Broker)
This is a second Docker container running alongside ring-mqtt. Think of Mosquitto as a local post office. ring-mqtt drops the messages off here, and Hubitat connects to this broker to pick them up in real-time.

3. Hubitat (The Receiver)
Hubitat subscribes to the Mosquitto broker using a custom Virtual Device driver. When the device receives a verified person event, it turns ON for 2 seconds and then turns OFF.

4. The App (The Automations)
Because the heavy lifting is handled by the driver, my custom Hubitat Apps don't have to parse messy JSON or rely on phone webhooks anymore. The apps simply subscribe to a standard switch.on event. Every smart home system inherently understands a switch!

The "Secret Sauce": Solving the 250ms Race Condition

If you just use a standard MQTT switch driver, person detection fails. Why? Because of how Ring's API handles computer vision.

Ring actually fires two separate packets a fraction of a second apart:

  1. Generic Motion Payload: ring/.../motion/state sends ON the millisecond a pixel changes (could be a tree branch).

  2. Attributes Payload: ring/.../motion/attributes sends a JSON file a split-second later containing {"personDetected":true} once the computer vision finishes analyzing the frame.

If Hubitat reacts the instant the first packet arrives, it throws the event away because the "person" flag hasn't arrived yet.

To fix this, I used a custom Smart Person Switch Driver.
When the driver sees the generic motion ON packet, it doesn't turn the switch on. Instead, it sets a 250-millisecond "trap." It waits for the attributes packet to arrive. If it sees personDetected: true within that 250ms window, it fires the switch. If it doesn't, it ignores the event, filtering out the stray cats and moving shadows.

The Custom Driver Code

If anyone else is running ring-mqtt and wants to set this up, here is the lightweight driver I am using. Just create a Virtual Device, assign this driver, and paste your camera's base motion topic into the preferences (e.g., ring/<location_id>/camera/<camera_id>/motion).

/**
 * Ring-MQTT Smart Person Switch Receiver
 */
metadata {
    definition (name: "Ring-MQTT Smart Person Switch", namespace: "local", author: "Community") {
        capability "Switch"
        capability "Initialize"
    }

    preferences {
        input name: "brokerIp", type: "string", title: "MQTT Broker IP Address", required: true
        input name: "brokerPort", type: "string", title: "MQTT Broker Port", defaultValue: "1883", required: true
        input name: "mqttTopic", type: "string", title: "MQTT Base Motion Topic", description: "e.g. ring/<location_id>/camera/<camera_id>/motion", required: true
        input name: "autoOff", type: "number", title: "Auto-Off Delay (seconds)", defaultValue: 2
    }
}

def installed() { initialize() }
def updated() { initialize() }

def initialize() {
    try {
        interfaces.mqtt.disconnect()
        pauseExecution(500)
        log.info "Connecting to MQTT Broker: tcp://${brokerIp}:${brokerPort}"
        interfaces.mqtt.connect("tcp://${brokerIp}:${brokerPort}", "hubitat_${device.deviceNetworkId}", null, null)
        pauseExecution(1000)
        
        // Subscribe to the wildcard /# to catch both /state and /attributes payloads
        def subscribeTopic = mqttTopic.endsWith("/") ? "${mqttTopic}#" : "${mqttTopic}/#"
        log.info "Subscribing to: ${subscribeTopic}"
        interfaces.mqtt.subscribe(subscribeTopic)
    } catch(e) {
        log.error "MQTT Initialization error: ${e.message}"
    }
}

def parse(String description) {
    def mqttMsg = interfaces.mqtt.parseMessage(description)
    def topic = mqttMsg.topic
    def payload = mqttMsg.payload
    
    // Catch the attributes packet and parse the JSON for the person flag
    if (topic.endsWith("motion/attributes") || topic.endsWith("motion/info")) {
        try {
            def json = new groovy.json.JsonSlurper().parseText(payload)
            if (json.personDetected != null) {
                state.personDetected = json.personDetected
            }
        } catch (e) {
            log.warn "Failed to parse attributes JSON"
        }
    } 
    // Catch the standard motion state packet
    else if (topic.endsWith("motion/state")) {
        state.motionActive = (payload.toUpperCase() == "ON")
        
        if (state.motionActive) {
            // Give the attributes packet 250ms to arrive to prevent race conditions
            runInMillis(250, evaluatePersonEvent)
        }
    }
}

def evaluatePersonEvent() {
    if (state.motionActive && state.personDetected) {
        log.info "Person confirmed! Firing Virtual Switch."
        sendEvent(name: "switch", value: "on")
        if (autoOff > 0) {
            runIn(autoOff, autoTurnOff)
        }
    }
}

def autoTurnOff() {
    sendEvent(name: "switch", value: "off")
}

def on() { sendEvent(name: "switch", value: "on") }
def off() { sendEvent(name: "switch", value: "off") }

def mqttClientStatus(String message) {
    log.info "MQTT Client Status: ${message}"
    if (message.contains("disconnected") || message.contains("error")) {
        log.warn "MQTT disconnected, attempting to reconnect in 30 seconds"
        runIn(30, initialize)
    }
}

1 Like