mmWave occupancy with ESP32 & LD1125H

Summary

Use of an ESP32 running ESPHome to interface with a LD1125H mmWave occupancy sensor, ensuring I can manage the controls/sensitivity via ESPHome and Home Assistant + MQTT. Also monitor room temperature, humidity and pressure via the same device.

Equipment Used

LD1125H Sensor description

The supplier Hi-link describes the sensor as

…a high sensitivity 24GHz millimeter wave human presence detection radar module. Different from the traditional radar, which judges the existence of the human body by detecting the large-scale movements of the human body or the small-scale body movements, the main feature of this module is that on the basis of the functions of the traditional human-body induction radar, it also has the ability to detect and accumulate small-scale movements such as human respiration, to determine the function of the existence of the human body. Therefore, compared with traditional Doppler radar, it has presence detection within a certain range, with higher accuracy, and is not easy to miss reports.

The unit is touted to allow a 4m range to detect ‘Static Human Presence’ (i.e. heart heat and small movements) and 8m range which will detect other general motion. The unit can output two detection notifications, one for small movements and above, and another for just larger movements.

The module outputs standard serial data, so ESPHome can use a custom text and number serial sensor to take serial text and convert it in to sensor numbers and binarys.

The Hi-Link manual is downloadable below that shows more detail including the suggested installation coverage, heights etc

Project

There are now a bunch of similar (or better) mmWave sensors on the market, but these were the first ones I bought, in 2023. My plan is to use a few of them around the house to automatically sense people, for turning on lights, fans, bathroom heating etc, and this was the first time I got to play with them.

Testing the setup with a dev board and some plug in connectors before soldering things in place.

The first project was to use one in my office/workshop, to turn on and off lights, and turn off various equipment when I wasn’t around. Much of the equipment in my office such as monitors, workbench power supplies, soldering iron, ELV test equipment is connected to controlled/switched power. When I’m not there, it is nice to have it automatically switch off. Obviously, overrides are needed if you want to run test equipment longer term (eg logging)

This is the connection diagram I used for mine, thanks to digiblur for the youtube video and details, linked further at the bottom of this page (and apparently to someone called Caleb for creating this schematic drawing, but I have no links to his details for attribution sorry).
Buck Converter

The photos show how I put the equipment together. I bought a cheap ‘temp sensor’ style wall case, and mounted inside an ESP32 Dev module, with a LD1125H on the front cover (facing straight out) and a BME280 on the inside for temp/humidity/pressure. I attempted to keep this away from the rest of the electronics and near the edge of the open sided case.

Power was with a small, 3W 250VAC-5VDC Buck Converter in a case (see pics). This was mounted in the wall behind and powered from the nearby light switch feed.

Above shows the components in the case ready to go after soldering everything together. The LD1125H was the only item in this lot that already had header pins on it, so it was easier to just plug this in than solder to it. It does however use smaller 2mm pitch headers though, so if you don’t have 2mm pitch plugs/cables then you’ll need to find some. The pic to the right shows me holding a connector that fits (and I swapped some pins around to match the colours correctly).

Mounted on the wall without the cover fitted. I kept the temp sensor away from the other devices to avoid heat, and put some tape over the mmwave sensor to stop it from moving away from facing straight out towards my chair/desk. The power cables go to the buck converter safely away in the wall.
Cover closed up. Remember that mmWave happily goes though anything non-metallic, so if I really wanted to, I could have just mounted it behind the gib board (which I may do in other locations). The temp sensor still needed to be exposed in this case however. The unit is powered from the lighting circuit to the right (a zemismart triple switch with an esp8266)

ESPHome Web Interface Display/Control

Tuning the sensor

mth1: This is the sensitivity setting for the sensing from 0 to 2.8m range. Lower is less sensitive (and 0 decimal places). This value corresponds to the signal threshold within 2.8 meters. (the default is 60).
mth2: This is as above, but from 2.8 to 8m range. The default is 30.
mth3: This is the sensitivity for anything above 8m. The default is 20.
rmax: This is the maximum distance, that you want to do any form of detection (only about 2.8m for my room). The distance value with one decimal place can be set, and the unit is meters. For example: Set the module to only detect targets within a distance of 6 meters. Targets 6 meters away will not trigger. The module defaults to rmax=6

When the unit detects a static human, or a very small amplitude movement (and also any larger movement), espHome will output “Occupancy” then after the clearance time “Clearance” (on the Occupancy or Movement Status binary sensor)
Clearance Time: This is the internal Mov/Occ to Clearance waiting time, i.e. the time to change from when someone is detected (and there is no more subsequent movement detected) to when it states ‘Clearance’

When it detects larger motion, it will output “ON” (on the Movement binary sensor) or “OFF” otherwise.
Movement Time: This is the time from when it thinks movement is no longer detected to when it notifies ‘OFF’

ESPHome Configuration

Below is the config I used. I try to heavily comment my YAML so I can easily reuse it in ESPHome and don’t forget what it all does when I come back to fix something!


#############################################
#############################################
# HiLink LD1125H mmWave sensor, with BME280 Temp/Hum/Pres Sensor on an ESP32
# https://github.com/patrick3399/Hi-Link_mmWave_Radar_ESPHome/tree/main
# https://github.com/patrick3399/Hi-Link_mmWave_Radar_ESPHome/blob/main/LD1125H/ESP32-LD1125H-Complete.yaml
#
# mth1: 0 to 2.8m sensitive
# mth2: 2.8 to 8m sensitive
# mth3: above 8m sensitive
# rmax: max distance
# Clearance Time: Mov/Occ to Clearance waiting time
# Movement Time: Mov to Occ waiting time
#
#############################################
#############################################

#############################################
# Variable Substitutions
#############################################
substitutions:
  devicename: "esp-occupancyoffice"
  friendly_name: "Office State"
  description_comment: "D1 Mini ESP32 with mmWave and environment sensors for downstairs office"
  api_key: !secret esp-occupancyoffice_api_key #unfortunately you can't use substitutions in secrets names
  ota_pass: !secret esp-occupancyoffice_ota_pass #unfortunately you can't use substitutions in secrets names
  mqtt_topic: "esphome" #main topic for the mqtt server, call it what you like
  update_time: 30s #update time for for temp sensors etc

#############################################
# ESPHome
# https://esphome.io/components/esphome.html
#############################################
esphome:
  name: ${devicename}
  comment: ${description_comment} #appears on the esphome page in HA
  on_boot:    #LD1125H Initial Setting, will remember previous values (if set)
    priority: -200
    then:
      - uart.write:
          id: LD1125H_UART_BUS
          data: !lambda |-
            std::string th1st = "mth1=" + str_sprintf("%.0f",id(LD1125H_mth1).state) +"\r\n";
            return std::vector<uint8_t>(th1st.begin(), th1st.end());
      - uart.write:
          id: LD1125H_UART_BUS
          data: !lambda |-
            std::string th2st = "mth2=" + str_sprintf("%.0f",id(LD1125H_mth2).state) +"\r\n";
            return std::vector<uint8_t>(th2st.begin(), th2st.end());
      - uart.write:
          id: LD1125H_UART_BUS
          data: !lambda |-
            std::string th3st = "mth3=" + str_sprintf("%.0f",id(LD1125H_mth3).state) +"\r\n";
            return std::vector<uint8_t>(th3st.begin(), th3st.end());
      - uart.write:
          id: LD1125H_UART_BUS
          data: !lambda |-
            std::string rmaxst = "rmax=" + str_sprintf("%.1f",id(LD1125H_rmax).state) +"\r\n";
            return std::vector<uint8_t>(rmaxst.begin(), rmaxst.end());

#############################################
# ESP Platform and Framework
# https://esphome.io/components/esp32.html
#############################################
esp32:
  board: nodemcu-32s
  framework:
    type: arduino
    #type: esp-idf  #Suggested Use ESP-IDF Framework, or Plug Out the UART Cable Might Cause ESP32 Hang. 
    version: recommended #recommended, latest or dev

#############################################
# i2s bus
# https://esphome.io/components/i2c.html
#############################################
i2c:
  sda: GPIO19
  scl: GPIO21
  scan: True  
  frequency: 100kHz #10, 50, 100, 200, 800 are possible settings, 100kHz was reliable for me

#############################################    
# ESPHome external or custom components to use
# https://esphome.io/components/external_components.html
############################################# 
external_components:
  - source:
      type: git
      url: https://github.com/ssieb/custom_components #Thanks for @ssieb components.
    components: [ serial ]  #text_sensor that reads lines for a uart. Also, a sensor that reads single values from the uart.

#############################################    
# ESPHome Logging Enable
# https://esphome.io/components/logger.html
############################################# 
logger:
  level: INFO  #INFO Level suggested, or DEBUG for testing
  baud_rate: 0 #set to 0 for no logging via UART, needed if you are using it for other serial things (eg PZEM)
  #esp8266_store_log_strings_in_flash: false
  #tx_buffer_size: 64

#############################################    
# Enable the Home Assistant API
# https://esphome.io/components/api.html
#############################################  
api:
  encryption:
    key: ${api_key}

#############################################    
# Enable Over the Air Update Capability
# https://esphome.io/components/ota.html?highlight=ota
#############################################  
ota:
  safe_mode: true  #Safe mode will detect boot loops
  password: ${ota_pass}

#############################################    
# Wifi Settings 
# https://esphome.io/components/wifi.html
#############################################  
wifi:
  ssid: !secret wifi_ssid
  password: !secret wifi_password
  #power_save_mode: LIGHT #https://esphome.io/components/wifi.html#wifi-power-save-mode
  #manual_ip: #optional static IP address
    #static_ip: 192.168.x.x
    #gateway: 192.168.X.x
    #subnet: 255.255.255.0
  ap:   #Details for fallback hotspot (captive portal) in case wifi connection fails https://esphome.io/components/wifi.html#access-point-mode
    ssid: $devicename fallback AP
    password: !secret fallback_ap_password
    ap_timeout: 5min #default is 1min

#############################################    
# Web Portal for display and monitoring
# Turning this off is probably a good idea to save resources.
# https://esphome.io/components/web_server.html
#############################################   
web_server:
  port: 80
#   username: !secret web_server_username #probably a good idea to secure it
#    password: !secret web_server_password

#############################################
# MQTT Monitoring
# https://esphome.io/components/mqtt.html?highlight=mqtt
# MUST also have api enabled if you enable MQTT
#############################################
mqtt:
  broker: !secret mqtt_server
  topic_prefix: ${mqtt_topic}/${devicename}
  username: !secret mqtt_username
  password: !secret mqtt_password

#############################################
# UART Serial
# hardware on EPS32, but software, and can be glitchy on ESP8266
# https://esphome.io/components/uart.html?highlight=uart
#############################################
uart:
  id: LD1125H_UART_BUS
  rx_pin: GPIO16  #For ESP32, you can use any pin, Recommend Use UART_2, Don't use UART_0, It might Cause Boot Fail or System Hang
  tx_pin: GPIO17  #For ESP32, you can use any pin, Recommend Use UART_2, Don't use UART_0, It might Cause Boot Fail or System Hang
#  rx_pin: GPIO1  #For ESP32, you can use any pin, Recommend Use UART_2, Don't use UART_0, It might Cause Boot Fail or System Hang
#  tx_pin: GPIO0 #For ESP32, you can use any pin, Recommend Use UART_2, Don't use UART_0, It might Cause Boot Fail or System Hang
  baud_rate: 115200
  data_bits: 8
  stop_bits: 1
  parity: NONE
#  debug:
#    direction: BOTH
#    dummy_receiver: false
#    after:
#      delimiter: "\n"
#    sequence:
#      - lambda: UARTDebug::log_string(direction, bytes);

#############################################
# Global Variables for use in automations etc
# https://esphome.io/guides/automations.html?highlight=globals#global-variables
#############################################
globals:
  - id: LD1125H_Last_Time
    type: time_t
    restore_value: no
    initial_value: time(NULL)
  - id: LD1125H_Last_Mov_Time
    type: time_t
    restore_value: no
    initial_value: time(NULL)
  - id: LD1125H_Clearence_Status
    type: bool
    restore_value: no
    initial_value: "false"

#############################################
# General esp status LED
# https://esphome.io/components/status_led.html
#############################################
status_led:
  pin:
    number: GPIO2 #ESP32 Onboard LED
    ignore_strapping_warning: True #https://esphome.io/guides/faq.html#why-am-i-getting-a-warning-about-strapping-pins
    inverted: false

#############################################
# Interval Automations 
# https://esphome.io/guides/automations.html       
#############################################
interval:
  - interval: 1s #Clearance Scan Time
    setup_priority: -200
    then:
      lambda: |-
        if ((time(NULL)-id(LD1125H_Last_Time))>id(LD1125H_Clear_Time).state) {
          if ((id(LD1125H_Clearence_Status) == false) || (id(LD1125H_Occupancy).state != "Clearance")) {
            id(LD1125H_Occupancy).publish_state("Clearance");
            id(LD1125H_Clearence_Status) = true;
          }
          if (id(LD1125H_MovOcc_Binary).state == true) {
            id(LD1125H_MovOcc_Binary).publish_state(false);
          }
          if (id(LD1125H_Mov_Binary).state == true) {
            id(LD1125H_Mov_Binary).publish_state(false);
          }
        }

#############################################
# Number Sensors (custom component)        
# refer https://github.com/ssieb/esphome_components/tree/master/components/serial
#############################################
number:
  - platform: template
    name: ${friendly_name} LD1125H mth1 #mth1 is 0~2.8m Sensitivity.
    id: LD1125H_mth1
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true #If you don't want to store the setting at ESP, set it to false.
    initial_value: "60.0" #Default mth1 Setting
    min_value: 10.0
    max_value: 600.0
    step: 5.0
    set_action:
      then:
        - uart.write:
            id: LD1125H_UART_BUS
            data: !lambda |-
              std::string th1st = "mth1=" + str_sprintf("%.0f",x) +"\r\n";
              return std::vector<uint8_t>(th1st.begin(), th1st.end());
  - platform: template
    name: ${friendly_name} LD1125H mth2 #mth2 is 2.8~8m Sensitivity.
    id: LD1125H_mth2
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true #If you don't want to store the setting at ESP, set it to false.
    initial_value: "30" #Default mth2 Setting
    min_value: 5
    max_value: 300
    step: 5
    set_action:
      then:
        - uart.write:
            id: LD1125H_UART_BUS
            data: !lambda |-
              std::string th2st = "mth2=" + str_sprintf("%.0f",x) +"\r\n";
              return std::vector<uint8_t>(th2st.begin(), th2st.end());
  - platform: template
    name: ${friendly_name} LD1125H mth3 #mth3 is above 8m Sensitivity.
    id: LD1125H_mth3
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true #If you don't want to store the setting at ESP, set it to false.
    initial_value: "20" #Default mth3 Setting
    min_value: 5
    max_value: 200
    step: 5
    set_action:
      then:
        - uart.write:
            id: LD1125H_UART_BUS
            data: !lambda |-
              std::string th3st = "mth3=" + str_sprintf("%.0f",x) +"\r\n";
              return std::vector<uint8_t>(th3st.begin(), th3st.end());
  - platform: template
    name: ${friendly_name} LD1125H rmax #rmax is max detection distance.
    id: LD1125H_rmax
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true #If you don't want to store the setting at ESP, set it to false.
    initial_value: "8" #Default rmax Setting
    min_value: 0.4
    max_value: 12
    step: 0.1
    set_action:
      then:
        - uart.write:
            id: LD1125H_UART_BUS
            data: !lambda |-
              std::string rmaxst = "rmax=" + str_sprintf("%.1f",x) +"\r\n";
              return std::vector<uint8_t>(rmaxst.begin(), rmaxst.end());
  - platform: template
    name: ${friendly_name} LD1125H Clearence Time
    id: LD1125H_Clear_Time
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true #If you don't want to store the setting at ESP, set it to false.
    initial_value: "5" #LD1125H Mov/Occ > Clearence Time Here
    min_value: 0.5
    max_value: 20
    step: 0.5
  - platform: template
    name: ${friendly_name} LD1125H Movement Time
    id: LD1125H_Mov_Time
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true #If you don't want to store the setting at ESP, set it to false.
    initial_value: "1" #LD1125H Mov > Occ Time Here
    min_value: 0.5
    max_value: 10
    step: 0.5

#############################################
# General Sensors 
# https://esphome.io/components/sensor/index.html
#############################################
sensor:
  - platform: bme280_i2c
    temperature:
      name: ${friendly_name} BME280 Temp
      accuracy_decimals: 1     
      oversampling: 2x
    pressure:
      name: ${friendly_name} BME280 Pressure
      oversampling: 2x
    humidity:
      name: ${friendly_name} BME280 Humidity
      accuracy_decimals: 1     
      oversampling: 2x
    address: 0x76
    update_interval: ${update_time}   
  - platform: uptime
    name: ${friendly_name} Uptime
  - platform: template
    name: ${friendly_name} LD1125H Distance
    id: LD1125H_Distance
    icon: "mdi:signal-distance-variant"
    unit_of_measurement: "m"
    accuracy_decimals: 2
    filters:    # Use Fliter To Debounce
    - sliding_window_moving_average:
        window_size: 8
        send_every: 2
    - heartbeat: 0.2s

#############################################
# Text Sensors (custom component)
# refer https://github.com/ssieb/esphome_components/tree/master/components/serial
#############################################
text_sensor:
  - platform: serial
    uart_id: LD1125H_UART_BUS
    name: ${friendly_name} LD1125H UART Text
    id: LD1125H_UART_Text
    icon: "mdi:format-text"
    internal: True #If Don't Want to See UART Receive Data, Set To True
    on_value:
      lambda: |-
        if (id(LD1125H_UART_Text).state.substr(0,3) == "occ") {
          id(LD1125H_Distance).publish_state(atof(id(LD1125H_UART_Text).state.substr(9).c_str()));
          if ((time(NULL)-id(LD1125H_Last_Mov_Time))>id(LD1125H_Mov_Time).state) {
            id(LD1125H_Occupancy).publish_state("Occupancy");
            if (id(LD1125H_MovOcc_Binary).state == false) {
              id(LD1125H_MovOcc_Binary).publish_state(true);
            }
            if (id(LD1125H_Mov_Binary).state == true) {
              id(LD1125H_Mov_Binary).publish_state(false);
            }
          }
          if (id(LD1125H_MovOcc_Binary).state == false) {
            id(LD1125H_MovOcc_Binary).publish_state(true);
          }
          id(LD1125H_Last_Time) = time(NULL);
          if (id(LD1125H_Clearence_Status) == true) {
            id(LD1125H_Clearence_Status) = false;
          }
        }
        else if (id(LD1125H_UART_Text).state.substr(0,3) == "mov") {
          id(LD1125H_Distance).publish_state(atof(id(LD1125H_UART_Text).state.substr(9).c_str()));
          id(LD1125H_Occupancy).publish_state("Movement");
          if (id(LD1125H_MovOcc_Binary).state == false) {
            id(LD1125H_MovOcc_Binary).publish_state(true);
          }
          if (id(LD1125H_Mov_Binary).state == false) {
            id(LD1125H_Mov_Binary).publish_state(true);
          }
          id(LD1125H_Last_Mov_Time) = time(NULL);
          id(LD1125H_Last_Time) = time(NULL);
          if (id(LD1125H_Clearence_Status) == true) {
            id(LD1125H_Clearence_Status) = false;
          }
        }
  - platform: template
    name: ${friendly_name} LD1125H Occupancy Status
    id: LD1125H_Occupancy
    icon: "mdi:motion-sensor"

#############################################
# Binary Sensors
# https://esphome.io/components/binary_sensor/index.html
#############################################
binary_sensor:
  - platform: status
    name: ${friendly_name} Status
  - platform: template
    name: ${friendly_name} LD1125H Occupancy or Movement
    id: LD1125H_MovOcc_Binary
    device_class: occupancy
  - platform: template
    name: ${friendly_name} LD1125H Movement
    id: LD1125H_Mov_Binary
    device_class: motion  

Other Links

Digiblur’s YouTube video on using a similar setup:
https://digiblur.com/2023/05/24/esphome-mmwave-presence-how-to-guide/

Another Digiblur video on why he likes the LD1125H
https://www.youtube.com/watch?v=9j5Yy5M8YOs

patrick3399’s HiLink mmWave ESPHome configs on Github:
https://github.com/patrick3399/Hi-Link_mmWave_Radar_ESPHome/tree/main

A quick LD1125H short range test demonstration (Useful Electronics)
https://www.youtube.com/watch?v=hZ1jm6BMlOE

Another video on the LD1125H (Useful Electronics)
https://www.youtube.com/watch?v=RXj-JX6W-YE

Leave a Reply

Your email address will not be published. Required fields are marked *

Post comment