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 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).
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.
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).
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.
Brilliant!
Bravo Zulu