From 44411a470e9a5e14edb995118925e3f13104f995 Mon Sep 17 00:00:00 2001 From: Matt Webb Date: Tue, 22 Sep 2026 15:51:49 +0100 Subject: [PATCH] fix(lvgl): stand the displays down while the app is suspended MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An overlay claim on a dual-role surface left its own last frame on the glass after it released, under a live app. The board suppresses its blit door for the duration of a claim (every board in the fleet with a dual-role surface does this) — but suppression drops the PIXELS while LVGL goes on rendering and goes on getting its lv_display_flush_ready. The library believed those areas had reached the panel, so when the claim lifted nothing was dirty: the overlay's frame sat there with only the widgets that happened to change painting over it. A device whose app mutates on change — which is the idiom the authoring sheets teach — never repaints the rest at all. So LvglModule now stands its displays down while the app is suspended (the app cannot draw anything new while suspended, so nothing is lost) and stands them up on resume, which invalidates each active 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 hear about the suspension at all, onAppRunning moves from Driver up to Extension, and Sandbox::notifyAppRunning calls it on every declared extension rather than only the Drivers among them. That is the right home for it: the extension that most needs to know the app stopped is a graphics module, which owns no hardware and so was never a Driver. Existing Driver overrides are untouched — same signature, same slot. Found on an oracle (360x360 round face, dual-role) and reproduced in that board's wasm sim, where a still app left 129600 of 129600 pixels unrepainted after an agent_status claim released. Co-Authored-By: Claude Opus 5 (1M context) --- docs/api.md | 9 ++-- docs/changelog.md | 7 ++++ idf_component.yml | 2 +- library.json | 2 +- src/ResidentDriver.h | 7 ++-- src/ResidentExtension.h | 8 ++++ src/ResidentLvglModule.h | 23 +++++++++++ src/ResidentSandbox.cpp | 7 +++- test/unit/test/test_overlay/test_overlay.cpp | 43 ++++++++++++++++++++ 9 files changed, 96 insertions(+), 12 deletions(-) diff --git a/docs/api.md b/docs/api.md index cd6a465..a227a66 100644 --- a/docs/api.md +++ b/docs/api.md @@ -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. @@ -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 @@ -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) @@ -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 diff --git a/docs/changelog.md b/docs/changelog.md index 14c4031..eacfeeb 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -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. diff --git a/idf_component.yml b/idf_component.yml index 24459bf..7dc5766 100644 --- a/idf_component.yml +++ b/idf_component.yml @@ -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" diff --git a/library.json b/library.json index c44d467..f27814b 100644 --- a/library.json +++ b/library.json @@ -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": { diff --git a/src/ResidentDriver.h b/src/ResidentDriver.h index a8819d7..ab42c38 100644 --- a/src/ResidentDriver.h +++ b/src/ResidentDriver.h @@ -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; } diff --git a/src/ResidentExtension.h b/src/ResidentExtension.h index 77bfdb2..63dd7e8 100644 --- a/src/ResidentExtension.h +++ b/src/ResidentExtension.h @@ -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. diff --git a/src/ResidentLvglModule.h b/src/ResidentLvglModule.h index c29a81d..39852fe 100644 --- a/src/ResidentLvglModule.h +++ b/src/ResidentLvglModule.h @@ -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 diff --git a/src/ResidentSandbox.cpp b/src/ResidentSandbox.cpp index 29f8d2e..bba5f63 100644 --- a/src/ResidentSandbox.cpp +++ b/src/ResidentSandbox.cpp @@ -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); } } diff --git a/test/unit/test/test_overlay/test_overlay.cpp b/test/unit/test/test_overlay/test_overlay.cpp index da85948..f33052d 100644 --- a/test/unit/test/test_overlay/test_overlay.cpp +++ b/test/unit/test/test_overlay/test_overlay.cpp @@ -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; @@ -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(); } } @@ -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; } @@ -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); @@ -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;