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
ForexDataServiceindependently 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:
- Clone the SDK
Start with the CloudMouse SDK boilerplate - Study the Forex App
The complete source code demonstrates all these patterns - Implement IAppOrchestrator
Create your main app class and implement the interface - Add custom events
Define your event types with the +100 offset pattern - Build your display manager
Create LVGL screens and register the callback - 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:
- Discord: Join the conversation
- SDK Documentation: Complete API reference
- Example Projects: Browse examples
- GitHub: Contribute and explore
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.