From cb1157325a39131650d1b06952f54d0ff39524ce Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sat, 31 Oct 2026 23:48:07 +0100 Subject: [PATCH 1/6] Fixes for multiple flashes when booting epaper device --- Drivers/ssd16xx-module/source/ssd16xx.cpp | 11 +++++++++- .../lvgl-module/source/devices/devices.cpp | 20 +++++++++++++++---- 2 files changed, 26 insertions(+), 5 deletions(-) diff --git a/Drivers/ssd16xx-module/source/ssd16xx.cpp b/Drivers/ssd16xx-module/source/ssd16xx.cpp index 2002d2413..a327948d5 100644 --- a/Drivers/ssd16xx-module/source/ssd16xx.cpp +++ b/Drivers/ssd16xx-module/source/ssd16xx.cpp @@ -241,14 +241,23 @@ static bool full_refresh(Ssd16xxInternal* internal) { } // Drives the pixels that differ from the displayed image +// Without a known displayed image, every pixel is driven by describing the old image as the inverse of the new one. // Returns false when a transfer failed, which leaves the base image invalid. static bool partial_update(Ssd16xxInternal* internal) { const size_t size = snapshot_frame(internal); + const bool displayed_unknown = !internal->base_valid; internal->update_failed = false; spi_controller_lock(internal->spi_controller); set_full_window(internal); write_command_with_buffer(internal, 0x24, internal->snapshot, size); + if (displayed_unknown) { + for (size_t i = 0; i < size; i++) { + internal->snapshot[i] = ~internal->snapshot[i]; + } + set_full_window(internal); + write_command_with_buffer(internal, 0x26, internal->snapshot, size); + } if (internal->update_failed) { spi_controller_unlock(internal->spi_controller); internal->base_valid = false; @@ -355,7 +364,7 @@ static void update_task(void* arg) { portEXIT_CRITICAL(&internal->lock); if (dirty) { - const bool ok = internal->base_valid ? partial_update(internal) : full_refresh(internal); + const bool ok = partial_update(internal); if (!ok) { retry_update(internal); } diff --git a/Modules/lvgl-module/source/devices/devices.cpp b/Modules/lvgl-module/source/devices/devices.cpp index 904103a04..20b3814db 100644 --- a/Modules/lvgl-module/source/devices/devices.cpp +++ b/Modules/lvgl-module/source/devices/devices.cpp @@ -53,6 +53,8 @@ struct IdleRepaintContext { lv_display_t* lvgl_display; struct Device* kernel_display_device; lv_timer_t* timer; + // Tick of the last clear. Input after it makes inactive time smaller than the time since it. + uint32_t cleared_at; // Set once the invalidate+lv_refr_now() repaint has run for the current idle stretch, so it // isn't redone every tick while still idle. bool repaint_done_since_activity; @@ -163,9 +165,12 @@ static void lvgl_device_listener(struct Device* device, enum DeviceEvent event, static void idle_repaint_timer_cb(lv_timer_t* timer) { auto* ctx = static_cast(lv_timer_get_user_data(timer)); - if (lv_display_get_inactive_time(ctx->lvgl_display) < IDLE_REPAINT_MS) { + uint32_t inactive_ms = lv_display_get_inactive_time(ctx->lvgl_display); + if (inactive_ms < lv_tick_elaps(ctx->cleared_at)) { ctx->repaint_done_since_activity = false; ctx->refresh_done_since_activity = false; + } + if (inactive_ms < IDLE_REPAINT_MS) { return; } if (!ctx->repaint_done_since_activity) { @@ -237,13 +242,20 @@ void lvgl_devices_attach() { // boot and whenever an app that stopped LVGL closes, so clearing here covers both // cases without extra plumbing at app-close time specifically. if (display_has_capability(kernel_display_device, DISPLAY_CAPABILITY_SLOW_REFRESH)) { - display_clear(kernel_display_device); + // The first attach follows the boot logo, which the first LVGL render overdraws completely + static bool first_attach = true; + if (!first_attach) { + display_clear(kernel_display_device); + } + first_attach = false; IdleRepaintContext* ctx = &idle_repaint_contexts()[i]; if (ctx->timer == nullptr) { ctx->lvgl_display = added_display; ctx->kernel_display_device = kernel_display_device; - ctx->repaint_done_since_activity = false; - ctx->refresh_done_since_activity = false; + // The panel was just cleared or fully drawn, so the first idle stretch needs no repaint + ctx->cleared_at = lv_tick_get(); + ctx->repaint_done_since_activity = true; + ctx->refresh_done_since_activity = true; ctx->timer = lv_timer_create(idle_repaint_timer_cb, 1000, ctx); } } From eabb6c11df08f57a91e2687d46c0573a3f4ea963 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 1 Nov 2026 00:21:24 +0100 Subject: [PATCH 2/6] Fixes --- .../lvgl-module/source/devices/devices.cpp | 18 +++++++++++++---- Tactility/Source/app/launcher/Launcher.cpp | 20 ++++++++++--------- 2 files changed, 25 insertions(+), 13 deletions(-) diff --git a/Modules/lvgl-module/source/devices/devices.cpp b/Modules/lvgl-module/source/devices/devices.cpp index 20b3814db..5ac2378b9 100644 --- a/Modules/lvgl-module/source/devices/devices.cpp +++ b/Modules/lvgl-module/source/devices/devices.cpp @@ -49,12 +49,14 @@ struct LvglDeviceList { */ constexpr uint32_t IDLE_REPAINT_MS = 5000; +constexpr int32_t ACTIVITY_MARGIN_MS = 20; + struct IdleRepaintContext { lv_display_t* lvgl_display; struct Device* kernel_display_device; lv_timer_t* timer; - // Tick of the last clear. Input after it makes inactive time smaller than the time since it. - uint32_t cleared_at; + // Tick of the last input that the flags below already account for + uint32_t handled_activity_at; // Set once the invalidate+lv_refr_now() repaint has run for the current idle stretch, so it // isn't redone every tick while still idle. bool repaint_done_since_activity; @@ -163,10 +165,18 @@ static void lvgl_device_listener(struct Device* device, enum DeviceEvent event, lvgl_unlock(); } +// The tick of the last input, which the display only exposes as time since then. +// The two tick reads can differ when the task is preempted between them, so callers compare with ACTIVITY_MARGIN_MS. +static uint32_t get_activity_tick(lv_display_t* display) { + return lv_tick_get() - lv_display_get_inactive_time(display); +} + static void idle_repaint_timer_cb(lv_timer_t* timer) { auto* ctx = static_cast(lv_timer_get_user_data(timer)); uint32_t inactive_ms = lv_display_get_inactive_time(ctx->lvgl_display); - if (inactive_ms < lv_tick_elaps(ctx->cleared_at)) { + uint32_t activity_at = lv_tick_get() - inactive_ms; + if (static_cast(activity_at - ctx->handled_activity_at) > ACTIVITY_MARGIN_MS) { + ctx->handled_activity_at = activity_at; ctx->repaint_done_since_activity = false; ctx->refresh_done_since_activity = false; } @@ -253,7 +263,7 @@ void lvgl_devices_attach() { ctx->lvgl_display = added_display; ctx->kernel_display_device = kernel_display_device; // The panel was just cleared or fully drawn, so the first idle stretch needs no repaint - ctx->cleared_at = lv_tick_get(); + ctx->handled_activity_at = get_activity_tick(added_display); ctx->repaint_done_since_activity = true; ctx->refresh_done_since_activity = true; ctx->timer = lv_timer_create(idle_repaint_timer_cb, 1000, ctx); diff --git a/Tactility/Source/app/launcher/Launcher.cpp b/Tactility/Source/app/launcher/Launcher.cpp index 16be21f4c..09f40b58d 100644 --- a/Tactility/Source/app/launcher/Launcher.cpp +++ b/Tactility/Source/app/launcher/Launcher.cpp @@ -193,22 +193,22 @@ int32_t appMain(int argc, char* argv[]) { check(file_ops_subscribe(&file_ops_sub, &event_group) == ERROR_NONE); Context ctx; - WindowId window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); + WindowId window = 0; + bool windowCreated = false; - // The file operations run in order: the favourites are available when auto-start runs + // The window is created once the favourites are loaded, so its first render has the final order check(file_ops_queue(loadFavourites, nullptr, &file_ops_sub) == ERROR_NONE); - check(file_ops_queue(runAutoStart, nullptr, nullptr) == ERROR_NONE); // The launcher is meant to stay resident (it's the home screen) - it only gives up its // thread when app-module's scheduler asks it to (e.g. another new-model app is started). while (true) { task_event_group_wait_any(&event_group, nullptr, portMAX_DELAY); - if (file_ops_poll(&file_ops_sub) == ERROR_NONE) { - // The favourites were loaded - lvgl_lock(); - ctx.grid.requestRepopulate(false); - lvgl_unlock(); + if (!windowCreated && file_ops_poll(&file_ops_sub) == ERROR_NONE) { + window = window_manager_create_ext(appInstanceId, createWidgets, destroyWidgets, &ctx); + windowCreated = true; + // The file operations run in order: the favourites are available when auto-start runs + check(file_ops_queue(runAutoStart, nullptr, nullptr) == ERROR_NONE); } bool shouldClose = false; @@ -222,7 +222,9 @@ int32_t appMain(int argc, char* argv[]) { if (shouldClose) break; } - window_manager_remove(window); + if (windowCreated) { + window_manager_remove(window); + } file_ops_unsubscribe(&file_ops_sub); check(app_event_unsubscribe(&sub) == ERROR_NONE); task_event_group_destruct(&event_group); From e754f18c4207d56ee01dd99b2743ce6564d83d5f Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 1 Nov 2026 00:47:20 +0100 Subject: [PATCH 3/6] Fix for time wrap --- Modules/lvgl-module/source/devices/devices.cpp | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Modules/lvgl-module/source/devices/devices.cpp b/Modules/lvgl-module/source/devices/devices.cpp index 5ac2378b9..f7c08364d 100644 --- a/Modules/lvgl-module/source/devices/devices.cpp +++ b/Modules/lvgl-module/source/devices/devices.cpp @@ -49,7 +49,7 @@ struct LvglDeviceList { */ constexpr uint32_t IDLE_REPAINT_MS = 5000; -constexpr int32_t ACTIVITY_MARGIN_MS = 20; +constexpr uint32_t ACTIVITY_MARGIN_MS = 20; struct IdleRepaintContext { lv_display_t* lvgl_display; @@ -175,7 +175,9 @@ static void idle_repaint_timer_cb(lv_timer_t* timer) { auto* ctx = static_cast(lv_timer_get_user_data(timer)); uint32_t inactive_ms = lv_display_get_inactive_time(ctx->lvgl_display); uint32_t activity_at = lv_tick_get() - inactive_ms; - if (static_cast(activity_at - ctx->handled_activity_at) > ACTIVITY_MARGIN_MS) { + // Any difference beyond the margin is new input, also when the tick counter wrapped in between + const uint32_t activity_delta = activity_at - ctx->handled_activity_at; + if (activity_delta > ACTIVITY_MARGIN_MS && activity_delta < 0U - ACTIVITY_MARGIN_MS) { ctx->handled_activity_at = activity_at; ctx->repaint_done_since_activity = false; ctx->refresh_done_since_activity = false; From a5f69a3409bf4ea86d19e5886e35cf681547d9fa Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 1 Nov 2026 00:50:15 +0100 Subject: [PATCH 4/6] A skewed tick read can no longer trigger a spurious repaint and display_refresh() --- .../lvgl-module/source/devices/devices.cpp | 25 +++++++++++++------ 1 file changed, 18 insertions(+), 7 deletions(-) diff --git a/Modules/lvgl-module/source/devices/devices.cpp b/Modules/lvgl-module/source/devices/devices.cpp index f7c08364d..f2594c43a 100644 --- a/Modules/lvgl-module/source/devices/devices.cpp +++ b/Modules/lvgl-module/source/devices/devices.cpp @@ -50,6 +50,7 @@ struct LvglDeviceList { constexpr uint32_t IDLE_REPAINT_MS = 5000; constexpr uint32_t ACTIVITY_MARGIN_MS = 20; +constexpr uint32_t MAX_ACTIVITY_READ_SKEW_MS = 5; struct IdleRepaintContext { lv_display_t* lvgl_display; @@ -165,16 +166,25 @@ static void lvgl_device_listener(struct Device* device, enum DeviceEvent event, lvgl_unlock(); } -// The tick of the last input, which the display only exposes as time since then. -// The two tick reads can differ when the task is preempted between them, so callers compare with ACTIVITY_MARGIN_MS. -static uint32_t get_activity_tick(lv_display_t* display) { - return lv_tick_get() - lv_display_get_inactive_time(display); +// Reads the tick of the last input, which the display only exposes as time since then. +// Fails when the task was preempted during the read, which would skew the result beyond ACTIVITY_MARGIN_MS. +static bool read_activity(lv_display_t* display, uint32_t* inactive_ms, uint32_t* activity_at) { + const uint32_t start = lv_tick_get(); + *inactive_ms = lv_display_get_inactive_time(display); + if (lv_tick_get() - start > MAX_ACTIVITY_READ_SKEW_MS) { + return false; + } + *activity_at = start - *inactive_ms; + return true; } static void idle_repaint_timer_cb(lv_timer_t* timer) { auto* ctx = static_cast(lv_timer_get_user_data(timer)); - uint32_t inactive_ms = lv_display_get_inactive_time(ctx->lvgl_display); - uint32_t activity_at = lv_tick_get() - inactive_ms; + uint32_t inactive_ms; + uint32_t activity_at; + if (!read_activity(ctx->lvgl_display, &inactive_ms, &activity_at)) { + return; + } // Any difference beyond the margin is new input, also when the tick counter wrapped in between const uint32_t activity_delta = activity_at - ctx->handled_activity_at; if (activity_delta > ACTIVITY_MARGIN_MS && activity_delta < 0U - ACTIVITY_MARGIN_MS) { @@ -265,7 +275,8 @@ void lvgl_devices_attach() { ctx->lvgl_display = added_display; ctx->kernel_display_device = kernel_display_device; // The panel was just cleared or fully drawn, so the first idle stretch needs no repaint - ctx->handled_activity_at = get_activity_tick(added_display); + uint32_t inactive_ms; + while (!read_activity(added_display, &inactive_ms, &ctx->handled_activity_at)) {} ctx->repaint_done_since_activity = true; ctx->refresh_done_since_activity = true; ctx->timer = lv_timer_create(idle_repaint_timer_cb, 1000, ctx); From 483bc84f52b0d679f43b1a1028683d9252af6cf2 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 1 Nov 2026 01:03:58 +0100 Subject: [PATCH 5/6] Update release plan --- Documentation/releasing.md | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/Documentation/releasing.md b/Documentation/releasing.md index 066354e72..303f0235b 100644 --- a/Documentation/releasing.md +++ b/Documentation/releasing.md @@ -6,25 +6,25 @@ 2. Merge the branch and wait for a build. 3. Push a tag e.g. `v1.2.3` - the `v` prefix is crucial for the pipelines. 4. Wait for the SDK and firmwares to be published on the CDN. - Only tags publish SDKs of a release version: `main` only publishes SDKs of a `-dev` version. -5. Test the firmwares on all devices with the web installer + (Only tags publish SDKs of a release version: `main` only publishes SDKs of a `-dev` version.) +5. Test the firmwares on several different devices with the web installer. + Try different architectures, input methods, screen sizes. 6. Run `Buildscripts/gh-download-artifacts.py [actionId] --token [token]` and let it run in the background. Get a token from [here](https://github.com/settings/personal-access-tokens) 7. Update the [TactilityApps](https://github.com/TactilityProject/TactilityApps) with the new SDK version and bump the app versions. -8. After merging the apps, manually upload to CDN, then test them. -9. Make a new version of the docs available at [TactilityDocs](https://github.com/TactilityProject/TactilityDocs) +8. Make a new version of the docs available at [TactilityDocs](https://github.com/TactilityProject/TactilityDocs) 1. Copy `docs/versions/main/` into new version folder 2. Edit `index.html` to include the new version 3. Push code directly to `main` branch -10. Make a new [GitHub release](https://github.com/TactilityProject/Tactility/releases/new) +9. Make a new [GitHub release](https://github.com/TactilityProject/Tactility/releases/new) Run `Buildscripts/release-summary.py` to generate a release, then review it -11. Double-check that all CDN/TactilityApps/Tactility repository changes are merged. -12. Add the `-dev` postfix from `version.txt` and bump the version. Merge it to `main`. -13. Ensure that the CDN is not in development mode anymore. +10. Double-check that all CDN/TactilityApps/Tactility repository changes are merged. +11. Add the `-dev` postfix from `version.txt` and bump the version. Merge it to `main`. +12. Ensure that the CDN is not in development mode anymore. ### Post-release 1. Mention on Discord -2. Consider making a video with one of the devices, showcasing new features +2. Publish on social media 3. Consider notifying vendors/stakeholders \ No newline at end of file From 56c0b5b60b30446e19e083b52c4ceb62e0f81f92 Mon Sep 17 00:00:00 2001 From: Ken Van Hoeylandt Date: Sun, 1 Nov 2026 01:42:11 +0100 Subject: [PATCH 6/6] Update ideas.md --- Documentation/ideas.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/Documentation/ideas.md b/Documentation/ideas.md index cc1e4cada..b700f6d67 100644 --- a/Documentation/ideas.md +++ b/Documentation/ideas.md @@ -9,11 +9,9 @@ - Make tt.py work without an app project. Ping the device for info. - LVGL spinner not working on cl32 v04 (possibly anything monochrome) -- Export platform-esp32 with TactilitySDK - Find out why the first build of an app with a clean build cache folder always fails - Improve posix support: use user home path and system /tmp folder. - Drivers/audio-codec-module is not a module. Move it somewhere else. Or make it an actual module. -- Improve SPI kernel driver (implement read, write, transactions) - External app loading: Check the version of Tactility and check ESP target hardware to check for compatibility Check during installation process, but also when starting (SD card might have old app install from before Tactility OS update) - minitar/untarFile(): "entry->metadata.path" can escape its confined path (e.g. "../something")