PaperS3: Fix ghosting and speed up partial updates (#651)

This commit is contained in:
Shadowtrance 2026-09-13 02:47:08 +10:00 committed by GitHub
parent 70af88ca5b
commit 0c08774657
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 272 additions and 90 deletions

View File

@ -13,8 +13,6 @@
#include <epdiy.h>
#include <esp_heap_caps.h>
#include <cstdlib>
#include <cstring>
@ -33,14 +31,10 @@ static constexpr float FULL_AREA_QUALITY_THRESHOLD = 0.6f;
// Bounds worst-case ghost accumulation during sustained fast-mode interaction (e.g. scrolling),
// regardless of idle time. LVGL's PARTIAL-mode draw buffer covers vres/10 rows (see
// lvgl-module/source/devices/devices.cpp's buffer_height), so a single full-screen redraw is
// already ~10 tiles - this must clear a full sweep comfortably, or a normal full-screen redraw
// already ~10 tiles. This must clear a full sweep comfortably, or a normal full-screen redraw
// gets promoted to slow GC16 partway through.
static constexpr uint32_t QUALITY_REFRESH_PARTIAL_COUNT = 20;
// Cleans up ghosting left behind after interaction stops, since nothing else triggers a refresh
// once draw_bitmap() calls stop arriving. Matches the M5Stack official demo's timer.
static constexpr uint32_t QUALITY_REFRESH_IDLE_SECONDS = 10;
// LVGL's PARTIAL render mode flushes one draw_bitmap() call per still-unjoined dirty rect, so
// one visual refresh is usually several back-to-back calls, not one; this holds quality mode
// across a sibling rect's near-zero gap so they don't end up on inconsistent modes. Must stay
@ -48,10 +42,13 @@ static constexpr uint32_t QUALITY_REFRESH_IDLE_SECONDS = 10;
// separate real frames and pins a whole multi-frame interaction to GC16.
static constexpr uint32_t QUALITY_HOLD_MS = 50;
// epd_fullclear() (white fill + GC16 draw + 3-cycle black/white flash, see epdiy's
// highlevel.c/render.c) only runs once, at boot (papers3_display_init()), never periodically:
// it wipes the whole panel, and this driver has no way to force LVGL to redraw everything
// afterward - only whatever rect is drawn next gets restored, leaving the rest blank.
// Caps how long a single hold session can keep re-extending itself (see
// commit_quality_mode_decision()). A real multi-rect LVGL redraw finishes well within this; a
// caller whose own triggers (is_full_screen_change/partial_count_exceeded) keep firing faster
// than QUALITY_HOLD_MS apart, such as GraphicsDemo's tight banded draw loop, would otherwise keep
// re-extending the hold indefinitely once one of those triggers happened to line up with an
// active hold window.
static constexpr uint32_t QUALITY_HOLD_SESSION_MAX_MS = 200;
// 4x4 ordered (Bayer) dither thresholds, spread evenly across a 0-15 nibble range.
static constexpr uint8_t BAYER_4X4[4][4] = {
@ -63,7 +60,7 @@ static constexpr uint8_t BAYER_4X4[4][4] = {
// Dithers an 8-bit luminance sample (0x00=black..0xFF=white) down to a 4-bit nibble
// (0x0=black..0xF=white, matching EPDiy's MODE_PACKING_2PPB), spreading the rounding error
// spatially instead of truncating every pixel the same way - this is what turns flat/banded
// spatially instead of truncating every pixel the same way. This is what turns flat/banded
// output into something that reads as smooth grayscale.
static inline uint8_t dither_to_nibble(uint8_t luminance, int32_t x, int32_t y) {
// BAYER_4X4 is 0-15; scaled by 17 it spans a full 0-255 quantization step (one increment of
@ -74,11 +71,11 @@ static inline uint8_t dither_to_nibble(uint8_t luminance, int32_t x, int32_t y)
return static_cast<uint8_t>(level > 15U ? 15U : level);
}
// Binary variant for MODE_DU, which only supports pure black/white (see epdiy.h) - dithering
// Binary variant for MODE_DU, which only supports pure black/white (see epdiy.h). Dithering
// still applies so a partial-update area doesn't look coarser than the quality pass that
// preceded it.
static inline uint8_t dither_to_bw_nibble(uint8_t luminance, int32_t x, int32_t y) {
// *16 (not 17) keeps the max threshold at 240, strictly below 255 - otherwise luminance 0xFF
// *16 (not 17) keeps the max threshold at 240, strictly below 255. Otherwise luminance 0xFF
// (pure white) would tie the top Bayer cell's threshold and the strict ">" would misclassify
// it as black.
const uint32_t threshold = BAYER_4X4[y & 3][x & 3] * 16U;
@ -97,18 +94,16 @@ static EpdiyHighlevelState s_hl_state = {};
struct Papers3DisplayInternal {
EpdiyHighlevelState hl_state;
uint8_t* framebuffer;
// Scratch buffer for the grayscale8->EPDiy(4bpp packed, 2px/byte) conversion in draw_bitmap().
uint8_t* packed_buffer;
bool powered;
uint32_t panel_pixel_count;
// Fast (MODE_DU) partial updates since the last quality refresh; see
// QUALITY_REFRESH_PARTIAL_COUNT.
uint32_t partial_count_since_quality;
// get_ticks() at the last quality refresh; see QUALITY_REFRESH_IDLE_SECONDS.
TickType_t last_quality_refresh_tick;
// While get_ticks() < this, every draw_bitmap() call uses quality mode regardless of the
// other triggers; see QUALITY_HOLD_MS.
TickType_t quality_hold_until_tick;
// get_ticks() when the current hold session first armed; see QUALITY_HOLD_SESSION_MAX_MS.
TickType_t quality_hold_session_start_tick;
};
static void power_on(Papers3DisplayInternal* internal) {
@ -122,8 +117,8 @@ static void power_on(Papers3DisplayInternal* internal) {
static error_t papers3_display_reset(Device* device) {
auto* internal = static_cast<Papers3DisplayInternal*>(device_get_driver_data(device));
// EPD has no discrete reset pin/sequence the way SPI TFT panels do - epd_init() (in start())
// already performs the real one-time hardware bring-up. A power-cycle is the closest
// EPD has no discrete reset pin/sequence the way SPI TFT panels do. epd_init() (in start())
// already performs the real one-time hardware bring-up, so a power-cycle is the closest
// equivalent available at runtime.
epd_poweroff();
internal->powered = false;
@ -131,24 +126,70 @@ static error_t papers3_display_reset(Device* device) {
return ERROR_NONE;
}
static error_t papers3_display_init(Device* device) {
const auto* config = GET_CONFIG(device);
auto* internal = static_cast<Papers3DisplayInternal*>(device_get_driver_data(device));
power_on(internal);
// The bootloader/boot-logo splash draws via partial refreshes that never get a real quality
// pass, leaving a faint ghost. Run a full clear now, before LVGL's first flush ever reaches
// draw_bitmap(), so it never has to undo content LVGL already put on screen.
epd_fullclear(&internal->hl_state, config->temperature_celsius);
internal->partial_count_since_quality = 0;
internal->last_quality_refresh_tick = get_ticks();
internal->quality_hold_until_tick = 0;
/**
* Registered as the init() callback, but the current boot path never calls the public
* display_init() wrapper. start() does this work directly instead. Kept as a harmless no-op
* for forward compatibility.
* \see start
*/
static error_t papers3_display_init(Device*) {
return ERROR_NONE;
}
/**
* White-fill plus a single GC16 redraw. Wipes content an app drew directly via draw_bitmap(),
* skipping epd_fullclear()'s 3-cycle hardware flash, which stays reserved for the boot-priming
* clear.
* \see start
*/
static error_t papers3_display_clear(Device* device) {
auto* internal = static_cast<Papers3DisplayInternal*>(device_get_driver_data(device));
const auto* config = GET_CONFIG(device);
power_on(internal);
epd_hl_set_all_white(&internal->hl_state);
auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius);
if (result == EPD_DRAW_SUCCESS) {
internal->partial_count_since_quality = 0;
internal->quality_hold_until_tick = 0;
}
return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE;
}
/**
* A GC16 pass over whatever's already in front_fb, without a preceding white-fill, so it clears
* ghosting without visibly changing on-screen content. MODE_DU alone never fully clears prior
* ghosting even when it draws the correct pixels.
*
* epd_hl_update_area()/epd_hl_update_screen() diff front_fb against back_fb and skip any pixel
* where the two already match, regardless of draw mode (see epd_difference_image_base in epdiy's
* render.c). A ghosted region whose logical content hasn't changed since it was last
* committed to back_fb is exactly such a match, so a plain GC16 pass silently skips physically
* re-driving it. Bitwise-inverting back_fb guarantees a mismatch on every byte (a byte can never
* equal its own complement), forcing the diff to treat the whole panel as dirty; back_fb resyncs
* to front_fb afterward via the diff's own post-draw copy.
*/
static error_t papers3_display_refresh(Device* device) {
auto* internal = static_cast<Papers3DisplayInternal*>(device_get_driver_data(device));
const auto* config = GET_CONFIG(device);
power_on(internal);
const size_t fb_size = static_cast<size_t>(epd_width()) / 2 * epd_height();
uint8_t* back_fb = internal->hl_state.back_fb;
for (size_t i = 0; i < fb_size; i++) {
back_fb[i] = static_cast<uint8_t>(~back_fb[i]);
}
auto result = epd_hl_update_screen(&internal->hl_state, MODE_GC16, config->temperature_celsius);
if (result == EPD_DRAW_SUCCESS) {
internal->partial_count_since_quality = 0;
internal->quality_hold_until_tick = 0;
}
return result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE;
}
// Decides whether this update should be a full-quality (config->quality_draw_mode) refresh or
// a fast MODE_DU one. Read-only - see commit_quality_mode_decision() for the state this decision
// leads to.
static bool should_use_quality_mode(Papers3DisplayInternal* internal, int32_t width, int32_t height) {
// a fast MODE_DU one. Read-only; see commit_quality_mode_decision() for the state this decision
// leads to. *out_within_hold reports whether the hold trigger specifically fired, since
// commit_quality_mode_decision() treats that case differently from the others.
static bool should_use_quality_mode(Papers3DisplayInternal* internal, int32_t width, int32_t height, bool* out_within_hold) {
const TickType_t now = get_ticks();
const uint32_t area = static_cast<uint32_t>(width) * static_cast<uint32_t>(height);
@ -156,25 +197,38 @@ static bool should_use_quality_mode(Papers3DisplayInternal* internal, int32_t wi
static_cast<float>(internal->panel_pixel_count) * FULL_AREA_QUALITY_THRESHOLD
);
const bool partial_count_exceeded = internal->partial_count_since_quality >= QUALITY_REFRESH_PARTIAL_COUNT;
// Idle refresh is too problematic to worth the possible gains. So it isn't done on purpose.
// Ghosting is very minimal now anyway (it's still around but way less bad)
const bool idle_exceeded = now - internal->last_quality_refresh_tick >= seconds_to_ticks(QUALITY_REFRESH_IDLE_SECONDS);
const bool within_hold = now < internal->quality_hold_until_tick;
*out_within_hold = within_hold;
return is_full_screen_change || partial_count_exceeded || idle_exceeded || within_hold;
return is_full_screen_change || partial_count_exceeded || within_hold;
}
// Applies should_use_quality_mode()'s decision, but only commits the quality-mode reset once the
// draw actually succeeded - a failed quality refresh must not make a still-ghosting panel look
// draw actually succeeded. A failed quality refresh must not make a still-ghosting panel look
// freshly cleaned to every trigger above. A failed fast update still counts toward the partial
// count, since it was still MODE_DU content, not a clean slate.
static void commit_quality_mode_decision(Papers3DisplayInternal* internal, bool used_quality, bool draw_succeeded) {
//
// quality_hold_until_tick exists to bridge the near-zero gap between sibling dirty rects of the
// SAME LVGL redraw (see QUALITY_HOLD_MS's comment). A GC16 draw's own duration (400ms+) already
// exceeds QUALITY_HOLD_MS, so unconditionally re-arming a fresh window on every successful quality
// draw let a caller whose own triggers (is_full_screen_change/partial_count_exceeded) keep firing
// faster than QUALITY_HOLD_MS apart, such as GraphicsDemo's tight banded draw loop, perpetuate
// quality mode forever, since each such draw's re-arm was still live by the time the next one
// landed. quality_hold_session_start_tick bounds this: a hold session may keep extending itself
// to bridge consecutive sibling rects, but never past QUALITY_HOLD_SESSION_MAX_MS from when it
// first armed, regardless of how it keeps getting triggered.
static void commit_quality_mode_decision(Papers3DisplayInternal* internal, bool used_quality, bool draw_succeeded, bool was_within_hold) {
if (used_quality) {
if (draw_succeeded) {
const TickType_t now = get_ticks();
internal->partial_count_since_quality = 0;
internal->last_quality_refresh_tick = now;
internal->quality_hold_until_tick = now + millis_to_ticks(QUALITY_HOLD_MS);
const TickType_t now = get_ticks();
if (!was_within_hold) {
internal->quality_hold_session_start_tick = now;
}
const TickType_t session_elapsed = now - internal->quality_hold_session_start_tick;
if (session_elapsed < millis_to_ticks(QUALITY_HOLD_SESSION_MAX_MS)) {
internal->quality_hold_until_tick = now + millis_to_ticks(QUALITY_HOLD_MS);
}
}
} else {
internal->partial_count_since_quality++;
@ -182,41 +236,35 @@ static void commit_quality_mode_decision(Papers3DisplayInternal* internal, bool
}
// Reports GRAYSCALE8 (not MONOCHROME) so LVGL uses partial/tile updates instead of forcing
// full-frame - the bridge hardcodes full-frame for MONOCHROME/I1 regardless of capability flags.
// So draw_bitmap is called once per changed tile, not necessarily the whole panel.
// full-frame, since the bridge hardcodes full-frame for MONOCHROME/I1 regardless of capability
// flags. So draw_bitmap is called once per changed tile, not necessarily the whole panel.
static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data) {
auto* internal = static_cast<Papers3DisplayInternal*>(device_get_driver_data(device));
const auto* config = GET_CONFIG(device);
const int32_t width = x_end - x_start;
const int32_t height = y_end - y_start;
const bool use_quality = should_use_quality_mode(internal, width, height);
bool within_hold = false;
const bool use_quality = should_use_quality_mode(internal, width, height, &within_hold);
// color_data is DISPLAY_COLOR_FORMAT_GRAYSCALE8: row-major, 1 byte/pixel luminance
// (0x00=black..0xFF=white, matching LVGL's L8). EPDiy wants 4bpp packed (2px/byte, 0x0=black,
// 0xF=white); Bayer dithering (full 16-level for the quality pass, binary for MODE_DU)
// spreads the rounding error instead of a flat truncation.
// spreads the rounding error instead of a flat truncation. epd_draw_pixel() applies this
// panel's configured rotation per pixel (see _rotate() in epdiy.c).
const auto* src = static_cast<const uint8_t*>(color_data);
const size_t src_stride = static_cast<size_t>(width);
const size_t packed_stride = static_cast<size_t>(width + 1) / 2;
for (int32_t row = 0; row < height; row++) {
const uint8_t* src_row = src + static_cast<size_t>(row) * src_stride;
uint8_t* dst_row = internal->packed_buffer + static_cast<size_t>(row) * packed_stride;
int32_t col = 0;
for (; col + 2 <= width; col += 2) {
const uint8_t p0 = use_quality
? dither_to_nibble(src_row[col], x_start + col, y_start + row)
: dither_to_bw_nibble(src_row[col], x_start + col, y_start + row);
const uint8_t p1 = use_quality
? dither_to_nibble(src_row[col + 1], x_start + col + 1, y_start + row)
: dither_to_bw_nibble(src_row[col + 1], x_start + col + 1, y_start + row);
dst_row[col / 2] = static_cast<uint8_t>((p1 << 4U) | p0);
}
if (col < width) { // odd width: last column has no pair, low nibble unused
dst_row[col / 2] = use_quality
? dither_to_nibble(src_row[col], x_start + col, y_start + row)
: dither_to_bw_nibble(src_row[col], x_start + col, y_start + row);
const int32_t display_y = y_start + row;
for (int32_t col = 0; col < width; col++) {
const int32_t display_x = x_start + col;
const uint8_t nibble = use_quality
? dither_to_nibble(src_row[col], display_x, display_y)
: dither_to_bw_nibble(src_row[col], display_x, display_y);
epd_draw_pixel(display_x, display_y, static_cast<uint8_t>(nibble << 4), internal->framebuffer);
}
}
@ -228,7 +276,6 @@ static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int3
};
power_on(internal);
epd_draw_rotated_image(update_area, internal->packed_buffer, internal->framebuffer);
const auto draw_mode = use_quality ? config->quality_draw_mode : FAST_DRAW_MODE;
auto draw_result = epd_hl_update_area(
&internal->hl_state,
@ -237,7 +284,7 @@ static error_t papers3_display_draw_bitmap(Device* device, int32_t x_start, int3
update_area
);
commit_quality_mode_decision(internal, use_quality, draw_result == EPD_DRAW_SUCCESS);
commit_quality_mode_decision(internal, use_quality, draw_result == EPD_DRAW_SUCCESS, within_hold);
return draw_result == EPD_DRAW_SUCCESS ? ERROR_NONE : ERROR_RESOURCE;
}
@ -257,10 +304,9 @@ static DisplayColorFormat papers3_display_get_color_format(Device*) {
}
// epd_width()/epd_height() are the panel's native, unrotated dimensions; epd_rotated_display_
// width()/height() swap them for EPD_ROT_PORTRAIT/INVERTED_PORTRAIT. epd_draw_rotated_image()
// clamps its input rect against the rotated dims and epd_draw_pixel() applies the rotation
// transform on top of that (see _rotate() in epdiy.c), so both LVGL's canvas size and
// draw_bitmap()'s rect must be in rotated-space, not native-space.
// width()/height() swap them for EPD_ROT_PORTRAIT/INVERTED_PORTRAIT. draw_bitmap()'s rect and
// LVGL's canvas size are both in this rotated space; epd_draw_pixel() (in draw_bitmap()) converts
// to native space per pixel via the panel's configured rotation.
static uint16_t papers3_display_get_resolution_x(Device*) {
return static_cast<uint16_t>(epd_rotated_display_width());
}
@ -271,8 +317,8 @@ static uint16_t papers3_display_get_resolution_y(Device*) {
static void papers3_display_get_frame_buffer(Device*, uint8_t, void** out_buffer) {
// Not exposed via the generic fb-direct path: EPDiy's framebuffer is its own 4bpp packed
// format, not the DISPLAY_COLOR_FORMAT_GRAYSCALE8 (1 byte/pixel) this driver reports - see
// get_frame_buffer_count() and draw_bitmap()'s conversion.
// format, not the DISPLAY_COLOR_FORMAT_GRAYSCALE8 (1 byte/pixel) this driver reports.
// See get_frame_buffer_count() and draw_bitmap()'s conversion.
*out_buffer = nullptr;
}
@ -283,12 +329,15 @@ static uint8_t papers3_display_get_frame_buffer_count(Device*) {
// endregion
static const DisplayApi papers3_display_api = {
// PREFER_EXTERNAL_RAM: draw_bitmap() converts into packed_buffer before touching hardware,
// never DMAs from LVGL's pointer directly - frees LVGL's draw buffers from forced internal RAM.
// PREFER_EXTERNAL_RAM: draw_bitmap() dithers straight into internal->framebuffer (SPIRAM)
// and never DMAs from LVGL's pointer directly, freeing LVGL's draw buffers from forced
// internal RAM.
.capabilities = DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_SLOW_REFRESH | DISPLAY_CAPABILITY_PREFER_EXTERNAL_RAM,
.reset = papers3_display_reset,
.init = papers3_display_init,
.draw_bitmap = papers3_display_draw_bitmap,
.clear = papers3_display_clear,
.refresh = papers3_display_refresh,
.mirror = nullptr,
.swap_xy = nullptr,
.get_swap_xy = nullptr,
@ -339,27 +388,22 @@ static error_t start(Device* device) {
internal->hl_state = s_hl_state;
internal->framebuffer = epd_hl_get_framebuffer(&internal->hl_state);
// Sized for the rotated (LVGL-facing) resolution - see get_resolution_x()/y()'s comment.
// ~260KB for this panel - a plain malloc() would land in scarce internal RAM. This buffer is
// only ever read once per draw_bitmap() call by epd_draw_rotated_image() (into epdiy's own
// SPIRAM-backed framebuffers, see highlevel.c), so it has no internal-RAM/DMA requirement and
// belongs in PSRAM instead, matching epdiy's own front_fb/back_fb/difference_fb allocations.
const size_t packed_buffer_size = static_cast<size_t>((epd_rotated_display_width() + 1) / 2) * static_cast<size_t>(epd_rotated_display_height());
internal->packed_buffer = static_cast<uint8_t*>(heap_caps_malloc(packed_buffer_size, MALLOC_CAP_SPIRAM));
if (internal->packed_buffer == nullptr) {
LOG_E(TAG, "Failed to allocate packed pixel buffer");
epd_deinit();
free(internal);
return ERROR_OUT_OF_MEMORY;
}
internal->panel_pixel_count = static_cast<uint32_t>(epd_rotated_display_width()) * static_cast<uint32_t>(epd_rotated_display_height());
internal->partial_count_since_quality = 0;
internal->last_quality_refresh_tick = get_ticks();
internal->quality_hold_until_tick = 0;
internal->quality_hold_session_start_tick = 0;
device_set_driver_data(device, internal);
// The bootloader/boot-logo splash draws via partial refreshes that never get a real quality
// pass, leaving a faint ghost. Run the heavier flash-cycle clear now, before LVGL's first
// flush ever reaches draw_bitmap(), so it never has to undo content LVGL already put on
// screen. Nothing in the current boot path calls display_init() on any display device, so
// this is done directly here instead of the DisplayApi init() callback. Routine post-boot
// clears use the lighter papers3_display_clear().
power_on(internal);
epd_fullclear(&internal->hl_state, config->temperature_celsius);
LOG_I(TAG, "EPDiy initialized (%dx%d native, %dx%d rotated)", epd_width(), epd_height(), epd_rotated_display_width(), epd_rotated_display_height());
return ERROR_NONE;
}
@ -374,7 +418,6 @@ static error_t stop(Device* device) {
epd_deinit();
free(internal->packed_buffer);
free(internal);
device_set_driver_data(device, nullptr);
return ERROR_NONE;

View File

@ -26,6 +26,35 @@ struct LvglDeviceList {
size_t count;
};
/**
* Fires periodically while a slow-refresh display (e.g. e-paper) is bound. Once
* lv_display_get_inactive_time(), the time since the last real input event tracked in lv_indev.c,
* crosses this threshold, invalidates the whole active screen so LVGL redraws every widget
* through its own draw_bitmap() calls, clearing ghosting that normal partial redraws leave behind
* over time. Checking real input activity rather than time since last draw means a redraw during
* active use, such as a blinking cursor, never resets the timer, so this only ever acts once
* genuinely idle.
*/
constexpr uint32_t IDLE_REPAINT_MS = 5000;
struct IdleRepaintContext {
lv_display_t* lvgl_display;
struct Device* kernel_display_device;
lv_timer_t* timer;
// 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;
// Set only once display_refresh() itself returns ERROR_NONE, tracked separately from
// repaint_done_since_activity so a failed refresh (e.g. ERROR_RESOURCE) keeps getting retried
// on later idle ticks instead of being silently skipped for the rest of the idle stretch.
bool refresh_done_since_activity;
};
// One slot per display bound in lvgl_devices_attach()'s loop below that turns out to be
// DISPLAY_CAPABILITY_SLOW_REFRESH; a board with multiple such displays needs independent idle
// tracking for each; slots are 1:1 with idle_repaint_contexts' index, not a compacted list.
static IdleRepaintContext idle_repaint_contexts[LVGL_DEVICES_MAX_PER_TYPE] = {};
extern "C" {
static bool lvgl_device_list_collect(struct Device* device, void* context) {
@ -36,6 +65,33 @@ static bool lvgl_device_list_collect(struct Device* device, void* context) {
return true;
}
static void idle_repaint_timer_cb(lv_timer_t* timer) {
auto* ctx = static_cast<IdleRepaintContext*>(lv_timer_get_user_data(timer));
if (lv_display_get_inactive_time(ctx->lvgl_display) < IDLE_REPAINT_MS) {
ctx->repaint_done_since_activity = false;
ctx->refresh_done_since_activity = false;
return;
}
if (!ctx->repaint_done_since_activity) {
ctx->repaint_done_since_activity = true;
lv_obj_invalidate(lv_display_get_screen_active(ctx->lvgl_display));
// lv_refr_now() runs the redraw synchronously, so display_refresh() below sees the
// now-current framebuffer instead of stale content from before the invalidate.
lv_refr_now(ctx->lvgl_display);
}
if (ctx->refresh_done_since_activity) {
return;
}
// Under PARTIAL render mode the repaint above still tiles into ordinary sub-threshold
// draw_bitmap() calls that stay in fast MODE_DU, which alone never fully clears prior
// ghosting even when it draws the correct pixels. This forces the quality pass that clears
// it. Tracked separately from the repaint so a failure here (e.g. ERROR_RESOURCE) retries on
// the next idle tick instead of also repeating the repaint, which succeeded already.
if (display_refresh(ctx->kernel_display_device) == ERROR_NONE) {
ctx->refresh_done_since_activity = true;
}
}
void lvgl_devices_attach() {
lvgl_lock();
@ -60,7 +116,7 @@ void lvgl_devices_attach() {
color_format == DISPLAY_COLOR_FORMAT_BGR565;
bool display_requires_full_frame = display_has_capability(kernel_display_device, DISPLAY_CAPABILITY_REQUIRES_FULL_FRAME);
// Without CAP_SWAP_XY the driver can't rotate 90/270 in hardware (display_swap_xy() is
// null and silently skipped by lvgl_display_apply_rotation()) - LVGL would still switch
// null and silently skipped by lvgl_display_apply_rotation()). LVGL would still switch
// its own logical w/h for those rotations, mismatching the panel's fixed physical
// orientation (e.g. RGB/DPI panels, whose video timing is fixed at panel-init time).
// sw_rotate makes LVGL rotate the rendered pixels in software instead, so the driver
@ -78,6 +134,22 @@ void lvgl_devices_attach() {
lv_disp_t* added_display = NULL;
if (lvgl_display_add(kernel_display_device, &lvgl_display_config, &added_display) == ERROR_NONE) {
LOG_I(TAG, "Bound %s to LVGL", kernel_display_device->name);
// Slow-refresh panels accumulate ghosting from normal partial redraws over time, and
// worse from content an app drew directly via draw_bitmap() outside LVGL's own screen
// model, which LVGL has no way to know needs repainting. This function runs both at
// 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);
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;
ctx->timer = lv_timer_create(idle_repaint_timer_cb, 1000, ctx);
}
}
// Pointers/keyboards below bind to the first display bound here, matching that display's
// refresh behavior.
if (lvgl_display == NULL) {
@ -181,6 +253,14 @@ void lvgl_devices_detach() {
indev = lv_indev_get_next(NULL);
}
for (size_t i = 0; i < LVGL_DEVICES_MAX_PER_TYPE; i++) {
IdleRepaintContext* ctx = &idle_repaint_contexts[i];
if (ctx->timer != nullptr) {
lv_timer_delete(ctx->timer);
*ctx = {};
}
}
lv_disp_t* display = lv_disp_get_next(NULL);
while (display != NULL) {
lvgl_display_remove(display);

View File

@ -81,6 +81,30 @@ struct DisplayApi {
*/
error_t (*draw_bitmap)(struct Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
/**
* @brief Fully wipes/resets the panel's visible image, independent of anything previously
* drawn via draw_bitmap(). Intended for panels where content drawn outside the normal
* screen/widget model, such as an app writing directly via draw_bitmap(), can leave residue
* that nothing else knows to repaint. E-paper ghosting is the main case.
* @warning Nullable. When null, this display has no dedicated clear operation. Callers should
* fall back to redrawing (e.g. a full-frame draw_bitmap()) if a clear is needed.
* @param[in] device the display device
* @retval ERROR_NONE when the operation was successful
*/
error_t (*clear)(struct Device* device);
/**
* @brief Forces a full-quality redraw of whatever is already in the panel's buffer, without
* changing its content. Unlike clear(), which wipes the image first, this is for e-paper
* panels doing a GC16-equivalent pass to clear ghosting left by earlier fast or partial
* draw_bitmap() calls, as periodic maintenance while content is otherwise correct but has
* accumulated visible residue from the panel's own display technology.
* @warning Nullable. When null, this display has no such maintenance pass.
* @param[in] device the display device
* @retval ERROR_NONE when the operation was successful
*/
error_t (*refresh)(struct Device* device);
/**
* @brief Mirrors the image along the X and/or Y axis.
* @warning Function pointer should be null if capability not available.
@ -254,6 +278,20 @@ error_t display_init(struct Device* device);
*/
error_t display_draw_bitmap(struct Device* device, int32_t x_start, int32_t y_start, int32_t x_end, int32_t y_end, const void* color_data);
/**
* @brief Fully wipes/resets the panel's visible image using the specified display. See
* DisplayApi::clear() for details.
* @retval ERROR_NOT_SUPPORTED when this display has no clear() implementation
*/
error_t display_clear(struct Device* device);
/**
* @brief Forces a full-quality redraw of the panel's current content using the specified display.
* See DisplayApi::refresh() for details.
* @retval ERROR_NOT_SUPPORTED when this display has no refresh() implementation
*/
error_t display_refresh(struct Device* device);
/**
* @brief Mirrors the image along the X and/or Y axis using the specified display.
*/

View File

@ -30,6 +30,24 @@ error_t display_draw_bitmap(Device* device, int32_t x_start, int32_t y_start, in
return DISPLAY_DRIVER_API(driver)->draw_bitmap(device, x_start, y_start, x_end, y_end, color_data);
}
error_t display_clear(Device* device) {
const auto* driver = device_get_driver(device);
const auto* api = DISPLAY_DRIVER_API(driver);
if (api->clear == nullptr) {
return ERROR_NOT_SUPPORTED;
}
return api->clear(device);
}
error_t display_refresh(Device* device) {
const auto* driver = device_get_driver(device);
const auto* api = DISPLAY_DRIVER_API(driver);
if (api->refresh == nullptr) {
return ERROR_NOT_SUPPORTED;
}
return api->refresh(device);
}
error_t display_mirror(Device* device, bool x_axis, bool y_axis) {
const auto* driver = device_get_driver(device);
return DISPLAY_DRIVER_API(driver)->mirror(device, x_axis, y_axis);

View File

@ -151,9 +151,12 @@ const struct ModuleSymbol KERNEL_SYMBOLS[] = {
DEFINE_MODULE_SYMBOL(backlight_get_max_brightness),
DEFINE_MODULE_SYMBOL(BACKLIGHT_TYPE),
// drivers/display
DEFINE_MODULE_SYMBOL(display_has_capability),
DEFINE_MODULE_SYMBOL(display_reset),
DEFINE_MODULE_SYMBOL(display_init),
DEFINE_MODULE_SYMBOL(display_draw_bitmap),
DEFINE_MODULE_SYMBOL(display_clear),
DEFINE_MODULE_SYMBOL(display_refresh),
DEFINE_MODULE_SYMBOL(display_mirror),
DEFINE_MODULE_SYMBOL(display_swap_xy),
DEFINE_MODULE_SYMBOL(display_get_swap_xy),