Scroll to content

DIY ESP32 presence detection ruined by a web developer

I’ve been on a bit of a quest recently to retrobodge smart home functionality into a 1930s house. This is the story of how I tried, failed and unexpectedly succeeded in building a custom presence detection sensor for Home Assistant.

The midnight trip to the toilet presents a dilemma: do you stumble in the dark trusting that nobody’s left anything underfoot, or do you turn on the light and hurt your chances of getting back to sleep again? This is exactly the sort of problem that smart home tech can solve, using a presence sensor to detect whether someone’s in the room and a smart bulb to turn on a dim light at a low colour temperature.

There are lots of smart home sensors out there. Some of them run on batteries, which I don’t like. No, I don’t care if they can last two years on a single battery, by the time I have 50 sensors (which isn’t an excessive number for a smart home) I’d be changing a battery every couple of weeks. That’s not using automation to make my life easier, it’s turning me into a battery changing monkey. Some presence sensors can be quite expensive, so I decided it might be a fun project to build my own.

The hardware

The hardware is very simple: the smallest ESP32 board I could find (an ESP32-S2 Mini) for the brains and a Hi-Link HLK-LD1125H 24GHz mmwave radar sensor to do the presence detection. A mmwave radar is a far more reliable way of detecting presence than something like a passive infrared sensor, as it can reliably detect someone who’s very still (for example because they’re asleep). Not that I’m expecting to fall asleep on the landing, but if a better and more reliable tech exists, why not use it?

The sensor board is small. Really small. Bottle cap for scale.

The mmwave sensor board propped up against a bottle cap. It measures around 16mm x 31mm

The housing, and a point of philosophy

Once I’d cobbled the hardware together I had to come up with some sort of housing for it. I know a lot of people would be reaching for the 3D printer at this point, but I’ve yet to join the 3D printing revolution. I found a PIR sensor for a few quid that was designed to fit into a standard wall box, replacing a lightswitch. I don’t want to replace the lightswitch. Mitch Hedberg once said “An escalator can never break, it can only become stairs.” I like my smart home stuff to enhance the dumb functionality that’s already there, not replace it entirely. Lightswitches should still work exactly as they used to, but with the option that the sensors can also turn the lights on and off (I haven’t got around to that part yet, so for the moment I’m just leaving the switch on).

The disassembled PIR sensor

If I remove the rear part of the IR sensor housing that fits into the wall box, I estimated that I should be able to fit the sensor and ESP32 board into the depth of the wall plate and mount it flush to the wall if I desoldered the pins and soldered the wires (5v, ground, tx and rx) directly to the board. A quick dry fit confirmed, and I apparently forgot to take a photo after soldering. Oops. Ah well, we’ve all seen small wires before.

Dry fitting the components into the wall plate

And finally mounted everything to the wall (I know, I need to strip the remains of the wallpaper and repaint. It’s on my list).

The completed sensor mounted to the wall, level with the top of the two doors next to it. A neatly pinned cable runs down the side of the doorframe on the left.

So, how much did it all cost?

All prices are correct at time of purchase but may fluctuate.

ESP32-S2 Mini £6.29
LD1125H £6.21
PIR sensor £6.39
Total £18.89

(I haven’t added the cost of the bits of wire, Type C cable and a spare charger from a Kindle tablet because I had those lying around already).

The software

I bodged together an ESPHome config for the sensor based on code from patrick3399’s repo and flashed it to the device using the ESPHome integration in Home Assistant:

substitutions:
  devicename: "landing-sensor" 
  upper_devicename: "landing-sensor" 
  update_time: 30s
  
esphome:
  name: $devicename
  on_boot:
     - 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(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(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(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(rmaxst.begin(), rmaxst.end());

esp32:
  board: lolin_s2_mini
  variant: ESP32S2
  framework:
    type: arduino
    version: recommended

api:
  encryption:
    key: "[REDACTED]"

wifi:
  ssid: "[REDACTED]"
  password: "[REDACTED]"

  manual_ip:
    static_ip: 10.0.3.2
    gateway: 10.0.0.1
    subnet: 255.255.0.0
    dns1: 10.0.0.5
  use_address: 10.0.3.2
 
  # Enable fallback hotspot (captive portal) in case wifi connection fails

  ap:
    ssid: "Landing-Sensor"
    password: "[REDACTED]"

captive_portal:

ota:
  - platform: esphome

external_components:
  - source: github://ssieb/custom_components 
    components: [ serial ]

logger:
  level: INFO  
  baud_rate: 0

uart:
  id: LD1125H_UART_BUS
  rx_pin: GPIO18  
  tx_pin: GPIO16 
  baud_rate: 115200
  data_bits: 8
  stop_bits: 1
  parity: NONE

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_Clearance_Status
    type: bool
    restore_value: no
    initial_value: "false"

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_Clearance_Status) == false) || (id(LD1125H_Occupancy).state != "Clearance")) {
            id(LD1125H_Occupancy).publish_state("Clearance");
            id(LD1125H_Clearance_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:
  - platform: template
    name: ${upper_devicename} LD1125H mth1 # mth1 is 0~2.8m Sensitivity.
    id: LD1125H_mth1
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true 
    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(th1st.begin(), th1st.end());
  - platform: template
    name: ${upper_devicename} LD1125H mth2 # mth2 is 2.8~8m Sensitivity.
    id: LD1125H_mth2
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true 
    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(th2st.begin(), th2st.end());
  - platform: template
    name: ${upper_devicename} LD1125H mth3 # mth3 is above 8m Sensitivity.
    id: LD1125H_mth3
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true 
    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(th3st.begin(), th3st.end());
  - platform: template
    name: ${upper_devicename} LD1125H rmax # rmax is max detection distance.
    id: LD1125H_rmax
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true 
    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(rmaxst.begin(), rmaxst.end());
  - platform: template
    name: ${upper_devicename} LD1125H Clearance Time
    id: LD1125H_Clear_Time
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true 
    initial_value: "5" # LD1125H Mov/Occ > Clearance Time Here
    min_value: 0.5
    max_value: 20
    step: 0.5
  - platform: template
    name: ${upper_devicename} LD1125H Movement Time
    id: LD1125H_Mov_Time
    icon: "mdi:cogs"
    optimistic: true
    restore_value: true 
    initial_value: "1" # LD1125H Mov > Occ Time Here
    min_value: 0.5
    max_value: 10
    step: 0.5

sensor:
  - platform: uptime
    name: ${upper_devicename} Uptime
  - platform: template
    name: ${upper_devicename} LD1125H Distance
    id: LD1125H_Distance
    icon: "mdi:signal-distance-variant"
    unit_of_measurement: "m"
    accuracy_decimals: 2
    filters:    # Use Filter To Debounce
    - sliding_window_moving_average:
        window_size: 8
        send_every: 2
    - heartbeat: 0.2s

text_sensor:
  - platform: serial
    uart_id: LD1125H_UART_BUS
    name: ${upper_devicename} LD1125H UART Text
    id: LD1125H_UART_Text
    icon: "mdi:format-text"
    internal: 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_Clearance_Status) == true) {
            id(LD1125H_Clearance_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_Clearance_Status) == true) {
            id(LD1125H_Clearance_Status) = false;
          }
        }
  - platform: template
    name: ${upper_devicename} LD1125H Occupancy Status
    id: LD1125H_Occupancy
    icon: "mdi:motion-sensor"

binary_sensor:

  - platform: status
    name: ${upper_devicename} Status

  - platform: template
    name: ${upper_devicename} LD1125H Occupancy or Movement
    id: LD1125H_MovOcc_Binary
    device_class: occupancy

  - platform: template
    name: ${upper_devicename} LD1125H Movement
    id: LD1125H_Mov_Binary
    device_class: motion

Once mounted I had no end of problems with it. It kept returning ghost readings when I knew there was nobody there, and I got too busy with other things to troubleshoot it so I left it there for a few weeks, meaning to come back to it. One particularly bright day I noticed there was a small cobweb up in the opposite corner of the ceiling above the stairs that was just barely moving. Could that have been the problem all along? I fetched a duster on a very long stick and cleaned it up, and sure enough the ghost readings disappeared.

This entry was posted in Smart Home and tagged , . Bookmark the permalink.

1 Responses to DIY ESP32 presence detection ruined by a web developer

Leave a Reply

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