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
90 changes: 34 additions & 56 deletions Devices/m5stack-papers3/source/drivers/papers3_display.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -11,46 +11,27 @@
#include <epd_board.h>
#include <epdiy.h>

#include <esp_heap_caps.h>

#include <cstdlib>
#include <cstring>

#define TAG "Papers3Display"
#define GET_CONFIG(device) (static_cast<const Papers3DisplayConfig*>((device)->config))

// Maps each src byte (8px, MSB-first, bit=1 -> white/0x0F) to the 4 packed dst bytes
// (2px/byte, EPDiy MODE_PACKING_2PPB nibble order) it produces, replacing a per-pixel
// branch loop with a table lookup.
static uint32_t s_unpack_lut[256];

static void init_unpack_lut() {
for (uint32_t byte = 0; byte < 256; byte++) {
uint8_t dst[4];
for (int32_t pair = 0; pair < 4; pair++) {
const uint8_t bit0 = (byte >> (7 - pair * 2)) & 0x01U;
const uint8_t bit1 = (byte >> (7 - pair * 2 - 1)) & 0x01U;
const uint8_t p0 = bit0 ? 0x0FU : 0x00U;
const uint8_t p1 = bit1 ? 0x0FU : 0x00U;
dst[pair] = static_cast<uint8_t>((p1 << 4U) | p0);
}
memcpy(&s_unpack_lut[byte], dst, sizeof(dst));
}
}

extern "C" {

extern Module m5stack_papers3_module;

// epd_hl_init() sets an internal already_initialized flag and has no matching deinit, so the
// highlevel state (and the framebuffer it owns) must persist across stop()/start() cycles and be
// reused rather than recreated - ported from the old deprecated-HAL EpdiyDisplay's identical
// s_hlInitialized/s_hlState statics.
// epd_hl_init() has no matching deinit and sets an internal already_initialized flag, so the
// highlevel state must persist across stop()/start() cycles and be reused rather than recreated.
static bool s_hl_initialized = false;
static EpdiyHighlevelState s_hl_state = {};

struct Papers3DisplayInternal {
EpdiyHighlevelState hl_state;
uint8_t* framebuffer;
// Scratch buffer for the I1(1bpp)->EPDiy(4bpp packed, 2px/byte) conversion in draw_bitmap().
// Scratch buffer for the grayscale8->EPDiy(4bpp packed, 2px/byte) conversion in draw_bitmap().
uint8_t* packed_buffer;
bool powered;
};
Expand All @@ -76,49 +57,46 @@ static error_t papers3_display_reset(Device* device) {
}

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);
epd_clear();
epd_hl_set_all_white(&internal->hl_state);
// 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);
return ERROR_NONE;
}

// LVGL only ever calls this with the full frame: DISPLAY_COLOR_FORMAT_MONOCHROME forces
// LV_DISPLAY_RENDER_MODE_FULL in the generic kernel LVGL bridge (lvgl_display.c), and FULL mode
// only presents (calls draw_bitmap) once per render cycle, with the complete 0,0..hres,vres rect.
// 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.
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;

// color_data is DISPLAY_COLOR_FORMAT_MONOCHROME: row-major, MSB-first 1bpp (LVGL's LV_COLOR_FORMAT_I1
// with the palette header already stripped by the caller). Bit 1 = white/lit (LVGL's I1 blend
// sets a bit when the source luminance is above its threshold), bit 0 = black.
// 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) - a plain >>4 truncation preserves all 16 real gray levels the panel supports
// (this panel is not B/W-only; see MODE_GC16/GL16 in papers3-display.yaml's draw-mode doc).
const auto* src = static_cast<const uint8_t*>(color_data);
const size_t src_stride = static_cast<size_t>(width + 7) / 8;
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;
// Bulk path: one LUT lookup + 4-byte copy per 8 source pixels.
for (; col + 8 <= width; col += 8) {
memcpy(dst_row + col / 2, &s_unpack_lut[src_row[col / 8]], 4);
}
// Tail: fewer than 8 pixels left (width not a multiple of 8).
for (; col < width; col += 2) {
const uint8_t bit0 = (src_row[col / 8] >> (7 - (col % 8))) & 0x01U;
const uint8_t p0 = bit0 ? 0x0FU : 0x00U;
uint8_t p1 = 0;
if (col + 1 < width) {
const uint8_t bit1 = (src_row[(col + 1) / 8] >> (7 - ((col + 1) % 8))) & 0x01U;
p1 = bit1 ? 0x0FU : 0x00U;
}
for (; col + 2 <= width; col += 2) {
const uint8_t p0 = src_row[col] >> 4U;
const uint8_t p1 = src_row[col + 1] >> 4U;
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] = static_cast<uint8_t>(src_row[col] >> 4U);
}
}

const EpdRect update_area = {
Expand Down Expand Up @@ -152,7 +130,7 @@ static error_t papers3_display_disp_on_off(Device* device, bool on_off) {
}

static DisplayColorFormat papers3_display_get_color_format(Device*) {
return DISPLAY_COLOR_FORMAT_MONOCHROME;
return DISPLAY_COLOR_FORMAT_GRAYSCALE8;
}

// epd_width()/epd_height() are the panel's native, unrotated dimensions (display->width/height in
Expand All @@ -172,7 +150,7 @@ 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_MONOCHROME (1bpp) this driver reports - see
// 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;
}
Expand All @@ -184,7 +162,9 @@ static uint8_t papers3_display_get_frame_buffer_count(Device*) {
// endregion

static const DisplayApi papers3_display_api = {
.capabilities = DISPLAY_CAPABILITY_ON_OFF | DISPLAY_CAPABILITY_REQUIRES_FULL_FRAME | DISPLAY_CAPABILITY_SLOW_REFRESH,
// 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.
.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,
Expand Down Expand Up @@ -213,12 +193,6 @@ static const DisplayApi papers3_display_api = {
static error_t start(Device* device) {
const auto* config = GET_CONFIG(device);

static bool s_lut_initialized = false;
if (!s_lut_initialized) {
init_unpack_lut();
s_lut_initialized = true;
}

auto* internal = static_cast<Papers3DisplayInternal*>(malloc(sizeof(Papers3DisplayInternal)));
if (internal == nullptr) {
return ERROR_OUT_OF_MEMORY;
Expand All @@ -245,8 +219,12 @@ static error_t start(Device* device) {
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*>(malloc(packed_buffer_size));
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();
Expand Down
83 changes: 59 additions & 24 deletions Devices/m5stack-tab5/Source/devices/tab5_keyboard.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ static constexpr uint32_t REPEAT_RATE_MS = 80;
// REG_INT_STAT polling (when no IRQ pin) and software key-repeat ticking.
static constexpr uint32_t POLL_INTERVAL_MS = 20;

// Upper bound on events consumed per drain_events() call. Since the loop re-reads REG_EVENT_NUM
// each iteration rather than counting down a latched value, this caps the damage if the device
// ever reports a non-zero count that never drains - without it, that would spin forever holding
// the I2C bus. The device's own queue is far smaller than this, so it never limits normal bursts.
static constexpr uint8_t MAX_EVENTS_PER_DRAIN = 32;

// ---------------------------------------------------------------------------
// Register addresses
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -117,6 +123,12 @@ static constexpr HidMapping KEY_MATRIX_HID_SYM[70] = {
// Covers all codes present in the Tab5 matrix tables above. LV_KEY_* are plain uint32_t
// constants - matching KeyboardKeyData::key's driver-defined contract and the same convention
// m5stack-module's cardputer_keyboard.cpp kernel driver already uses.
//
// `ctrl` only selects the LVGL focus-navigation aliases for the arrow keys. Ctrl chords on
// ordinary keys are NOT folded into the returned value - the C0 control codes a terminal wants
// (Ctrl+C = 0x03, Ctrl+K = 0x0B, ...) collide with the LVGL constants returned here (LV_KEY_END = 3,
// LV_KEY_PREV = 11, ...), so Ctrl is reported out-of-band via KeyboardKeyData::ctrl instead and
// consumers that want control codes derive them themselves.
// ---------------------------------------------------------------------------
static uint32_t tab5_translate_key(uint8_t keycode, uint8_t modifier, bool ctrl) {
const bool shift = (modifier & 0x22U) != 0U;
Expand Down Expand Up @@ -172,6 +184,15 @@ static uint32_t now_ms() {
return static_cast<uint32_t>(esp_timer_get_time() / 1000);
}

// Queued key event. Modifier state is captured here at enqueue time rather than read back from
// Tab5KeyboardInternal at dequeue time, since the user can release Ctrl before read_key() drains
// the event - and software key-repeat replays this same struct, so a held chord keeps its modifiers.
struct Tab5KeyEvent {
uint32_t key;
bool ctrl;
bool alt;
};
Comment thread
coderabbitai[bot] marked this conversation as resolved.

struct Tab5KeyboardInternal {
QueueHandle_t queue;

Expand All @@ -181,6 +202,7 @@ struct Tab5KeyboardInternal {
bool aa_held;
bool aa_tapped;
bool ctrl_held;
bool alt_held;

// IRQ-driven event gating
volatile bool irq_pending;
Expand All @@ -193,7 +215,7 @@ struct Tab5KeyboardInternal {
uint32_t last_poll_ms;

// Software key-repeat state (tracked by position to survive modifier changes)
uint32_t repeat_key;
Tab5KeyEvent repeat_event;
uint8_t repeat_row;
uint8_t repeat_col;
uint32_t repeat_start_ms;
Expand Down Expand Up @@ -289,16 +311,22 @@ static void remove_irq_pin(Tab5KeyboardInternal* internal) {
// drain_events - reads all pending events from the device queue
// ---------------------------------------------------------------------------
static void drain_events(Device* device, Tab5KeyboardInternal* internal) {
uint8_t count = 0;
if (!read_reg(device, REG_EVENT_NUM, &count) || count == 0) {
return;
}
// REG_EVENT_NUM is re-read every iteration rather than latched once and counted down, matching
// M5's own UnitTab5Keyboard::drain_events(). Each REG_KEY_EVENT read consumes one event from the
// device queue, so a count latched up front can go stale mid-drain; re-reading makes the loop
// self-correcting and lets it stop as soon as the device says the queue is actually empty.
uint8_t drained = 0;
while (drained < MAX_EVENTS_PER_DRAIN) {
uint8_t count = 0;
if (!read_reg(device, REG_EVENT_NUM, &count) || count == 0) {
break;
}

while (count > 0) {
uint8_t raw = 0;
if (!read_reg(device, REG_KEY_EVENT, &raw) || raw == KEY_EVENT_EMPTY) {
break;
}
drained++;

const bool pressed = (raw & 0x80U) != 0U;
const uint8_t row = (raw >> 4U) & 0x07U;
Expand All @@ -308,7 +336,6 @@ static void drain_events(Device* device, Tab5KeyboardInternal* internal) {
if (row == MOD_ROW_SYM && col == MOD_COL_SYM) {
internal->sym_active = pressed;
update_leds(device, internal);
count--;
continue;
}
if (row == MOD_ROW_AA && col == MOD_COL_AA) {
Expand All @@ -324,16 +351,14 @@ static void drain_events(Device* device, Tab5KeyboardInternal* internal) {
internal->aa_tapped = false;
}
update_leds(device, internal);
count--;
continue;
}
if (row == MOD_ROW_CTRL && col == MOD_COL_CTRL) {
internal->ctrl_held = pressed;
count--;
continue;
}
if (row == MOD_ROW_ALT && col == MOD_COL_ALT) {
count--;
internal->alt_held = pressed;
continue;
}

Expand All @@ -354,10 +379,11 @@ static void drain_events(Device* device, Tab5KeyboardInternal* internal) {
// no business reaching into, so ESC is now just queued as a normal key
// like everything else (LVGL/app code already handles ESC via focus/group
// navigation the same way a dedicated ESC key on any other keyboard would).
xQueueSend(internal->queue, &lv_key, 0);
const Tab5KeyEvent event = { lv_key, internal->ctrl_held, internal->alt_held };
xQueueSend(internal->queue, &event, 0);
// Arm software repeat tracking by row/col to survive modifier changes
const uint32_t now = now_ms();
internal->repeat_key = lv_key;
internal->repeat_event = event;
internal->repeat_row = row;
internal->repeat_col = col;
internal->repeat_start_ms = now;
Expand All @@ -370,12 +396,11 @@ static void drain_events(Device* device, Tab5KeyboardInternal* internal) {
}
} else if (row == internal->repeat_row && col == internal->repeat_col) {
// Match release by position, not translated value — survives sticky Aa clear
internal->repeat_key = 0;
internal->repeat_event.key = 0;
}
}
}
}
count--;
}

// Clear INT status after draining so the line de-asserts
Expand Down Expand Up @@ -434,13 +459,19 @@ static void poll_if_due(Device* device, Tab5KeyboardInternal* internal) {
drain_events(device, internal);
}

// Software key-repeat (runs every tick regardless of IRQ)
if (internal->repeat_key != 0U) {
if ((now - internal->repeat_start_ms) >= REPEAT_INITIAL_MS) {
// Software key-repeat (runs every tick regardless of IRQ).
//
// The clock is re-read here rather than reusing `now` from the top of the function: a press
// handled by the drain above sets repeat_start_ms to a timestamp taken *during* the drain, which
// is later than `now`. The unsigned subtraction below would then wrap to a huge value and clear
// the REPEAT_INITIAL_MS gate immediately, emitting one spurious repeat ~1ms after every press.
const uint32_t repeat_now = now_ms();
if (internal->repeat_event.key != 0U) {
if ((repeat_now - internal->repeat_start_ms) >= REPEAT_INITIAL_MS) {
const uint32_t last = internal->repeat_last_ms;
if (last == 0 || (now - last) >= REPEAT_RATE_MS) {
internal->repeat_last_ms = now;
xQueueSend(internal->queue, &internal->repeat_key, 0);
if (last == 0 || (repeat_now - last) >= REPEAT_RATE_MS) {
internal->repeat_last_ms = repeat_now;
xQueueSend(internal->queue, &internal->repeat_event, 0);
}
}
}
Expand All @@ -464,7 +495,7 @@ static error_t start(Device* device) {
}
memset(internal, 0, sizeof(Tab5KeyboardInternal));

internal->queue = xQueueCreate(20, sizeof(uint32_t));
internal->queue = xQueueCreate(20, sizeof(Tab5KeyEvent));
if (internal->queue == nullptr) {
free(internal);
return ERROR_OUT_OF_MEMORY;
Expand Down Expand Up @@ -522,15 +553,19 @@ static error_t tab5_keyboard_read_key(Device* device, KeyboardKeyData* data) {

poll_if_due(device, internal);

uint32_t lv_key = 0;
if (xQueueReceive(internal->queue, &lv_key, 0) == pdTRUE) {
data->key = lv_key;
Tab5KeyEvent event = {};
if (xQueueReceive(internal->queue, &event, 0) == pdTRUE) {
data->key = event.key;
data->pressed = true;
data->continue_reading = uxQueueMessagesWaiting(internal->queue) > 0;
data->ctrl = event.ctrl;
data->alt = event.alt;
} else {
data->key = 0;
data->pressed = false;
data->continue_reading = false;
data->ctrl = false;
data->alt = false;
}

return ERROR_NONE;
Expand Down
7 changes: 7 additions & 0 deletions Modules/lvgl-module/include/lvgl/devices/display.h
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,13 @@ struct LvglDisplayConfig {
* the LV_COLOR_FORMAT_I1 path (already always-full-frame).
*/
bool force_full_frame;

/**
* Opts owned draw buffer(s) OUT of DMA-capable memory, falling back to PSRAM instead of
* scarce internal RAM. Default false keeps existing behavior. Only set true if the driver
* never DMAs directly from the buffer pointer LVGL hands it in the flush callback.
*/
bool prefer_external_ram;
};

/**
Expand Down
Loading
Loading