GLM 4.5 Air

ESP32 Home Automation: Event Loop vs Tasks Guide

Learn when to use event loop vs multiple tasks for ESP32 home automation robots. Discover best practices for callback management, scaling strategies, and hybrid architectures in FreeRTOS.

Question

ESP32 Home Automation Robot Architecture: Single Event Loop vs Multiple Tasks

I’m developing a home automation robot for plant growing using an ESP32 with FreeRTOS. I’m deciding between two architectural approaches:

Current Design: Single Event Loop

  • Using a lightweight libuv-style event loop for most logic
  • Handles sensor readings (temperature, humidity, soil moisture)
  • Controls pumps, lights, and fans
  • Manages networking in a separate FreeRTOS task for responsiveness
  • No strict real-time requirements (operations on hundreds of milliseconds scale)

Questions:

  1. Is a single cooperative event loop architecture suitable for an ESP32-based control system, or am I oversimplifying it?
  2. How can I effectively schedule deferred callbacks in a single event loop with a bounded queue?
  3. What are the best practices for callback management in this context?
  4. Would a multi-task approach provide better performance or reliability for this use case?
  5. If I eventually scale to multiple tasks, can the main event loop still serve as a central orchestrator?

Background:

  • Coming from app development background, applying desktop app architecture patterns
  • Understanding that true concurrency isn’t possible on single-core processor
  • Looking for architectural guidance from experienced embedded developers
GLM 4.5 Air

ESP32 Home Automation Robot Architecture: Event Loop vs Multiple Tasks

The single event loop approach is indeed suitable for an ESP32-based home automation system, especially with your millisecond-scale requirements. It provides simplicity and predictability while maintaining responsiveness when combined with a separate networking task. However, your architecture can benefit from thoughtful callback management and may evolve to incorporate task separation for specific components as your system grows.

Contents


Evaluating Single Event Loop Architecture

Your single event loop approach is not oversimplified at all—it’s a valid and potentially excellent architecture for your use case. The ESP32 is a powerful microcontroller, and a cooperative event loop model can work very well for home automation systems with relaxed timing constraints.

Key Considerations for Event Loop Approach

The ESP32 has a single-core CPU (though it does have a second core that you could utilize separately), making true parallelism impossible without hardware concurrency. Your understanding is correct that you’re implementing cooperative multitasking rather than preemptive multitasking.

Advantages of your current approach:

  • Predictability: With cooperative multitasking, you control exactly when context switching occurs, making execution more predictable
  • Simplicity: Fewer synchronization primitives to manage (no mutexes needed within the event loop)
  • Lower overhead: No task switching overhead for the main control logic
  • Natural fit for I/O operations: Event loops excel at handling multiple I/O sources with efficient CPU usage

Potential limitations:

  • Error propagation: A bug in one callback can block the entire system
  • Priority handling: Without careful design, important operations might be delayed by less critical ones
  • Debugging complexity: Tracking state through asynchronous callbacks can be challenging

For plant growing automation with operations on hundreds of milliseconds scale, your design is well-suited. Only if you needed microsecond precision for certain operations would you need to reconsider.

Comparison with Multi-task Approach

A multi-task approach would divide responsibilities across multiple FreeRTOS tasks, each with its own priority and stack. This would look something like:

Approach Event Loop Multi-task
Context switching Cooperative (explicit) Preemptive (timer-based)
Isolation Function-level Task-level
Overhead Lower Higher
Complexity Lower Higher
Error containment Poor Good

For your specific application, the event loop approach is likely sufficient unless you need strict separation between components (e.g., ensuring sensor failures don’t affect actuator control).


Implementing Deferred Callbacks with Bounded Queues

Implementing an efficient callback system with bounded queues is crucial for preventing memory exhaustion and system crashes. Here’s how to approach this:

Queue Design Patterns

Create a dedicated queue structure for your callbacks:

c
#define MAX_CALLBACKS 32
typedef struct {
    void (*func)(void* arg);
    void* arg;
    uint32_t expires_at;  // For timed callbacks
} callback_t;

callback_t callback_queue[MAX_CALLBACKS];
volatile uint16_t callback_head = 0;
volatile uint16_t callback_tail = 0;

Adding Callbacks with Bounds Checking

c
bool add_callback(void (*func)(void* arg), void* arg, uint32_t delay_ms) {
    uint16_t next_head = (callback_head + 1) % MAX_CALLBACKS;
    
    if (next_head == callback_tail) {
        return false;  // Queue is full
    }
    
    callback_queue[callback_head].func = func;
    callback_queue[callback_head].arg = arg;
    callback_queue[callback_head].expires_at = esp_timer_get_time()/1000 + delay_ms;
    
    callback_head = next_head;
    return true;
}

Processing the Callback Queue

In your event loop:

c
void process_callbacks() {
    uint32_t now = esp_timer_get_time()/1000;  // Current time in ms
    
    while (callback_tail != callback_head) {
        if (callback_queue[callback_tail].expires_at <= now) {
            callback_t cb = callback_queue[callback_tail];
            cb.func(cb.arg);  // Execute callback
            
            callback_tail = (callback_tail + 1) % MAX_CALLBACKS;
        } else {
            break;  // No more ready callbacks
        }
    }
}

Memory Management Considerations

  • Queue sizing: Start with MAX_CALLBACKS=32 and monitor usage, adjusting as needed
  • Callback cleanup: Implement timeout mechanisms for callbacks that never execute
  • Priority handling: Consider separate queues for high and low priority callbacks
  • Statistics tracking: Monitor queue depth over time to identify bottlenecks

Timer-based Callback Alternatives

For time-sensitive operations, consider using ESP32’s hardware timers:

c
// Timer example for periodic callbacks
static void timer_callback(void* arg) {
    // Add your callback to the queue
    add_callback(your_callback_function, NULL, 0);
}

void setup_timer(uint64_t period_us) {
    esp_timer_create_args_t timer_args = {
        .callback = &timer_callback,
        .name = "periodic_timer"
    };
    
    esp_timer_handle_t timer;
    esp_timer_create(&timer_args, &timer);
    esp_timer_start_periodic(timer, period_us);
}

Callback Management Best Practices

Effective callback management is critical for maintaining system stability and responsiveness in an event loop architecture.

Callback Organization Strategies

  1. Categorize callbacks by priority:

    c
    // High priority: System critical (safety checks)
    // Medium priority: Regular control logic
    // Low priority: Logging, status updates
    
  2. Implement callback timeouts:

    c
    // Add expiration time to callbacks
    if (now - callback_start_time > CALLBACK_TIMEOUT_MS) {
        // Handle timeout
    }
    
  3. Use callback IDs for tracking:

    c
    typedef struct {
        uint32_t id;
        void (*func)(void* arg);
        void* arg;
        time_t created_at;
    } tracked_callback_t;
    

Error Handling Patterns

Callback errors can cascade through your system. Implement robust error handling:

c
void safe_callback_wrapper(void* (*func)(void*), void* arg) {
    void* result = NULL;
    try {
        result = func(arg);
        // Process result
    } catch (const std::exception& e) {
        // Log error and take recovery action
        log_error("Callback failed: %s", e.what());
        recover_from_callback_failure();
    }
}

// In your callback functions
void* my_callback(void* arg) {
    // Check preconditions
    if (!validate_conditions()) {
        throw std::runtime_error("Invalid conditions");
    }
    
    // Execute logic
    // ...
    
    return NULL;
}

Avoiding Callback Hell

For complex sequences, consider callback chains or state machines:

c
typedef enum {
    STATE_IDLE,
    STATE_READING_SENSOR,
    STATE_PROCESSING_DATA,
    STATE_ACTUATING,
    STATE_ERROR
} system_state_t;

system_state_t current_state = STATE_IDLE;

void state_machine_callback(void* arg) {
    switch (current_state) {
        case STATE_IDLE:
            // Transition to reading sensor
            add_callback(read_sensor_callback, NULL, 0);
            current_state = STATE_READING_SENSOR;
            break;
            
        case STATE_READING_SENSOR:
            // Process sensor data
            add_callback(process_data_callback, NULL, 0);
            current_state = STATE_PROCESSING_DATA;
            break;
            
        // ... other states
    }
}

Monitoring and Debugging

Implement callback monitoring to catch issues early:

c
void monitor_callback_system() {
    static uint32_t last_check = 0;
    uint32_t now = esp_timer_get_time()/1000;
    
    if (now - last_check > 10000) {  // Check every 10 seconds
        uint16_t queue_depth = (callback_head - callback_tail + MAX_CALLBACKS) % MAX_CALLBACKS;
        
        if (queue_depth > MAX_CALLBACKS/2) {
            log_warning("Callback queue filling up: %d/%d", queue_depth, MAX_CALLBACKS);
        }
        
        last_check = now;
    }
}

Multi-task Approach Considerations

While your event loop approach is suitable, there are compelling reasons to consider a multi-task architecture as your system grows.

Benefits of Task Separation

  1. Improved reliability: Sensor failures won’t block actuator control
  2. Better prioritization: Critical operations can run at higher priority
  3. Simpler component design: Each module can manage its own state
  4. Easier testing: Components can be tested independently

Recommended Task Structure

For your home automation robot, consider this task breakdown:

Task Priority Purpose Stack Size
Sensor Monitor High Read temperature, humidity, soil moisture 2048
Actuator Control High Control pumps, lights, fans 2048
Network Handler Medium Handle WiFi, MQTT, HTTP 4096
User Interface Medium Handle buttons, display, status 2048
Logging/Storage Low Write to SD card, remote logging 2048

Task Communication Patterns

Use FreeRTOS queues for inter-task communication:

c
// Queue definitions
QueueHandle_t sensor_queue;
QueueHandle_t actuator_queue;

// In sensor task
void sensor_task(void* pvParameters) {
    sensor_data_t data;
    while (1) {
        read_sensors(&data);
        xQueueSend(sensor_queue, &data, pdMS_TO_TICKS(100));
        vTaskDelay(pdMS_TO_TICKS(5000));  // Read every 5 seconds
    }
}

// In actuator task
void actuator_task(void* pvParameters) {
    sensor_data_t data;
    while (1) {
        if (xQueueReceive(sensor_queue, &data, pdMS_TO_TICKS(1000)) == pdTRUE) {
            process_sensor_data(&data);
            control_actuators(&data);
        }
    }
}

Resource Management with Multiple Tasks

When using multiple tasks, be mindful of:

  1. Memory usage: Each task has its own stack
  2. Priority inversion: Higher priority tasks waiting for resources held by lower ones
  3. Shared access: Protecting shared resources with mutexes
  4. Deadlock prevention: Avoiding circular dependencies

For example, when sharing a sensor between tasks:

c
// Shared resource protection
SemaphoreHandle_t sensor_mutex;

void safe_sensor_read(sensor_data_t* data) {
    if (xSemaphoreTake(sensor_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
        read_sensors(data);
        xSemaphoreGive(sensor_mutex);
    } else {
        log_error("Failed to acquire sensor mutex");
    }
}

Hybrid Architectures and Scaling

Your event loop can indeed serve as a central orchestrator in a multi-task environment, creating a hybrid architecture that combines the best of both approaches.

Central Orchestrator Pattern

Implement your main control logic in the event loop while delegating specific responsibilities to tasks:

c
// Main event loop (orchestrator)
void main_loop() {
    while (1) {
        // Process system events
        process_callbacks();
        
        // Receive messages from tasks
        system_message_t msg;
        if (xQueueReceive(system_queue, &msg, 0) == pdTRUE) {
            handle_message(&msg);
        }
        
        // Check system state
        monitor_system_health();
        
        // Small delay to prevent CPU hogging
        vTaskDelay(pdMS_TO_TICKS(10));
    }
}

// Task sends messages to orchestrator
void notify_orchestrator(event_type_t type, void* data) {
    system_message_t msg = {
        .type = type,
        .data = data,
        .timestamp = esp_timer_get_time()/1000
    };
    xQueueSend(system_queue, &msg, 0);
}

Scaling Strategy

As your system grows, consider this scaling path:

  1. Phase 1: Single event loop (your current approach)
  2. Phase 2: Event loop + network task
  3. Phase 3: Event loop orchestrator + specialized tasks for sensors/actuators
  4. Phase 4: Distributed architecture across ESP32 cores

ESP32 Dual-Core Considerations

The ESP32 has two cores that you can utilize:

  • Core 0: Run FreeRTOS and your main tasks
  • Core 1: Run your event loop or high-priority real-time operations

To assign tasks to specific cores:

c
void create_tasks() {
    xTaskCreatePinnedToCore(
        network_task,          // Function
        "Network",             // Name
        4096,                  // Stack size
        NULL,                  // Parameters
        2,                     // Priority
        &network_task_handle,  // Handle
        0                      // Core ID (0 or 1)
    );
    
    xTaskCreatePinnedToCore(
        control_task,
        "Control",
        2048,
        NULL,
        3,
        &control_task_handle,
        1  // Run on core 1
    );
}

State Management in Hybrid Systems

With multiple components, maintaining consistent state is crucial:

c
typedef struct {
    // Sensor readings
    float temperature;
    float humidity;
    float soil_moisture;
    
    // System status
    bool pump_active;
    bool light_active;
    bool fan_active;
    
    // Configuration
    target_config_t targets;
    
    // Timestamps
    uint32_t last_sensor_update;
    uint32_t last_control_update;
} system_state_t;

// Global state with mutex protection
system_state_t system_state;
SemaphoreHandle_t state_mutex;

void update_state(const system_state_t* new_state) {
    if (xSemaphoreTake(state_mutex, pdMS_TO_TICKS(100)) == pdTRUE) {
        // Update specific fields
        system_state.temperature = new_state->temperature;
        // ... other fields
        xSemaphoreGive(state_mutex);
    }
}

Final Recommendations

Based on your requirements and background, here are my recommendations:

  1. Stick with your event loop architecture for now - It’s well-suited for your millisecond-scale requirements and provides simpler development.

  2. Implement robust callback management:

    • Use bounded queues with overflow protection
    • Implement callback timeouts and error handling
    • Monitor queue depth to identify potential bottlenecks
  3. Consider task separation for these specific components:

    • Network operations (already planned)
    • Time-critical control loops
    • High-frequency sensor polling
  4. Plan a migration path to hybrid architecture:

    • Keep the event loop as central orchestrator
    • Offload specific responsibilities to tasks as complexity grows
    • Use FreeRTOS queues for inter-component communication
  5. Leverage both ESP32 cores if needed:

    • Run real-time control on one core
    • Handle networking and background tasks on the other

Your background in application development serves you well—many of the architectural patterns you’re familiar with translate well to embedded systems with some adaptation. The key is recognizing that while the concepts are similar, the execution environment has different constraints and requirements.

For immediate next steps, focus on implementing your callback management system with proper bounds checking and error handling. Then gradually introduce task separation for components that would benefit from isolation, such as your network operations.

As your system evolves, regularly assess whether the event loop approach remains the best fit or if a more task-oriented architecture would provide better reliability or performance for your specific needs.