## Summary **What is the goal of this PR?** Added overview and migration guide for ActivityManager changes in #1016. Thanks for the suggestion, @drbourbon! --- ### AI Usage While CrossPoint doesn't have restrictions on AI tools in contributing, please be transparent about their usage as it helps set the right context for reviewers. Did you use AI tools to help write this code? _**YES, fully written by Claude 4.6**_
21 KiB
Activity & ActivityManager Migration Guide
This document explains the refactoring from the original per-activity render task model to the centralized ActivityManager introduced in PR #1016. It covers the architectural differences, what changed for activity authors, and the FreeRTOS task and locking model that underpins the system.
Overview of Changes
| Aspect | Old Model | New Model |
|---|---|---|
| Render task | One per activity (8KB stack each) | Single shared task in ActivityManager |
| Render mutex | Per-activity renderingMutex |
Single global mutex in ActivityManager |
RenderLock |
Inner class of Activity |
Standalone class, acquires global mutex |
| Subactivities | ActivityWithSubactivity base class |
Activity stack managed by ActivityManager |
| Navigation | Free functions in main.cpp |
activityManager.goHome(), goToReader(), etc. |
| Subactivity results | Callback lambdas stored in parent | startActivityForResult() / setResult() / finish() |
requestUpdate() |
Notifies activity's own render task | Delegates to ActivityManager (immediate or deferred) |
Architecture
Old Model: Per-Activity Render Tasks
Each activity created its own FreeRTOS render task on entry and destroyed it on exit:
┌─────────────────────────────────────────────────────────┐
│ Main Task (Arduino loop) │
│ ┌───────────────────────────────────────────────────┐ │
│ │ currentActivity->loop() │ │
│ │ ├── handle input │ │
│ │ ├── update state (under RenderLock) │ │
│ │ └── requestUpdate() ──notify──► Render Task │ │
│ │ (per-activity)│ │
│ │ 8KB stack │ │
│ │ owns mutex │ │
│ └───────────────────────────────────────────────────┘ │
│ │
│ ActivityWithSubactivity: │
│ ┌──────────────┐ ┌──────────────┐ │
│ │ Parent │────►│ SubActivity │ │
│ │ (has render │ │ (has own │ │
│ │ task) │ │ render task) │ │
│ └──────────────┘ └──────────────┘ │
└─────────────────────────────────────────────────────────┘
Problems with this approach:
- 8KB per render task: Each activity allocated an 8KB FreeRTOS stack for its render task, even though only one renders at a time
- Dangerous deletion patterns:
exitActivity()+enterNewActivity()in callbacks led todelete thissituations where the caller was destroyed while its code was still on the stack - Subactivity coupling: Parents stored callbacks to child results, creating tight coupling and lifetime hazards
New Model: Centralized ActivityManager
A single ActivityManager owns the render task and manages an activity stack:
┌──────────────────────────────────────────────────────────┐
│ Main Task (Arduino loop) │
│ │
│ activityManager.loop() │
│ │ │
│ ├── currentActivity->loop() │
│ │ ├── handle input │
│ │ ├── update state (under RenderLock) │
│ │ └── requestUpdate() │
│ │ │
│ ├── process pending actions (Push / Pop / Replace) │
│ │ │
│ └── if requestedUpdate: ──notify──► Render Task │
│ (single, shared) │
│ 8KB stack │
│ global mutex │
│ │
│ Activity Stack: │
│ ┌──────────┬──────────┬──────────┐ ┌──────────┐ │
│ │ Home │ Settings │ Wifi │ │ Keyboard │ │
│ │ (stack) │ (stack) │ (stack) │ │ (current)│ │
│ └──────────┴──────────┴──────────┘ └──────────┘ │
│ stackActivities[] currentActivity │
└──────────────────────────────────────────────────────────┘
Migration Checklist
1. Change Base Class
If your activity extended ActivityWithSubactivity, change it to extend Activity:
// BEFORE
class MyActivity final : public ActivityWithSubactivity {
MyActivity(GfxRenderer& r, MappedInputManager& m, std::function<void()> goBack)
: ActivityWithSubactivity("MyActivity", r, m), goBack(goBack) {}
};
// AFTER
class MyActivity final : public Activity {
MyActivity(GfxRenderer& r, MappedInputManager& m)
: Activity("MyActivity", r, m) {}
};
Note that navigation callbacks like goBack are no longer stored — use finish() or activityManager.goHome() instead.
2. Replace Navigation Functions
The free functions exitActivity() / enterNewActivity() in main.cpp are gone. Use ActivityManager methods:
// BEFORE (in main.cpp or via stored callbacks)
exitActivity();
enterNewActivity(new SettingsActivity(renderer, mappedInput, onGoHome));
// AFTER (from any Activity method)
activityManager.goToSettings();
// or for arbitrary navigation:
activityManager.replaceActivity(std::make_unique<MyActivity>(renderer, mappedInput));
replaceActivity() destroys the current activity and clears the stack. Use it for top-level navigation (home, reader, settings, etc.).
3. Replace Subactivity Pattern
The enterNewActivity() / exitActivity() subactivity pattern is replaced by a stack with typed results:
// BEFORE
void MyActivity::launchWifi() {
enterNewActivity(new WifiSelectionActivity(renderer, mappedInput,
[this](bool connected) { onWifiDone(connected); }));
}
// Child calls: onComplete(true); // triggers callback, which may call exitActivity()
// AFTER
void MyActivity::launchWifi() {
startActivityForResult(
std::make_unique<WifiSelectionActivity>(renderer, mappedInput),
[this](const ActivityResult& result) {
if (result.isCancelled) return;
auto& wifi = std::get<WifiResult>(result.data);
onWifiDone(wifi.connected);
});
}
// Child calls:
// setResult(WifiResult{.connected = true, .ssid = ssid});
// finish();
Key differences:
startActivityForResult()pushes the current activity onto the stack and launches the childsetResult()stores a typed result on the child activityfinish()signals the manager to pop the child, call the result handler, and resume the parent- The parent is never deleted during this process — it's safely stored on the stack
4. Update render() Signature
The RenderLock type changed from Activity::RenderLock (inner class) to standalone RenderLock:
// BEFORE
void render(Activity::RenderLock&&) override;
// AFTER
void render(RenderLock&&) override;
Include RenderLock.h if not transitively included via Activity.h.
5. Update onEnter() / onExit()
Activities no longer create or destroy render tasks:
// BEFORE
void MyActivity::onEnter() {
Activity::onEnter(); // created render task + logged
// ... allocate resources
requestUpdate();
}
void MyActivity::onExit() {
// ... free resources
Activity::onExit(); // acquired RenderLock, deleted render task
}
// AFTER
void MyActivity::onEnter() {
Activity::onEnter(); // just logs
// ... allocate resources
requestUpdate();
}
void MyActivity::onExit() {
// ... free resources
Activity::onExit(); // just logs
}
The render task lifecycle is handled entirely by ActivityManager::begin().
6. Update requestUpdate() Calls
The signature changed to accept an immediate flag:
// BEFORE
void requestUpdate(); // always immediate notification to per-activity render task
// AFTER
void requestUpdate(bool immediate = false);
// immediate=false (default): deferred until end of current loop iteration
// immediate=true: sends notification to render task right away
When to use immediate: Almost never. Deferred updates are batched — if loop() triggers multiple state changes that each call requestUpdate(), only one render happens. Use immediate only when you need the render to start before the current function returns (e.g., before a blocking network call).
requestUpdateAndWait(): Blocks the calling task until the render completes. Use sparingly — it's designed for cases where you need the screen to reflect new state before proceeding (e.g., showing "Checking for update..." before calling a network API).
7. Remove Stored Navigation Callbacks
Old activities often stored std::function callbacks for navigation:
// BEFORE
class SettingsActivity : public ActivityWithSubactivity {
const std::function<void()> goBack; // stored callback
const std::function<void()> goHome; // stored callback
public:
SettingsActivity(GfxRenderer& r, MappedInputManager& m,
std::function<void()> goBack, std::function<void()> goHome)
: ActivityWithSubactivity("Settings", r, m), goBack(goBack), goHome(goHome) {}
};
// AFTER
class SettingsActivity : public Activity {
public:
SettingsActivity(GfxRenderer& r, MappedInputManager& m)
: Activity("Settings", r, m) {}
// Use finish() to go back, activityManager.goHome() to go home
};
This removes std::function overhead (~2-4KB per unique signature) and eliminates lifetime risks from captured this pointers.
Technical Details
FreeRTOS Task Model
The firmware runs on an ESP32-C3, a single-core RISC-V microcontroller. FreeRTOS provides cooperative and preemptive multitasking on this single core — only one task executes at any moment, and the scheduler switches between tasks at yield points (blocking calls, vTaskDelay, taskYIELD) or when a tick interrupt promotes a higher-priority task.
There are two tasks relevant to the activity system:
┌──────────────────────┐ ┌──────────────────────────┐
│ Main Task │ │ Render Task │
│ (Arduino loop) │ │ (ActivityManager-owned) │
│ Priority: 1 │ │ Priority: 1 │
│ │ │ │
│ Runs: │ │ Runs: │
│ - gpio.update() │ │ - ulTaskNotifyTake() │
│ - activity->loop() │ │ (blocks until notified)│
│ - pending actions │ │ - RenderLock (mutex) │
│ - sleep/power mgmt │ │ - activity->render() │
│ - requestUpdate →────┼─────┼─► xTaskNotify() │
│ (end of loop) │ │ │
└──────────────────────┘ └──────────────────────────┘
Both tasks run at priority 1. Since the ESP32-C3 is single-core, they alternate execution: the main task runs loop(), then at the end of the loop iteration, notifies the render task if an update was requested. The render task wakes, acquires the mutex, calls render(), releases the mutex, and blocks again.
Do not use xTaskCreate inside activities. If you have a use case that seems to require a background task, open a discussion to propose a lifecycle-aware Worker abstraction first.
The Render Mutex and RenderLock
A single FreeRTOS mutex (renderingMutex) protects shared state between loop() and render(). Since these run on different tasks, any state read by render() and written by loop() must be guarded.
RenderLock is an RAII wrapper:
// Standalone class (not tied to any specific activity)
class RenderLock {
bool isLocked = false;
public:
explicit RenderLock(); // acquires activityManager.renderingMutex
explicit RenderLock(Activity&); // same — Activity& param kept for compatibility
~RenderLock(); // releases mutex if still held
void unlock(); // early release
};
Usage patterns:
// In loop(): protect state mutations that render() reads
void MyActivity::loop() {
if (somethingChanged) {
RenderLock lock;
state = newState; // safe — render() can't run while lock is held
}
requestUpdate(); // trigger render after lock is released
}
// In render(): lock is passed in, held for duration of render
void MyActivity::render(RenderLock&&) {
// Lock is held — safe to read shared state
renderer.clearScreen();
renderer.drawText(..., stateString, ...);
renderer.displayBuffer();
// Lock released when RenderLock destructor runs
}
Critical rule: Never call requestUpdateAndWait() while holding a RenderLock. The render task needs the mutex to call render(), so holding it while waiting for the render to complete is a deadlock:
Main Task Render Task
────────── ───────────
RenderLock lock; (blocked on mutex)
requestUpdateAndWait();
→ notify render task
→ block waiting for
render to complete → wakes up
→ tries to acquire mutex
→ DEADLOCK: main holds mutex,
waits for render; render
waits for mutex
requestUpdate() vs requestUpdateAndWait()
requestUpdate(false) requestUpdate(true)
───────────────── ─────────────────
Sets flag only. Notifies render task
Render happens after immediately.
loop() returns and Render may start
ActivityManager checks before the calling
the flag. function returns.
(Does NOT wait for
render to complete.)
requestUpdateAndWait()
──────────────────────
Notifies render task AND
blocks calling task until
render is done. Uses
FreeRTOS direct-to-task
notification on the
caller's task handle.
requestUpdateAndWait() flow in detail:
Calling Task Render Task
──────────── ───────────
requestUpdateAndWait()
├─ assert: not render task
├─ assert: not holding RenderLock
├─ store waitingTaskHandle
├─ xTaskNotify(renderTask) → wakes render task
└─ ulTaskNotifyTake() ─┐
(blocked) │ RenderLock lock;
│ activity->render();
│ // render complete
│ taskENTER_CRITICAL
│ waiter = waitingTaskHandle
│ waitingTaskHandle = nullptr
│ taskEXIT_CRITICAL
│ xTaskNotify(waiter) ───┐
│ │
┌──────────────────────┘ │
│ (woken by notification) ◄────────────────────────┘
└─ return
Activity Lifecycle Under ActivityManager
activityManager.replaceActivity(make_unique<MyActivity>(...))
│
▼
╔═══════════════════════════════════════════════════╗
║ pendingAction = Replace ║
║ pendingActivity = MyActivity ║
╚═══════════════════════════════════════════════════╝
│
▼ (next loop iteration)
ActivityManager::loop()
│
├── currentActivity->loop() // old activity's last loop
│
├── process pending action:
│ ├── RenderLock lock;
│ ├── oldActivity->onExit() // cleanup under lock
│ ├── delete oldActivity
│ ├── clear stack
│ ├── currentActivity = MyActivity
│ ├── lock.unlock()
│ └── MyActivity->onEnter() // init new activity
│
└── if requestedUpdate:
└── notify render task
For push/pop (subactivity) navigation:
Parent calls: startActivityForResult(make_unique<Child>(...), handler)
│
▼
╔══════════════════════════════════════╗
║ pendingAction = Push ║
║ pendingActivity = Child ║
║ parent->resultHandler = handler ║
╚══════════════════════════════════════╝
│
▼ (next loop iteration)
├── Parent moved to stackActivities[]
├── currentActivity = Child
└── Child->onEnter()
... child runs ...
Child calls: setResult(MyResult{...}); finish();
│
▼
╔══════════════════════════════════════╗
║ pendingAction = Pop ║
║ child->result = MyResult{...} ║
╚══════════════════════════════════════╝
│
▼ (next loop iteration)
├── result = child->result
├── Child->onExit(); delete Child
├── currentActivity = Parent (popped from stack)
├── Parent->resultHandler(result)
└── requestUpdate() // automatic re-render for parent
Common Pitfalls
Calling finish() and continuing to access this: finish() sets pendingAction = Pop but does not immediately destroy the activity. The activity is destroyed on the next ActivityManager::loop() iteration. It's safe to access member variables after finish() within the same function, but don't rely on the activity surviving past the current loop() call.
Modifying shared state without RenderLock: If render() reads a variable and loop() writes it, the write must be under a RenderLock. Without it, render() could see a half-written value (e.g., a partially updated string or struct).
Creating background tasks that outlive the activity: Any FreeRTOS task created in onEnter() must be deleted in onExit() before the activity is destroyed. The ActivityManager does not track or clean up background tasks.
Holding RenderLock across blocking calls: The render task is blocked on the mutex while you hold the lock. Keep critical sections short — acquire, mutate state, release, then do blocking work.
// WRONG — blocks render for the entire network call
void MyActivity::doNetworkStuff() {
RenderLock lock;
state = LOADING;
auto result = http.get(url); // blocks for seconds with lock held
state = DONE;
}
// CORRECT — release lock before blocking
void MyActivity::doNetworkStuff() {
{
RenderLock lock;
state = LOADING;
}
requestUpdate(true); // render "Loading..." immediately, before we block
auto result = http.get(url); // lock is not held
{
RenderLock lock;
state = DONE;
}
requestUpdate();
}