ESP32 + Rust: Interactive Project with UART-Controlled LED

1129 words 6 minutes esp32 rust simple uart led

Introduction

In my previous article, we explored the "Hello, World!" of embedded programming: a blinking LED on the ESP32 using Rust. That project was all about setting up the development environment and running simple code. Now, let’s take it up a notch by making the ESP32 interactive. Instead of passively blinking, we’ll enable the ESP32 to respond to commands sent from a computer via USB serial (UART). This project introduces real-time interaction, laying the foundation for more complex Internet of Things (IoT) applications.

Let’s dive into creating an interactive UART-controlled LED project with Rust on the ESP32!


Prerequisites

Before starting, ensure you have the following from previous article:

  • Hardware:

    • An ESP32 development board (e.g., ESP32-DevKitC).
    • An LED connected to GPIO5 with a current-limiting resistor (e.g., 220–330 ohms to ground).
    • A USB cable for serial communication and power.
  • Software:

    • Rust installed with the esp-idf toolchain configured. Refer to the [step-by-step guide on Patreon] if you haven’t set this up yet.
    • A serial terminal program.

Project Plan

Here’s the high-level plan for our interactive project:

  1. Initialize UART: Configure the ESP32’s UART0 peripheral for serial communication.
  2. Read Input: Capture data sent from a serial terminal.
  3. Parse Commands: Interpret the received data as commands to control the LED.
  4. Perform Actions: Turn the LED on/off or report its status based on the command.
  5. Provide Feedback: Echo commands and send debug information (e.g., raw hex data) back to the terminal.

Features Implemented

This project implements the following features:

  1. Interactive Commands via UART:

    • 1: Turn the LED on.
    • 0: Turn the LED off.
    • 5: Report the LED’s current state (ON or OFF).
  2. Robust UART Handling:

    • A fixed-size input buffer with safety checks to prevent overflows.
    • Conversion of raw input to UTF-8 strings with trimming of whitespace and newline characters.
    • Debugging output in hexadecimal format to inspect raw received data.
  3. User Feedback:

    • Echoes every command received.
    • Displays the raw input buffer, trimmed command, and action result.
    • Sends error messages for invalid inputs or read errors.

Workflow

The ESP32’s default UART0 pins (TX on GPIO1, RX on GPIO3) are already configured for USB serial communication in most development boards. We’ll leverage this setup to communicate with a computer. The LED is connected to GPIO5, and we’ll use Rust’s esp_idf_hal crate to control both the UART and GPIO peripherals.


Code Breakdown

Below is a detailed explanation of the Rust program that powers this project.

1. Setting Up Peripherals

We use the esp_idf_hal crate to safely initialize the ESP32’s peripherals. The Peripherals::take() method ensures exclusive access to hardware resources.

use esp_idf_hal::peripherals::Peripherals;

let peripherals = match Peripherals::take() {
    Ok(p) => p,
    Err(e) => {
        println!("Failed to take peripherals: {:?}", e);
        return;
    }
};
let pins = peripherals.pins;

This code retrieves the GPIO and UART peripherals for use in the program.

2. Configuring the LED

The LED is connected to GPIO5 and configured as an output pin using PinDriver::output. We initialize it to the low state (off).

use esp_idf_hal::gpio::PinDriver;

let mut led = match PinDriver::output(pins.gpio5) {
    Ok(driver) => driver,
    Err(e) => {
        println!("Failed to configure LED on GPIO5: {:?}", e);
        return;
    }
};
let _ = led.set_low();

3. Setting Up UART

We configure UART0 for serial communication at a baud rate of 115200. The TX and RX pins are mapped to GPIO1 and GPIO3, respectively, which are the default USB serial pins on most ESP32 boards.

use esp_idf_hal::uart::{UartConfig, UartDriver};
use esp_idf_hal::units::Hertz;

let config = UartConfig::new().baudrate(Hertz(115_200));
let uart = match UartDriver::new(
    peripherals.uart0,
    pins.gpio1, // TX
    pins.gpio3, // RX
    Option::<esp_idf_hal::gpio::AnyIOPin>::None,
    Option::<esp_idf_hal::gpio::AnyIOPin>::None,
    &config,
) {
    Ok(uart) => uart,
    Err(e) => {
        println!("Failed to configure UART: {:?}", e);
        return;
    }
};

// Send initial confirmation message
let _ = uart.write(b"ESP32 Ready\r\n");

The initial ESP32 Ready message confirms that the UART is operational.

4. Command Processing Loop

The main loop reads serial input into a 64-byte buffer and processes it as UTF-8 commands. It supports the commands 1, 0, and 5, and provides feedback for each action.

use core::str;

let mut buf = [0u8; 64];
loop {
    match uart.read(&mut buf, 1000) {
        Ok(len) if len > 0 => {
            // Convert raw input to UTF-8
            match str::from_utf8(&buf[..len]) {
                Ok(cmd) => {
                    let trimmed = cmd.trim_matches(|c| c == ' ' || c == '\r' || c == '\n');
                    match trimmed {
                        "1" => {
                            let _ = led.set_high();
                            let _ = uart.write(b"Command: ON, LED ON\r\n");
                        }
                        "0" => {
                            let _ = led.set_low();
                            let _ = uart.write(b"Command: OFF, LED OFF\r\n");
                        }
                        "5" => {
                            let state = if led.is_set_high() {
                                "ON\r\n"
                            } else {
                                "OFF\r\n"
                            };
                            let _ = uart.write(state.as_bytes());
                        }
                        _ => {
                            let _ = uart.write(b"Unknown command\r\n");
                        }
                    }
                    // Echo trimmed command for debugging
                    let response = format!("Trimmed command: {}\r\n", trimmed);
                    let _ = uart.write(response.as_bytes());
                }
                Err(e) => {
                    let _ = uart.write(b"Invalid UTF-8 input\r\n");
                    println!("UTF-8 error: {:?}", e);
                }
            }
            // Output raw buffer as hex for debugging
            let mut hex_buf = [0u8; 256];
            let mut pos = 0;
            let prefix = b"Raw buffer: ";
            hex_buf[pos..pos + prefix.len()].copy_from_slice(prefix);
            pos += prefix.len();
            for &byte in &buf[..len] {
                let hex = b"0123456789abcdef";
                hex_buf[pos] = hex[(byte >> 4) as usize];
                hex_buf[pos + 1] = hex[(byte & 0xF) as usize];
                hex_buf[pos + 2] = b' ';
                pos += 3;
            }
            hex_buf[pos..pos + 2].copy_from_slice(b"\r\n");
            let _ = uart.write(&hex_buf[..pos + 2]);
        }
        Ok(_) => {} // No data received
        Err(e) => {
            let _ = uart.write(b"Read error\r\n");
            println!("Read error: {:?}", e);
        }
    }
}

Full code you can find in my GitHub: https://github.com/AnakenRalf/esp32-comport-simple-communication

5. Debugging with Hex Output

To aid debugging, the program converts the raw input buffer to a hexadecimal string and sends it back over UART. This allows you to inspect the exact bytes received, which is especially useful for diagnosing issues with serial communication.


Serial Terminal Implementation

There are many serial terminal programs available, such as PuTTY (Windows), minicom, or picocom (Linux/macOS). However, each has its own quirks, and unexpected data (e.g., extra newlines or control characters) can cause the ESP32 to report errors like Invalid UTF-8 input or Read error. To simplify communication for this educational project, we provide a custom Python serial terminal script that sends exactly the expected commands.

import serial
import time

# Adjust 'COM3' to your port, e.g., '/dev/ttyUSB0' on Linux
COMPORT = 'COM3'

# Configure serial port 
ser = serial.Serial(
    port=COMPORT,
    baudrate=115200,
    parity=serial.PARITY_NONE,
    stopbits=serial.STOPBITS_ONE,
    bytesize=serial.EIGHTBITS,
    timeout=0.2
)

try:
    print(f"Connected to {COMPORT}.\nAvailable commands:\n  '1' → Turn LED ON\n  '0' → Turn LED OFF\n  '5' → Check LED status\n  'exit' → Close serial port")
    print("Press Enter to send. Responses from ESP32 will be displayed.")
    
    # Clear initial buffer and read boot messages
    time.sleep(1)
    while ser.in_waiting:
        print(ser.readline().decode('utf-8', errors='ignore').strip())
    
    while True:
        command = input("> ")
        if command == "exit":
            break
        # Send command with \r\n
        ser.write(f"{command}\r\n".encode('utf-8'))
        ser.flush()
        # Read responses for up to 0.5s
        start_time = time.time()
        while time.time() - start_time < 0.5:
            if ser.in_waiting:
                response = ser.readline().decode('utf-8', errors='ignore').strip()
                if response:
                    print(f"ESP32: {response}")
except KeyboardInterrupt:
    print("\nExiting...")
finally:
    ser.close()
    print("Serial port closed.")

This script:

  • Connects to the ESP32 via the specified COM port (adjust COM3 to match your system).
  • Sends commands with proper newline termination (\r\n).
  • Displays responses from the ESP32, including raw hex output and command feedback.
  • Closes the serial port cleanly when you type exit or interrupt the program.

Using this script ensures reliable communication, but as you gain experience, you can extend the ESP32’s command parser to handle more complex inputs (e.g., accumulating data until a newline is received).


How to Use

Follow these steps to set up and run the project:

  1. Connect the Hardware:

    • Wire an LED to GPIO5 with a current-limiting resistor (e.g., 220–330 ohms) to ground.
    • Connect the ESP32 to your computer via a USB cable.
  2. Build and Flash:

    • Use the esp-idf toolchain to compile the Rust code to your ESP32. Run:
      cargo build --release     
      
    • Flash binary files to controller
  3. Run the Python Serial Terminal:

    • Save the Python script above as serial_terminal.py.
    • Install the pyserial library: pip install pyserial.
    • Run the script: python serial_terminal.py.
    • Adjust the COM port in the script if necessary (e.g., /dev/ttyUSB0 on Linux).
  4. Send Commands:

    • Type 1 and press Enter to turn the LED on.
    • Type 0 and press Enter to turn the LED off.
    • Type 5 and press Enter to check the LED’s state.
    • Type exit to close the serial terminal.
  5. Observe Feedback:

    • The ESP32 responds with:
      • The raw input buffer in hexadecimal.
      • The trimmed command.
      • The result of the command (e.g., Command: ON, LED ON or Unknown command).

Demo

You can see actions in YouTube short video


Why This Step Is Important

This project marks a significant milestone in your Rust + ESP32 journey:

  • Interactivity: The ESP32 is no longer just executing pre-programmed code—it’s responding to real-time user input.
  • Foundation for IoT: UART communication is a building block for more advanced features, such as Wi-Fi configuration, sensor data reporting, or remote control.
  • Debugging Skills: By including raw hex output and error handling, you’re learning how to diagnose issues in embedded systems.
  • Scalability: The command-processing loop can be extended to support additional commands, such as PWM for LED brightness or Wi-Fi setup for network connectivity.

This project bridges the gap between a simple “Hello, World” and a true interactive embedded application.

ADV

Become Patreon for support Me.