ESP32 + Rust: Scenario-Based LED Controller CLI

703 words 4 minutes rust esp32 cli iot

🔧 Introduction

In previous projects, we built a simple LED control for the ESP32 using Rust — turning lights on and off through UART commands.
It was a great starting point to explore embedded development with Rust and understand how hardware and software interact.

This time, we’re taking a significant step forward — both in code and electronics.
We’re building a fully functional command-line (CLI) application with Rust that communicates with the ESP32 to control LEDs through scenario-based lighting logic.

Unlike before, where we controlled just a single LED, this project expands the electrical side as well — we now work with three LEDs, introducing more complex patterns and interactions.
You’ll be able to:

  • Send commands directly from your PC to the ESP32;
  • Run predefined lighting scenarios;
  • Create and manage your own custom lighting scenarios;

This project not only showcases practical embedded Rust development but also brings ESP32 setup closer to a small, smart lighting system — all controlled from your terminal.

The full code is available on GitHub, while here we’ll focus on architecture, main components, and selected snippets.


⚙️ Project Overview

The application is written in asynchronous Rust using Tokio runtime and organized into clear modules:

src/
├── cli.rs # Command-line interface definitions (clap)
├── logging.rs # Tracing + file logging setup
├── serial.rs # Async serial communication with ESP32
├── scenarios/ # Scenario logic (builtin + custom)
│ ├── mod.rs
│ └── builtin.rs
├── scheduler.rs # (not shown) Scenario scheduling logic
├── utils.rs # Helper for reading YAML configs
└── main.rs # Entry point

⚡ Electrical Part

Each LED must be connected through a current-limiting resistor (typically 220–330 Ω) to prevent damage to both the LED and the ESP32 pin.
The schematic is simple but essential for reliable operation: Circuit

In my case I connect

  • 1 LED → GPIO D5 (through 220 Ω resistor)
  • 2 LED → GPIO D18 (through 220 Ω resistor)
  • 3 LED → GPIO D19 (through 220 Ω resistor)

Circuit


🧭 Command-Line Interface (CLI)

We use the clap crate to define commands and subcommands.
Each command is designed for specific control over rooms, scenarios, and schedules.

Example:

led-scenario.exe room 1 on
led-scenario.exe scenario run morning

The CLI builder in cli.rs defines everything:

Command::new("LED Controller")
    .version("0.1.0")
    .about("Scenario based LED control CLI")
    .arg(Arg::new("comport")
        .short('c')
        .long("comport")
        .default_value("COM3"))
    .arg(Arg::new("baudrate")
        .short('b')
        .long("baudrate")
        .default_value("115200"))
    .subcommand(
        Command::new("room")
            .about("Send single command")
            .arg(Arg::new("room").required(true))
            .arg(Arg::new("action").required(true))
    )

This gives us a convenient way to control lights or manage automation directly from the terminal.

🔌 Serial Communication Layer

The heart of communication with ESP32 lies in serial.rs.
We use tokio-serial for asynchronous I/O and continuously read device responses in a background task.

Key functionality:

pub async fn send_command(&self, cmd: &str) -> Result<(), anyhow::Error> {
    let mut inner = self.inner.lock().await;
    let mut data = cmd.as_bytes().to_vec();
    data.push(b'\n');
    inner.write_all(&data).await?;
    inner.flush().await?;
    info!("Sent serial: {}", cmd);
    Ok(())
}

And a background reader:

pub fn start_reader(&self) {
    let inner_clone = self.inner.clone();
    tokio::spawn(async move {
        let mut buf = [0u8; 1024];
        let mut line_buf = Vec::new();
        loop {            
            let n = { inner_clone.lock().await.read(&mut buf).await.unwrap_or(0) };
            if n > 0 {
                for &b in &buf[..n] {
                    if b == b'\n' {
                        if let Ok(line) = String::from_utf8(line_buf.clone()) {
                            info!("Received serial: {}", line.trim());
                        }
                        line_buf.clear();
                    } else if b != b'\r' {
                        line_buf.push(b);
                    }
                }
            }
        }
    });
}

This allows us to send simple UART commands like 11 (turn ON room 1) or 30 (turn OFF room 3), while still listening for device feedback.

🎭 Scenario System

The Scenario Manager organizes both built-in and custom YAML-defined lighting scenarios.
Each scenario is a sequence of commands with optional delays.

Example of built-in scenario (from builtin.rs):

Scenario {
    name: "morning".to_string(),
    description: "Smooth morning lights".to_string(),
    delay_between_commands: 1000,
    commands: vec![
        ScenarioCommand { room: "1".to_string(), action: "on".to_string(), delay_after: Some(500) },
        ScenarioCommand { room: "2".to_string(), action: "on".to_string(), delay_after: Some(500) },
        ScenarioCommand { room: "3".to_string(), action: "on".to_string(), delay_after: None },
    ],
}

Running it from CLI:

led-scenario.exe scenario run morning

Internally, the executor iterates over the steps asynchronously:

for (i, command) in scenario.commands.iter().enumerate() {
    info!("Step {}/{}: room {} -> {}", i + 1, scenario.commands.len(), command.room, command.action);
    let cmd = command_to_string(&command.room, &command.action);
    serial.send_command(&cmd).await?;
    sleep(Duration::from_millis(command.delay_after.unwrap_or(scenario.delay_between_commands))).await;
}

You can also load your own scenarios from YAML files stored in /example.

🧩 Configuration and Custom Scenarios

User-defined scenarios are simple YAML files:

name: "party"
description: "Colorful blinking lights"
delay_between_commands: 500
commands:
  - room: "1"
    action: "on"
  - room: "2"
    action: "off"
  - room: "3"
    action: "on"

Load them with:

led-scenario.exe scenario create my_party.yaml

The app automatically copies it into your home directory (~/.esp32_scenarios) for persistence.

🪵 Logging

Logging is handled by tracing with both console and file outputs:

let (non_blocking, guard) = non_blocking(file);
let env = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));

tracing_subscriber::registry()
    .with(env)
    .with(fmt_layer)
    .with(file_layer)
    .try_init()
    .ok();

All logs are written into logs/LED_controller.log, including serial communication traces and scenario execution steps.

🧠 Main Application Flow

Let’s look at the simplified structure of main.rs:

#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let _guard = logging::init()?;
    let matches = cli::build_cli().get_matches();

    // Connect to serial port
    let comport = matches.get_one::<String>("comport").unwrap_or(&"COM3".to_string());
    let baud = matches.get_one::<u32>("baudrate").unwrap_or(&115200);
    let mut serial = SerialController::new(comport, *baud).await?;
    serial.start_reader();

    // Load builtin + user scenarios
    let mut manager = ScenarioManager::new();
    manager.load_custom_from_dir(&dirs::home_dir().unwrap().join(".esp32_scenarios")).ok();

    // Handle subcommands
    match matches.subcommand() {
        Some(("room", _)) => { /* send single command */ }
        Some(("scenario", _)) => { /* manage scenarios */ }
        Some(("schedule", _)) => { /* run or add tasks */ }
        _ => { println!("Use --help for command list"); }
    }

    Ok(())
}

This single binary provides a complete end-to-end solution — from low-level serial communication to scenario scheduling.

🧾 Summary

This project demonstrates how Rust can be used for practical IoT automation, offering both reliability and structure.
We combined:

  • ✅ Async serial communication with ESP32
  • ✅ Scenario-based lighting control
  • ✅ YAML-based configuration and scheduling
  • ✅ Structured CLI with clap
  • ✅ Logging and persistent settings

It’s an extensible foundation for simple home automation or testing workflow based on UART communication.

👉 Full Source Code

You can find the complete code with all modules and configuration examples on GitHub:
https://github.com/AnakenRalf/esp32-rust-led-scenario-cli

🎥 YouTube demo

This visual walkthrough helps you better understand how software commands translate into real electrical behavior — turning abstract Rust code into a tangible, dynamic lighting system.

👉 Watch the full process and demo here:
YouTube link

Help in setup esp-idf toolchain

Some time-saving tips for configuration esp-idf toolchain in [step-by-step guide on Patreon]

Stay tuned — Rust + IoT is just getting started.