The App Orchestrator Pattern

Learn how CloudMouse SDK's App Orchestrator pattern enables clean separation between framework and application code, using interface-based design and custom events for production-ready embedded applications

Building Production-Ready Applications on CloudMouse SDK

When we set out to build the CloudMouse Forex Tracker, we faced a challenge that every embedded systems developer encounters: how do you build complex applications on top of a hardware abstraction layer without creating a tangled mess of dependencies?

Today, we're sharing the architectural patterns that emerged from this project - patterns that make building CloudMouse applications clean, maintainable, and genuinely enjoyable.

 


 

The Problem: Separation of Concerns in Embedded Systems

CloudMouse SDK provides a complete dual-core framework for ESP32-based devices. It handles display rendering on Core 1, manages WiFi and networking on Core 0, provides hardware abstractions for encoders and LEDs, and maintains a robust event-driven architecture. It's powerful, but here's the question: how do you build your application on top without modifying the SDK itself?

The traditional approach - forking the SDK and hacking your application logic directly into the core - creates maintenance nightmares. SDK updates become painful, testing becomes harder, and your application logic gets entangled with system code. We needed something better.

 

The Solution: Interface-Based App Orchestration

We implemented what we call the "App Orchestrator Pattern" - a clean separation layer that lets your application receive events from the SDK without tight coupling. At its heart is a simple interface that any application can implement:

namespace CloudMouse
{
  /**
   * Interface for custom App orchestrators
   * Any app that wants to receive SDK events must implement this
   */
  class IAppOrchestrator
  {
  public:
    virtual ~IAppOrchestrator() = default;
    
    /**
     * Called once during system initialization
     * Perfect place to set up your app state, load preferences, etc.
     */
    virtual void initialize() = 0;
    
    /**
     * App Orchestrator update loop
     */
    virtual void update() = 0;

    /**
     * Process SDK events
     * Called from Core 0 when system events occur
     */
    virtual void processSDKEvent(const CloudMouse::Event& event) = 0;
  };
}

That's it. Three methods. Your application implements this interface, and suddenly you have a clean contract with the SDK. Let's see how this works in practice with our Forex Tracker.

 

Real-World Implementation: The Forex Tracker

The CloudMouse Forex Tracker is a real-time stock market monitoring application. It polls APIs, manages alert thresholds, handles configuration via a web interface, and renders a beautiful LVGL-powered UI. Here's how we structure it:

// ForexApp.h
namespace ForexExample
{
  class ForexApp : public CloudMouse::IAppOrchestrator
  {
  public:
    // Implement required interface methods
    void initialize() override;
    void processSDKEvent(const CloudMouse::Event& event) override;

  private:
    // App-specific components
    ForexDataService dataService;
    ForexPreferences prefs;
    ForexConfigServer configServer;
    
    // State management
    ForexAppState currentState;
    
    // Helper methods
    void handleWiFiConnected();
    void startDataPolling();
    bool isMarketOpen();
  };
}

 

Initialization Flow

When the system boots, the SDK calls your initialize() method. This is where you set up your application state:

void ForexApp::initialize()
{
    Serial.println("? Initializing ForexApp...");
    
    // Load persistent configuration from NVS
    prefs.loadConfig();
    
    // Check if user has configured the app
    if (!prefs.hasAPIKey() || prefs.getSymbolCount() == 0) {
        currentState = ForexAppState::CONFIG_NEEDED;
        // We'll show setup instructions on the display
        return;
    }
    
    // Initialize data service with API credentials
    dataService.setAPIKey(prefs.getAPIKey());
    dataService.setSymbols(prefs.getSymbols(), prefs.getSymbolCount());
    
    currentState = ForexAppState::READY;
    Serial.println("✅ ForexApp ready!");
}

 

Event Handling

The SDK sends events through processSDKEvent(). This is where you respond to system state changes:

void ForexApp::processSDKEvent(const CloudMouse::Event& event)
{
    switch (event.type) {
        case CloudMouse::EventType::WIFI_CONNECTED:
            handleWiFiConnected();
            break;
        
        case CloudMouse::EventType::WIFI_DISCONNECTED:
            Serial.println("? WiFi lost - pausing data polling");
            currentState = ForexAppState::WIFI_LOST;
            break;
        
        case CloudMouse::EventType::ENCODER_ROTATED:
            // User is navigating - send to display manager
            notifyDisplayManager(event);
            break;
        
        case CloudMouse::EventType::ENCODER_CLICKED:
            // User clicked - handle based on current state
            handleUserClick();
            break;
    }
}

void ForexApp::handleWiFiConnected()
{
    Serial.println("? WiFi connected - starting services");
    
    // Start web configuration server
    configServer.start();
    
    // Check market hours and start polling if appropriate
    if (isMarketOpen()) {
        currentState = ForexAppState::POLLING_ACTIVE;
        startDataPolling();
    } else {
        currentState = ForexAppState::POLLING_PAUSED;
        Serial.println("? Market closed - displaying cached data");
    }
}

 

The Dual Pattern: Core 0 and Core 1 Communication

Here's where it gets interesting. CloudMouse uses a dual-core architecture: Core 0 handles networking and business logic, while Core 1 renders the UI at a constant 30 FPS. These cores need to communicate, but they operate independently to maintain smooth performance.

We use two different patterns for optimal results:

Pattern 1: Interface for Core 0 (App Orchestrator)

As we've seen, your main application logic on Core 0 uses the IAppOrchestrator interface. This is perfect for:

  • Complex state management
  • Multiple lifecycle methods
  • Type-safe polymorphism
  • Business logic that can tolerate slight latency

 

Pattern 2: Callbacks for Core 1 (Display Manager)

For the display manager on Core 1, we use a different approach: direct callbacks. Why? Because Core 1 needs to be blazingly fast. It's rendering at 30 FPS, and every millisecond counts.

// ForexDisplayManager.h
class ForexDisplayManager
{
public:
    void init();
    
    // Static callback for SDK DisplayManager
    static void handleDisplayCallback(const CloudMouse::Event& event);
    
private:
    static ForexDisplayManager* instance;
    void onDisplayEvent(const CloudMouse::Event& event);
    void updateSymbolList();
    void renderDetailScreen();
};

// ForexDisplayManager.cpp
void ForexDisplayManager::init()
{
    // Set up LVGL screens, styles, etc.
    setupUI();
    
    // Register callback with SDK DisplayManager
    // This receives ALL events that DisplayManager processes
    CloudMouse::Core::instance().getDisplay()->registerAppCallback(
        &ForexDisplayManager::handleDisplayCallback);
}

void ForexDisplayManager::handleDisplayCallback(const CloudMouse::Event& event)
{
    if (instance) {
        instance->onDisplayEvent(event);
    }
}

void ForexDisplayManager::onDisplayEvent(const CloudMouse::Event& event)
{
    // Handle events on Core 1 - MUST BE FAST!
    switch (event.type) {
        case CloudMouse::EventType::ENCODER_ROTATED:
            // Update selected item in list
            scrollSymbolList(event.value);
            break;
        
        case CloudMouse::EventType::ENCODER_CLICKED:
            // Show detail screen for selected symbol
            showDetailScreen();
            break;
    }
}

See the reference documentation for DisplayManager for more details on callback registration.

 

Performance Considerations

The callback pattern on Core 1 is crucial for performance. Here's what you should and shouldn't do:

// ✅ GOOD - Fast UI update on Core 1
void ForexDisplayManager::onDisplayEvent(const Event& event)
{
    lv_label_set_text(priceLabel, "$174.52");
    lv_obj_set_style_bg_color(symbolCard, lv_color_hex(0x2ed573), 0);
}

// ❌ BAD - Slow network operation on Core 1
void ForexDisplayManager::onDisplayEvent(const Event& event)
{
    HTTPClient http;
    http.begin("https://api.twelvedata.com/quote?symbol=AAPL");
    String response = http.getString();  // NEVER do this on Core 1!
}

// ✅ GOOD - Delegate to Core 0 via EventBus
void ForexDisplayManager::onDisplayEvent(const Event& event)
{
    // Send custom event to Core 0 for processing
    ForexEventData fetchRequest;
    fetchRequest.type = ForexEventType::FETCH_SYMBOL_DATA;
    strcpy(fetchRequest.stringData, "AAPL");
    
    // Core 0 handles the network call
    CloudMouse::EventBus::instance().sendToMain(toSDKEvent(fetchRequest));
}

 

Custom Events: Extending the SDK Without Modifying It

The SDK defines its own event types (WiFi connected, encoder rotated, etc.), but what about your application-specific events? You need a way to send custom events between your components without colliding with SDK events.

We solved this with an elegant pattern: event type offsets. The SDK uses event types 0-99. Your application uses 100+. Three simple helper functions bridge the gap:

// Your custom event types
enum class ForexEventType {
    FOREX_DISPLAY_BOOTSTRAP = 0,   // Will become 100 in SDK
    FOREX_SHOW_LIST = 1,           // Will become 101 in SDK
    FOREX_UPDATE_SYMBOL = 2,       // Will become 102 in SDK
    FOREX_DATA_REFRESHED = 3,      // Will become 103 in SDK
    FOREX_ALERT_GAIN = 4,          // Will become 104 in SDK
    FOREX_ALERT_LOSS = 5,          // Will become 105 in SDK
};

// 1. Convert your event to SDK event (adds +100 offset)
inline CloudMouse::Event toSDKEvent(const ForexEventData& forexEvent)
{
    CloudMouse::Event sdkEvent;
    sdkEvent.type = static_cast(
        static_cast(forexEvent.type) + 100);
    sdkEvent.value = forexEvent.value;
    strncpy(sdkEvent.stringData, forexEvent.stringData, 
            sizeof(sdkEvent.stringData) - 1);
    return sdkEvent;
}

// 2. Check if an SDK event is actually your custom event
inline bool isForexEvent(const CloudMouse::Event& sdkEvent)
{
    return static_cast(sdkEvent.type) >= 100;
}

// 3. Convert SDK event back to your event (removes +100 offset)
inline ForexEventData toForexEvent(const CloudMouse::Event& sdkEvent)
{
    ForexEventData forexEvent;
    forexEvent.type = static_cast(
        static_cast(sdkEvent.type) - 100);
    forexEvent.value = sdkEvent.value;
    strncpy(forexEvent.stringData, sdkEvent.stringData,
            sizeof(forexEvent.stringData) - 1);
    return forexEvent;
}

 

Using Custom Events in Practice

Let's trace a complete flow: the data service on Core 0 detects a price alert and needs to update the UI on Core 1.

// Step 1: Core 0 - Data service detects alert
void ForexDataService::checkAlerts(const char* symbol, float changePercent)
{
    float gainThreshold = prefs.getGainThreshold(symbol);
    
    if (changePercent >= gainThreshold) {
        Serial.printf("? GAIN ALERT: %s at %.2f%%\n", symbol, changePercent);
        
        // Create custom event
        ForexEventData alertEvent;
        alertEvent.type = ForexEventType::FOREX_ALERT_GAIN;
        strcpy(alertEvent.stringData, symbol);
        alertEvent.value = changePercent;
        
        // Convert to SDK event and send to Core 1
        CloudMouse::EventBus::instance().sendToUI(toSDKEvent(alertEvent));
        
        // Also trigger hardware feedback
        triggerLEDFlash(0, 255, 0);  // Green flash
        triggerBuzzerPattern(GAIN_PATTERN);
    }
}

// Step 2: Core 1 - Display manager receives event
void ForexDisplayManager::onDisplayEvent(const CloudMouse::Event& event)
{
    // Check if this is a custom Forex event
    if (isForexEvent(event)) {
        processForexEvent(toForexEvent(event));
        return;
    }
    
    // Otherwise handle SDK events
    // ...
}

void ForexDisplayManager::processForexEvent(const ForexEventData& event)
{
    switch (event.type) {
        case ForexEventType::FOREX_ALERT_GAIN:
            // Update UI to show alert
            showAlertIcon(event.stringData);
            highlightSymbol(event.stringData, lv_color_hex(0x00ff00));
            
            Serial.printf("? UI updated for gain alert: %s\n", 
                         event.stringData);
            break;
            
        case ForexEventType::FOREX_UPDATE_SYMBOL:
            // Refresh symbol display with new price
            updateSymbolPrice(event.stringData, event.value);
            break;
    }
}

How beauty this pattern is, isn't it? Your events travel through the SDK's proven FreeRTOS queue system (see EventBus documentation), maintaining thread-safety, but the SDK never needs to know about your custom event types. It's namespace isolation at its finest.

 

Complete Architecture Diagram

┌─────────────────────────────────────────────────────────────────┐
│ Core 1 (UI Thread - 30 FPS)                                     │
│                                                                 │
│  SDK DisplayManager ─[callback]→ ForexDisplayManager            │
│         │                              │                        │
│         │                              ├─ LVGL UI Updates       │
│         │                              ├─ Screen Management     │
│         │                              └─ Encoder Navigation    │
│         │                                                       │
│         └──────────[EventBus.sendToMain()]─────────┐            │
└─────────────────────────────────────────────────────┼───────────┘
                                                      │
                                          [FreeRTOS Queue]
                                                      │
┌─────────────────────────────────────────────────────┼───────────┐
│ Core 0 (Main Thread - 20 Hz)                        │           │
│                                                     ↓           │
│  SDK Core ─[IAppOrchestrator]→ ForexApp                         │
│                                    │                            │
│                                    ├─ State Machine             │
│                                    ├─ WiFi Management           │
│                                    ├─ Market Hours Detection    │
│                                    │                            │
│                                    ├→ ForexDataService          │
│                                    │    ├─ API Polling          │
│                                    │    ├─ Alert Detection      │
│                                    │    └─ Cache Management     │
│                                    │                            │
│                                    ├→ ForexConfigServer         │
│                                    │    └─ Web Interface        │
│                                    │                            │
│                                    └→ ForexPreferences          │
│                                         └─ NVS Storage          │
│                                                                 │
│         ┌──────────[EventBus.sendToUI()]──────────┘             │
└─────────┼───────────────────────────────────────────────────────┘
          │
[FreeRTOS Queue]
          │
          └─────────→ Back to Core 1
  

 

Registration: Putting It All Together

In your main sketch, registration is straightforward and declarative:

// main.cpp (or your .ino file)
#include "lib/core/Core.h"
#include "lib/forex/ForexApp.h"

using namespace CloudMouse;
using namespace ForexExample;

// Hardware components
EncoderManager encoder;
DisplayManager display;
WiFiManager wifi;
WebServerManager webServer(wifi);
LEDManager ledManager;

// Your application
ForexApp forexApp;

void setup()
{
    Serial.begin(115200);
    
    // Initialize hardware
    encoder.init();
    display.init();
    ledManager.init();
    
    // Register hardware with SDK Core
    Core::instance().setEncoder(&encoder);
    Core::instance().setDisplay(&display);
    Core::instance().setWiFi(&wifi);
    Core::instance().setWebServer(&webServer);
    Core::instance().setLEDManager(&ledManager);
    
    // Register your app - SDK will call forexApp.initialize()
    Core::instance().setAppOrchestrator(&forexApp);
    
    // Start dual-core operation
    Core::instance().startUITask();     // Launches Core 1
    Core::instance().initialize();      // Starts Core 0 and calls app.initialize()
}

void loop()
{
    // SDK coordination loop (20 Hz on Core 0)
    Core::instance().coordinationLoop();
    delay(50);
}

That's it. Clean, declarative, and maintainable. Your application is completely decoupled from the SDK internals.

 

Real-World Benefits: The Forex Tracker in Production

This architecture has proven itself in the Forex Tracker's real-world operation:

  • Maintainability: When we updated the SDK to improve WiFi stability, the Forex App required zero changes
  • Performance: Consistent 30 FPS UI rendering even during network operations
  • Testability: We can unit test ForexDataService independently from hardware
  • Reusability: The same pattern works for weather stations, industrial monitors, or any other application

 

Performance Metrics

  • 30 FPS UI rendering (Core 1) - never drops frames
  • Sub-2-second screen transitions with LVGL animations
  • Sub-5-second data refresh for 10 symbols via API
  • Sub-100ms encoder response time
  • Sub-1ms cross-core event latency via FreeRTOS queues

 

Getting Started: Build Your Own Application

Ready to build your own CloudMouse application? Here's your roadmap:

  1. Clone the SDK
    Start with the CloudMouse SDK boilerplate
  2. Study the Forex App
    The complete source code demonstrates all these patterns
  3. Implement IAppOrchestrator
    Create your main app class and implement the interface
  4. Add custom events
    Define your event types with the +100 offset pattern
  5. Build your display manager
    Create LVGL screens and register the callback
  6. Register and run
    Hook everything together in your main sketch

 

Pattern Variations: Beyond Forex

While we developed these patterns for a financial tracker, they're universally applicable. Here are some other applications we're seeing in the community:

 

Industrial Process Monitor

class ProcessMonitor : public IAppOrchestrator
{
    void initialize() override {
        // Connect to Modbus sensors
        modbus.begin(9600);
        sensors.loadCalibration();
    }
    
    void processSDKEvent(const Event& event) override {
        if (event.type == EventType::ENCODER_CLICKED) {
            // Cycle through sensor views
            nextSensorView();
        }
    }
    
private:
    void pollSensors() {
        float temp = modbus.readTemperature();
        float pressure = modbus.readPressure();
        
        // Check thresholds and send alerts
        if (temp > TEMP_ALERT_THRESHOLD) {
            sendAlert(AlertType::TEMPERATURE_HIGH, temp);
        }
    }
};

 

Smart Home Controller

class SmartHomeHub : public IAppOrchestrator
{
    void initialize() override {
        // Connect to MQTT broker
        mqtt.connect("home-assistant.local");
        mqtt.subscribe("home/#");
    }
    
    void processSDKEvent(const Event& event) override {
        switch (event.type) {
            case EventType::ENCODER_ROTATED:
                // Adjust light brightness
                adjustBrightness(event.value);
                break;
            
            case EventType::ENCODER_LONG_PRESS:
                // Toggle scene
                toggleScene();
                break;
        }
    }
};

 

Environmental Data Logger

class WeatherStation : public IAppOrchestrator
{
    void initialize() override {
        // Initialize sensors
        bme280.begin();
        anemometer.begin();
        
        // Set up data logging
        sdCard.begin();
        startLogging();
    }
    
    void processSDKEvent(const Event& event) override {
        if (event.type == EventType::WIFI_CONNECTED) {
            // Upload buffered data to cloud
            syncDataToCloud();
        }
    }
    
private:
    void logData() {
        WeatherData data;
        data.temperature = bme280.readTemperature();
        data.humidity = bme280.readHumidity();
        data.windSpeed = anemometer.read();
        
        sdCard.append(data);
        updateDisplay(data);
    }
};

 

Community and Resources

The CloudMouse community is actively building applications using these patterns. Join us:

 

Conclusion: Clean Architecture for Embedded Systems

The App Orchestrator pattern and custom event system prove that embedded systems don't have to sacrifice clean architecture for performance. By combining interface-based polymorphism with performance-critical callbacks, and extending the event system through clever offsetting, we've created a framework that's both powerful and pleasant to use.

The Forex Tracker demonstrates this in production: complex business logic, real-time data processing, beautiful UI, all running smoothly on a ESP32 module. The separation between SDK and application code means we can update either independently, test components in isolation, and build new applications by implementing a simple interface.

We're excited to see what you build with CloudMouse. Whether it's financial tracking, industrial monitoring, home automation, or something entirely new, these architectural patterns provide a solid foundation. The SDK handles the hard parts - dual-core coordination, hardware abstraction, event management - so you can focus on making your application awesome.

Let's go build something incredible!

And when you have a GREAT idea, share it with the community, we can't wait to see what you create!


Interested in CloudMouse hardware? Visit cloudmouse.co to learn more about our ESP32-based development platform designed for professional embedded applications.