Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions docs/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -305,7 +305,7 @@ sandbox.clearPersistedApp(); // wipe the saved app from the persistent
- Returns `false` on compile error, runtime error, no app loaded, or during a `deferAppLoads` window. Deferred chunks are **dropped** with a log, never stashed.
- Never persisted. NVS keeps the base app; senders re-send chunks after a reboot.

`suspendApp` pauses the Lua tick (`on_tick` and event dispatch) without unloading the app — Courier and extension `update()` keep running. While suspended, drivers receive `onAppRunning(false)` so the status display is freed for direct text (e.g. a "Listening" overlay via `SystemDisplay::displayText()`); `resumeApp` reverses this with `onAppRunning(true)`. Both are no-ops when no app is loaded, and repeated calls don't re-notify. `isAppRunning()` stays `true` while suspended — suspension is a separate axis queried via `isAppSuspended()`. Events arriving while suspended are queued, not dropped (though a long suspend can overflow the 8-slot ring, losing the oldest), and `loadApp` always clears suspension.
`suspendApp` pauses the Lua tick (`on_tick` and event dispatch) without unloading the app — Courier and extension `update()` keep running. While suspended, every declared extension receives `onAppRunning(false)` so the status display is freed for direct text (e.g. a "Listening" overlay via `SystemDisplay::displayText()`); `resumeApp` reverses this with `onAppRunning(true)`. Both are no-ops when no app is loaded, and repeated calls don't re-notify. `isAppRunning()` stays `true` while suspended — suspension is a separate axis queried via `isAppSuspended()`. Events arriving while suspended are queued, not dropped (though a long suspend can overflow the 8-slot ring, losing the oldest), and `loadApp` always clears suspension.

`onSystemButtonHold(cb)` turns the `systemButton` role slot into a runtime hold gesture: `cb(true)` fires once when the button is held past the threshold (~500 ms), `cb(false)` on release. It is inert only during the boot countdown (where a hold forgets the persisted app), so it also fires in `Ready` with no app loaded. Combined with an [Overlay](#residentoverlay) and the [SystemMic](#residentsystemmic) streaming pump it composes into push-to-talk with no per-device boilerplate — see the `m5stick-voice` example.

Expand Down Expand Up @@ -376,6 +376,7 @@ Include with:
| `begin()` | no-op | Hardware / module init — called once by `Sandbox::setup()` |
| `update()` | no-op | Per-loop tick at full main-loop rate (not Lua's 10 FPS) |
| `onAppReset()` | no-op | Called before each new app is compiled |
| `onAppRunning(bool running)` | no-op | Called when an app starts or resumes (`true`), and when it stops or is suspended (`false`) |

### Idempotent early init

Expand All @@ -401,9 +402,9 @@ Include with:

| Method | Default | Description |
|--------|---------|-------------|
| `onAppRunning(bool running)` | no-op | Called when an app starts (`true`) or stops (`false`) |
| `sendEvent(name, fields, count)` (protected) | — | Push a driver-generated event into Lua |

All `Extension` methods (`name`, `registerModule`, `begin`, `update`, `onAppReset`) are inherited unchanged.
All `Extension` methods (`name`, `registerModule`, `begin`, `update`, `onAppReset`, `onAppRunning`) are inherited unchanged. `onAppRunning` lived here until 0.8.6; it is on `Extension` now, because the extension that most needs it — a graphics module — registers no hardware.

### `sendEvent` (protected)

Expand Down Expand Up @@ -464,7 +465,7 @@ This matters because `LuaModule::method<>` casts the stored `Extension*` pointer
### When to use Extension vs Driver

- Use `Extension` when you only register a Lua module (read sensors, control outputs from Lua, but no driver-generated events).
- Use `Driver` when your extension needs to push events into Lua (`sendEvent`) or respond to app start/stop (`onAppRunning`).
- Use `Driver` when your extension needs to push events into Lua (`sendEvent`). App start/stop (`onAppRunning`) reaches every declared extension, Driver or not.

### Driver lifecycle and update cadence

Expand Down
7 changes: 7 additions & 0 deletions docs/changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Changelog

## v0.8.6-dev

- Fixed: an overlay claim on a dual-role surface left its last frame on the glass after it released, under a live LVGL app. The board suppresses its blit door while a claim is held, but LVGL went on rendering and getting `lv_display_flush_ready`, so it believed the dropped pixels had reached the panel; when the claim lifted nothing was dirty and only the widgets that happened to change repainted over the overlay. `LvglModule` now stands its displays down while the app is suspended and invalidates them whole when it resumes.
- `Extension::onAppRunning(bool)`: the hook moves up from `Driver`, and `Sandbox` now calls it on every declared extension rather than only the drivers among them. A module-less extension — a graphics module — is exactly the one that needs to know the app stopped. Existing `Driver` overrides are unaffected.

---

## v0.8.5

- `LvglModule::setFontResolver(fn)`: a board's own fonts, installed once through luavgl's font extension when the sandbox sets up its state (set it before `setup()`). `lvgl.Font` tries built-ins first, then the resolver, then the next name in a comma list.
Expand Down
2 changes: 1 addition & 1 deletion idf_component.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
version: "0.8.5"
version: "0.8.6-dev"
description: "Sandbox with hardware IO and hot reload for ESP32 devices"
url: "https://github.com/inanimate-tech/resident"

Expand Down
2 changes: 1 addition & 1 deletion library.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "resident",
"version": "0.8.5",
"version": "0.8.6-dev",
"description": "Sandbox with hardware IO and hot reload for ESP32 devices",
"keywords": "esp32, sandbox, iot, hot-reload",
"repository": {
Expand Down
7 changes: 3 additions & 4 deletions src/ResidentDriver.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,9 @@ struct EventField {

class Driver : public Extension {
public:
// Most lifecycle (name, registerModule, begin, update, onAppReset) is
// inherited from Extension. Driver adds the hardware-state hook and
// the event-sink machinery.
virtual void onAppRunning(bool running) { (void)running; }
// Lifecycle (name, registerModule, begin, update, onAppReset,
// onAppRunning) is inherited from Extension. Driver adds the RTTI-free
// downcast and the event-sink machinery.

// RTTI-free downcast support (Extension::asDriver returns nullptr by default).
Driver* asDriver() override { return this; }
Expand Down
8 changes: 8 additions & 0 deletions src/ResidentExtension.h
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@ class Extension {
virtual void begin() {} // hardware / module init
virtual void update() {} // per-loop tick (full rate)
virtual void onAppReset() {} // app load/reload

// The app started, stopped or was suspended (the overlay arbiter suspends
// it while a claim sits on a surface the app draws to). Lives here rather
// than on Driver because the extension that most needs it owns no hardware:
// a retained-mode graphics module must stop rendering while the app is
// suspended — a suppressed surface swallows its flushes while the library
// marks those pixels drawn — and repaint the whole surface when it resumes.
virtual void onAppRunning(bool running) { (void)running; }
virtual ~Extension() = default;

// RTTI-free downcast: returns non-null only for Driver subclasses.
Expand Down
23 changes: 23 additions & 0 deletions src/ResidentLvglModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,29 @@ class LvglModule : public Extension {
lv_timer_handler_run_in_period(5);
}

// The app stopped, was suspended, or resumed. A suspension is the overlay
// arbiter taking a surface this app draws on (bar, oracle, fan-p1, face-p1's
// mouth — every dual-role surface in the fleet), and the board suppresses its
// own blit door for the duration. But suppression drops the PIXELS while LVGL
// still renders and still gets its lv_display_flush_ready: the library goes
// on believing those areas reached the glass, so when the claim lifts nothing
// is dirty and the overlay's last frame sits there under a live app, with
// only the widgets that happen to change painting over it.
//
// So stand the rendering down for the duration — the app is suspended, so
// nothing it draws can change anyway — and on the way back stand up, which
// invalidates the whole screen and repaints every pixel the overlay owned.
// Only for targets this module still owns: before an app's first bind there
// is nothing to repaint, and a target lgfx claimed is not ours to touch.
void onAppRunning(bool running) override {
for (int i = 0; i < _count; i++) {
if (!_slots[i].disp) continue;
if (!running) standDown(_slots[i]);
else if (RenderTargets::isOwner(_slots[i].name, RenderTargets::MODULE_LVGL))
standUp(_slots[i]);
}
}

// App reset: wipe the outgoing app's tree and release every claim. Safe
// with stale Lua handles — luavgl invalidates them on C-side deletion
// (fork tests/appswap.lua). The blank frame that the wipe invalidates is
Expand Down
7 changes: 5 additions & 2 deletions src/ResidentSandbox.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3250,9 +3250,12 @@ void Sandbox::notifyAppRunning(bool running) {
// Declared extensions only — same intentional carve-out as onAppReset():
// slot-only peripherals are begun/updated via the lifecycle set but don't
// receive app-facing hooks (onAppRunning / onAppReset).
//
// Every declared extension, not only the Drivers among them: the hook is on
// Extension, and the module-less graphics extensions are the ones that must
// stand their rendering down while the app is suspended.
for (uint8_t i = 0; i < _config.extensions.count; i++) {
Driver* driver = _config.extensions.items[i]->asDriver();
if (driver) driver->onAppRunning(running);
_config.extensions.items[i]->onAppRunning(running);
}
}

Expand Down
43 changes: 43 additions & 0 deletions test/unit/test/test_overlay/test_overlay.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,17 @@ class SpyDisplay : public Resident::SystemDisplay {
void restoreContent() override { restores++; }
};

// A plain Extension — no Lua module, no Driver. LvglModule is one of these,
// and it has to know when the app stops and starts: while it is suspended a
// retained-mode library must stop rendering, because a surface an overlay has
// taken swallows its flushes while it marks those pixels drawn.
class SpyExtension : public Resident::Extension {
public:
int running = 0, stopped = 0;
const char* name() const override { return "spyext"; }
void onAppRunning(bool run) override { if (run) running++; else stopped++; }
};

class FakeOverlay : public Resident::Overlay {
public:
int acquires = 0, releases = 0, draws = 0;
Expand All @@ -30,6 +41,7 @@ constexpr const char* APP =

SpyDisplay* disp = nullptr;
SpyDisplay* disp2 = nullptr;
SpyExtension* ext = nullptr;
Resident::Sandbox* sandbox = nullptr;

void runLoop(int n) { for (int i = 0; i < n; i++) { testMillis() += 200; sandbox->loop(); } }
Expand All @@ -50,6 +62,7 @@ void tearDown(void) {
delete sandbox; sandbox = nullptr;
delete disp; disp = nullptr;
delete disp2; disp2 = nullptr;
delete ext; ext = nullptr;
}
void setUp(void) { testMillis() = 0; }

Expand Down Expand Up @@ -237,6 +250,35 @@ void test_device_suspension_survives_overlay_cycle(void) {
TEST_ASSERT_TRUE(sandbox->isAppSuspended());
}

// A module-less extension (the LVGL module's shape) is told about suspension
// too, not just role-assigned drivers: it is the one that has to stop
// rendering into a surface the overlay now owns, and repaint all of it when
// the claim lifts.
void test_plain_extension_hears_overlay_suspension(void) {
disp = new SpyDisplay();
ext = new SpyExtension();
Resident::SandboxConfig cfg;
cfg.deviceType = "native-test";
cfg.extensions = {disp, ext};
cfg.systemDisplay = disp;
sandbox = new Resident::Sandbox(cfg);
sandbox->setup();
sandbox->loadApp(APP);
const int ranOnLoad = ext->running;

FakeOverlay ov;
sandbox->addOverlay(&ov, disp, 100);
sandbox->requestOverlay(&ov, true);
runLoop(1);
TEST_ASSERT_TRUE(sandbox->isAppSuspended());
TEST_ASSERT_EQUAL_INT(1, ext->stopped);

sandbox->requestOverlay(&ov, false);
runLoop(1);
TEST_ASSERT_FALSE(sandbox->isAppSuspended());
TEST_ASSERT_EQUAL_INT(ranOnLoad + 1, ext->running);
}

void test_draw_paced_on_tick_cadence(void) {
buildDualRole();
sandbox->loadApp(APP);
Expand All @@ -263,6 +305,7 @@ int main(int, char**) {
RUN_TEST(test_swap_between_dual_role_overlays_keeps_suspended_no_restore);
RUN_TEST(test_app_loaded_under_claim_starts_suspended);
RUN_TEST(test_device_suspension_survives_overlay_cycle);
RUN_TEST(test_plain_extension_hears_overlay_suspension);
RUN_TEST(test_draw_paced_on_tick_cadence);
UNITY_END();
return 0;
Expand Down
Loading