The ESP32-C6 is a radio-technology-rich offering of the Espressif ESP32 family of microcontrollers. In addition to such widely-used technologies as Thread and Zigbee, the chip has onboard WiFi and an available HTTP stack. This makes it capable of communicating with the internet by way of your WiFi router. Since, like so many other MCUs it also has a UART, it can gather data from a small "human radar" device called the LD2420.

This project presents a bare-bones, minimalist (not production-ready) implementation of the use of this sensor with 'telemetry' to a server on the LAN connected by WiFi. It could just as easily be to a public server (the "example.com" server was tried with the HTTP capability). To receive the data, a server using Spring RESTful in Kotlin was implemented. A pair of FreeRTOS tasks linked by a queue was used on the MCU to capture and communicate the sensor output. Since the ESP32-c6 was used, only the MCU and a single communication wire was required.

None
The circuit

As can be seen above, the ESP32-C6 can feed power back to the breadboard for use by the sensor.

None
All wiring to into the LD2420: 3v3, Gnd and OT1 (as Tx)

In the second image, the sensor is temporarily lifted away from the board so that the power connections underneath become visible.

Since no commands are sent back to the sensor, there is no need to connect its Rx (receiver) pin. Only three connections are needed.

About the ESP32-C6

ESP's come from a company called Espressif. They are frequently far more powerful than some 8-bit (and even many 32-bit) counterparts. This one has the builtin radio capabilities, plus considerable flash and RAM memory. This overview Espressif ESP32-C6 shows off its features. It includes WiFi 6 (802.11ax) plus older WiFi technologies (802.11 b/g/n). It supports Zigbee and Thread communications. Zigbee, with its low power and low data rate (rather like LoRa, but not for the extreme range), is meant to be simpler than WiFi. Thread is in a similar space, but uses IPv6 and is integrated into a home automation standard, Matter. IPv6 is one of the distinguishing features of Thread from Zigbee. Bluetooth Low Energy is also an ESP32-C6 capability. Those will not be discussed further in this article, but all are accessible with this little wonder.

The full data sheet for the C6 can be found at ESP32-C6 Datasheet. The device has two RISC V processors — one for high power and one for low power, running at 160MHz and 20MHz, respectively. The ESP32s all have some proficient security features, as well. Like most modern MCUs, this one is surface mount, so a Xiao development board is used for this project. Thumbnail-sized, with built-in USB connectivity, the Xiao saves us from having to purchase a separate programming board. The C6 has 320KB of ROM and 512KB of static RAM. According to the datasheet, a Xiao-specific description, there is also 4MB of Flash included on-board, available through its Flash interface, but by-and-large the off-chip nature of it is transparent to most developers. When programming in C/C++, you just use what's there. In summary, the Xiao is a small but powerful package.

Of note: to use this one in a bread board you will need to solder some pins onto it. But for nearby radio communications, you will not need to attach an antenna.

About LD2420

This sensor board is equally tiny. Its data sheet is found at LD2420. This sensor does not have as much readily-available information as some others, such as the BMP180 or the DHT-11/22. However, what will be presented here is a rather simplistic application of the device. It simply is powered up, whereupon it continually sends sensor readings of proximity (in centimeters) through its UART interface. If a person is nearby, and within range (stated ranges are up to 8 meters), it will send the message "ON\nNNN". If there is no person within its range "OFF\n" will be messaged. This is simply a flood of messages to be used or dropped as the receiver sees fit.

In this project, the sensor's readings are received and forwarded to a queue to be transmitted via WiFi. The WiFi is much slower and more power-hungry than the sensor. Samples of the message flood are transmitted at 10 second intervals with the accompanying code. However, other approaches may be deemed more desirable for realistic applications. A constant outflow would lend itself to quick responses to an arriving threat or opportunity, for example.

Rationale

There are some obvious reasons one might want a human presence detector, as they would be handy for home and personal security, or guarding a disused piece of property or equipment. Being more specific to human presence than some other sensors, they could be used to trigger lighting, camera, or other, more expensive sensors. Maybe you could even dispatch a robot to assess a situation, given that a person is suspected to be on site.

The Server

The implementation of the server used here was done in a matter of less than an hour. If you were using the sensor for remote security, you would also want to take server security much more seriously. But, the code here is sufficient to receive telemetry from the device. A trip to the "Spring Initializr" site got this project over half done in seconds. Getting the right combination of requested features and language took far longer. What worked very nicely was JDK17 plus Kotlin plus gradle using Kotlin, plus the Spring REST dependency. When you run the Initializr (sic), you get a zip file you can download, unzip and run in your IDE. Once that had been done, it was a simple matter to implement a REST service with a single GET method that writes the sensor output to a log.


package sensor.restcontroller

import lombok.extern.slf4j.Slf4j
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.PathVariable
import org.springframework.web.bind.annotation.RestController

@RestController()
@Slf4j
class SensorController {
    @GetMapping("/sensor/{value}", produces = ["text/plain"])
    fun accept(@PathVariable value: String): String {
        log.info(value)
        return "OK, got $value"
    }
    companion object {
        val log: Logger = LoggerFactory.getLogger(SensorController::class.java)
    }
}

Above is the entire server code used. It is automatically registered as a Spring REST controller by being placed under the package generated by the Initializr. A node.js or Python Django server would work just as well. Aspects such as security, persistence, load-balancing and scaling are all well supported by the Spring community if you go that route.

2026-04-19T21:10:06.091-04:00  INFO 52316 --- [collector] [io-8080-exec-10] s.l.c.controller.SensorController        : 105
2026-04-19T21:10:16.295-04:00  INFO 52316 --- [collector] [nio-8080-exec-1] s.l.c.controller.SensorController        : 118
2026-04-19T21:10:26.491-04:00  INFO 52316 --- [collector] [nio-8080-exec-2] s.l.c.controller.SensorController        : 157
2026-04-19T21:10:36.692-04:00  INFO 52316 --- [collector] [nio-8080-exec-3] s.l.c.controller.SensorController        : 185
2026-04-19T21:10:46.935-04:00  INFO 52316 --- [collector] [nio-8080-exec-4] s.l.c.controller.SensorController        : 86
2026-04-19T21:10:57.138-04:00  INFO 52316 --- [collector] [nio-8080-exec-5] s.l.c.controller.SensorController        : 90

And this is the log output, which includes some logging chatter. The actual output is just those few digits of readings values in the final column.

Your mileage may vary, but out of the box, this sensor was in a special debug or text (or debug-text) mode. It was not in any way calibrated for this simple demo.

It must be mentioned here that HTTP (and even HTTPS with its in-transit encryption for security) are not often the first choice for sensor (or IoT) communications. More often, the simpler MQTT protocol is preferred for its scalability and applicability in high volume communication. MQTT is what you might use to make your ESP32 an AWS IoT Core "Thing" and have it publish messages.

Coding The Firmware

MCU code is often called "Firmware" because it is not as "soft" as what we use in desktop and laptop computers. It does not load from disk or download in response to a web site click. It must be built much as Java or C code is compiled, and then it is pushed in a cross-platform manner, from your desktop/laptop, onto the chip. The Xiao board lets us do that using ESP IDF with commands including:

idf.py clean
idf.py build
idf.py -p COM7 flash
idf.py -p COM7 monitor

IDF is integrated into VS Code using a plugin. If you are familiar with that editing system, you can reuse those skills with IDF. The code here is written in C, and C++ works just as well.

Because the UART peripheral is not trivial and because HTTP over Wifi is even less so, some libraries were used for this project. We may take for granted that we always have a REST client capability in nearly any desktop application programming language we can think of — Java/Kotlin/Scala/Groovy, C/C++, C#, Python, JavaScript/TypeScript, etc., but on this non-Windows/non-Linux device, we have to use a library that has the TCP "stack" available. Fortunately, this is a powerful 32 bit device with plenty of RAM and Flash, and its powerful Wifi capability. It also has a broadly used core, so there is no shortage of information. As an aside, up until just a few years ago, 32 bits was the standard for Windows systems. Indeed, if you look at a Windows 11 installation, you can still find a folder called "Program Files (x86)" specifically for 32 bit applications. When the internet first received widespread use, 32 bits was the norm.

The Firmware Code

Apologies for the lack of brevity in the code that follows. But this is all of the C code.

#include <stdio.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/queue.h"
#include "esp_system.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "driver/uart.h"

#include "protocol_examples_common.h"
#include "lwip/err.h"
#include "lwip/sockets.h"
#include "lwip/sys.h"
#include "lwip/netdb.h"
#include "lwip/dns.h"
#include "esp_wifi.h"
#include "nvs_flash.h"

/* Constants that aren't configurable in menuconfig */
#define WEB_SERVER "mywebserver.local"
#define WEB_PORT "8080"
#define WEB_PATH "/sensor/"

static QueueHandle_t sensor_evt_queue = NULL;

static const char *TAG = "HPS";

static uart_port_t uart_num;

static void http_get_task(void *pvParameters)
{
    const struct addrinfo hints = {
        .ai_family = AF_INET,
        .ai_socktype = SOCK_STREAM,
    };
    struct addrinfo *res;
    struct in_addr *addr;
    int s, r;
    char recv_buf[64];
    int32_t value;

    while(1)
    {
        if (xQueueReceive(sensor_evt_queue, &value, portMAX_DELAY))
        {
            ESP_LOGI(TAG, "Received sensor event with value: %ld", value);

            int err = getaddrinfo(WEB_SERVER, WEB_PORT, &hints, &res);


            if(err != 0 || res == NULL)
            {
                ESP_LOGE(TAG, "DNS lookup failed err=%d res=%p", err, res);
                vTaskDelay(1000 / portTICK_PERIOD_MS);
                continue;
            }

            /* Code to print the resolved IP.


            Note: inet_ntoa is non-reentrant, look at ipaddr_ntoa_r for "real" code */
            addr = &((struct sockaddr_in *)res->ai_addr)->sin_addr;
            ESP_LOGI(TAG, "DNS lookup succeeded. IP=%s", inet_ntoa(*addr));


            s = socket(res->ai_family, res->ai_socktype, 0);
            if(s < 0)
            {
                ESP_LOGE(TAG, "... Failed to allocate socket.");
                freeaddrinfo(res);
                vTaskDelay(1000 / portTICK_PERIOD_MS);
                continue;
            }
            ESP_LOGI(TAG, "... allocated socket");


            if(connect(s, res->ai_addr, res->ai_addrlen) != 0)
            {
                ESP_LOGE(TAG, "... socket connect failed errno=%d", errno);
                close(s);
                freeaddrinfo(res);
                vTaskDelay(4000 / portTICK_PERIOD_MS);
                continue;
            }


            ESP_LOGI(TAG, "... connected");
            freeaddrinfo(res);

            char request_buf[256];
            snprintf(request_buf, sizeof(request_buf), "GET %s%ld HTTP/1.0\r\nHost: %s:%s\r\nUser-Agent: esp-idf/1.0 esp32\r\n\r\n",
                     WEB_PATH, value, WEB_SERVER, WEB_PORT);

             ESP_LOGI(TAG, "Sending HTTP request: %s", request_buf);

             if (write(s, request_buf, strlen(request_buf)) < 0)
             {
                ESP_LOGE(TAG, "... socket send failed");
                close(s);
                vTaskDelay(4000 / portTICK_PERIOD_MS);
                continue;
            }

            ESP_LOGI(TAG, "... socket send success");


            struct timeval receiving_timeout;
            receiving_timeout.tv_sec = 5;
            receiving_timeout.tv_usec = 0;
            if (setsockopt(s, SOL_SOCKET, SO_RCVTIMEO, &receiving_timeout,
                    sizeof(receiving_timeout)) < 0)
            {
                ESP_LOGE(TAG, "... failed to set socket receiving timeout");
                close(s);
                vTaskDelay(4000 / portTICK_PERIOD_MS);
                continue;
            }
            ESP_LOGI(TAG, "... set socket receiving timeout success");


            /* Read HTTP response */
            do
            {
                bzero(recv_buf, sizeof(recv_buf));
                r = read(s, recv_buf, sizeof(recv_buf)-1);
                for(int i = 0; i < r; i++) {
                    putchar(recv_buf[i]);
                }
            } while(r > 0);


            ESP_LOGI(TAG, "... done reading from socket. Last read return=%d errno=%d.", r, errno);
            close(s);
        }
    }
}

static void sensor_read_task(void *pvParameters)
{
    while(1)
    {
        // Read data from UART.
        uint8_t data[128];
        int length = 0;
        ESP_ERROR_CHECK_WITHOUT_ABORT(uart_flush(uart_num));
        vTaskDelay(pdMS_TO_TICKS(200));
        ESP_ERROR_CHECK_WITHOUT_ABORT(uart_get_buffered_data_len(uart_num, (size_t*)&length));
        length = uart_read_bytes(uart_num, data, length, 100);
        if (length > 0)
        {
            ESP_LOGI(TAG, "Received %d bytes: %.*s", length, length, data);

            // Parse the input.
            int32_t value = 0;
            // data[length] = '\0'; // Null-terminate the string
            if (strncmp((char*)data, "ON", 2) == 0)
            {
                char* value_str = (char*)data + 10; // Assuming format "ON\nRange 105"
                value = atoi(value_str);
                ESP_LOGD(TAG, "Parsed sensor value: %s as %ld", value_str, value);
            }
            else if (strncmp((char*)data, "OFF", 3) == 0)
            {
                value = -1;
            }
            else
            {
                ESP_LOGW(TAG, "Unrecognized sensor data format: %.*s", length, data);
                continue; // Skip sending to queue if format is unrecognized
            }

            // Send event to queue
            if (xQueueSend(sensor_evt_queue, &value, 0) != pdTRUE)
            {
                ESP_LOGE(TAG, "Failed to send sensor event");
            }

        }
        vTaskDelay(pdMS_TO_TICKS(10000));
    }
}

/**
 * @brief https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-reference/peripherals/uart.html
 * This initializes UART1 for receiving data. It sets up a buffered UART driver with an event queue, configures
 * the UART parameters (baud rate, data bits, parity, stop bits, flow control), and assigns GPIO pins for TX and RX. The main loop continuously checks for incoming data on the UART and logs any received bytes.
 * @return uart_port_t simply the UART port number
 */
static uart_port_t uart_init(void)
{
    const uart_port_t uart_num = UART_NUM_1;

    // Setup UART buffered IO with event queue
    const int uart_buffer_size = (1024 * 2);
    QueueHandle_t uart_queue;
    // Install UART driver using an event queue here
    ESP_ERROR_CHECK(uart_driver_install(uart_num, uart_buffer_size, uart_buffer_size, 10, &uart_queue, 0));

    uart_config_t uart_config = {
        .baud_rate = 115200,
        .data_bits = UART_DATA_8_BITS,
        .parity = UART_PARITY_DISABLE,
        .stop_bits = UART_STOP_BITS_1,
        .flow_ctrl = UART_HW_FLOWCTRL_CTS_RTS,
        .rx_flow_ctrl_thresh = 122,
    };
    // Configure UART parameters
    ESP_ERROR_CHECK_WITHOUT_ABORT(uart_param_config(uart_num, &uart_config));
    //  esp_err_t uart_set_pin(uart_port_t uart_num, int tx_io_num, int rx_io_num, int rts_io_num, int cts_io_num);
    ESP_ERROR_CHECK_WITHOUT_ABORT(uart_set_pin(uart_num, GPIO_NUM_0, GPIO_NUM_1, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE));    
    return uart_num;
}

void app_main(void)
{
    ESP_LOGI(TAG, "UART initialized for receiving");

    ESP_ERROR_CHECK_WITHOUT_ABORT(nvs_flash_init());
    ESP_ERROR_CHECK_WITHOUT_ABORT(esp_netif_init());
    ESP_ERROR_CHECK_WITHOUT_ABORT(esp_event_loop_create_default());
    ESP_ERROR_CHECK_WITHOUT_ABORT(example_connect());        
    uart_num = uart_init();

    sensor_evt_queue = xQueueCreate(10, sizeof(uint32_t));
    xTaskCreate(&http_get_task, "http_get_task", 4096, NULL, 5, NULL);

    xTaskCreate(&sensor_read_task, "sensor_read_task", 4096, NULL, 5, NULL);
    
    if (sensor_evt_queue == NULL)
    {
        ESP_LOGE(TAG, "Failed to create sensor event queue");
        return;
    }

    for(;;)
    {
        vTaskDelay(portMAX_DELAY);
    }
}

Of prime interest here are the C include statements. You can see the use of the "LWIP" library, FreeRTOS headers, ESP WiFi and something called NVS Flash. That last one is Non-Volatile-Storage flash, and is required for the use of the WiFi library itself. Flash memory is not trivial to work with; you may be aware that flash drives have only a certain number of write cycles? MCUs (and their attached flash memory in this case) are the same way. The number of cycles can run well into the thousands or even hundreds of thousands, which is a high number of code flashes. But if the code uses it to write temporary data it gets more complicated, because any byte of the flash that gets written beyond its maximum will become no longer usable. You could end up with a permanently fragmented piece of memory, so the library will use things like rotation algorithms to avoid repeated writes to the same location.

The LWIP library is "lightweight IP", and helps with socket communications and other things related to the TCP/IP protocol.

This code also depends on the ESP's UART library. Other articles in this series have used UARTs and done so at bare metal. UARTs are a common peripheral, and can be included in the silicon such that you only have to write data to registers to make it work. But this is a more complex piece of hardware. And even if it were to turn out only to have as many steps as the smaller 8-bit MCUs, having a library here avoids one more thing to debug.

The UART is configured for 115200 baud, which is standard for this sensor. The sensor read code is a FreeRTOS task which feeds a FreeRTOS queue (more on that later). It is taking the responsibility of trimming the incoming message down to a single integer. We will be using the sentinel value of '-1' to indicate that "Elvis has left the building" (or, put differently, nobody is in the room).

The other FreeRTOS task created here is the one that talks to HTTP. It listens to the queue being fed from sensor reads, retrieves those messages and contacts our server. It resolves the IP address of the remote server, sets up the socket for communications, makes a request in a special buffer using "snprintf()", sends the message, gathers back the response and logs the result.

You may have noticed another include statement:

#include "protocol_examples_common.h"

The project's code was built upon and relies heavily on an example project from the makers of ESP32. The YAML file below is placed into the 'main' folder of the project, which also contains the C code:

...ESP32Projects\human-presence-sensor\main>type idf_component.yml
dependencies:
  protocol_examples_common:
    path: ${IDF_PATH}/examples/common_components/protocol_examples_common

When IDF is installed, and when the IDF shell is in use, the IDF_PATH variable will have been established for convenience. The installation includes examples like this. Below is what that directory looks like.

...ESP32Projects\human-presence-sensor\main>dir %IDF_PATH%\examples\common_components\protocol_examples_common
 Directory of ...\Espressif\frameworks\esp-idf-v5.3.1\examples\common_components\protocol_examples_common

01/08/2025  02:00 AM    <DIR>          .
01/08/2025  02:00 AM    <DIR>          ..
11/06/2024  07:17 AM             2,355 addr_from_stdin.c
11/06/2024  07:17 AM             1,166 CMakeLists.txt
11/06/2024  07:17 AM             4,282 connect.c
11/06/2024  07:17 AM             2,657 console_cmd.c
11/06/2024  07:17 AM             9,225 eth_connect.c
01/08/2025  02:00 AM    <DIR>          include
11/06/2024  07:17 AM            16,627 Kconfig.projbuild
11/06/2024  07:17 AM             9,346 ppp_connect.c
11/06/2024  07:17 AM            12,209 protocol_examples_utils.c
11/06/2024  07:17 AM             3,505 README.md
11/06/2024  07:17 AM             1,333 stdin_out.c
11/06/2024  07:17 AM             9,213 wifi_connect.c

Another important step if you use this code as-is, is establishing your WiFi SSID and credentials outside of the actual code. This also uses a tool from IDF called "menuconfig", which is launched as shown below. Note, that you must have already established the idf_component.yml file above, in order to even see the submenu we will use. Just launch menu config.

idf.py menuconfig

That will show you the menu system below.

None
Launching menuconfig
None
Click into the "Example Connection Configuration" submenu
None
Note WiFi SSID and WiFi Password options
None
You can enter your password, here

When done as above, the values get stored in flash memory. You can also see options for using standard input to feed in the SSID and credential. Another item you may wish to change in the menu is "Obtain IPv6 address". It may be beneficial to uncheck that and use IPv4 — especially if you are using a LAN based server.

None
Unchecking IPv6

The absolute path has been redacted, but the IDF_PATH variable will "find" it for you.

Conclusions

This project has been a push-off in a very small boat, from the shores of ignorance. What was presented here does not do much, but it could be the foundation for bigger things. Just as a boat at anchor has a certain amount of momentum, so does learning. Hopefully this article breaks a little of that momentum for ESP32's and IDF. If you decide to do a project with these tools, ESP IDF can be installed with instructions at getting started with the ESP32. This project is just a start. These are some ideas of how to make your code more production-ready:

  • Use HTTPS or use MQTT over TLS, for security
  • If using the web/REST approach, use POST method, not GET
  • Harden the MCU so it cannot be compromised easily
  • Configure the sensor for more minimal output and leverage its framed communication, rather than having to parse away the text
  • Get away from the breadboard, and make something with a more portable power supply
  • Do something more constructive with the sensor output

Of course it is important to remember that just as we would safeguard our own security, we should always respect that of others.

Now, you get to take the helm!

References

Acknowledgements

Many thanks to Drew Foster for editorial input.