Skip to content

Refactor#12

Merged
bettio merged 59 commits intoatomvm:mainfrom
bettio:refactor
May 3, 2026
Merged

Refactor#12
bettio merged 59 commits intoatomvm:mainfrom
bettio:refactor

Conversation

@bettio
Copy link
Copy Markdown
Collaborator

@bettio bettio commented Apr 15, 2026

Address the excessive code duplication across AtomGL display
drivers by collapsing each hardware family into one driver,
dispatched via a compatible-string lookup that returns a
per-controller descriptor.

  • DCS LCD: 3 drivers (ili934x, ili948x, st7789) -> 1.
  • E-paper: 2 drivers (5in65_acep_7c, gdep073e01) -> 1.
  • OLED I2C: if/else branching on 3 variants -> descriptor table.
  • Sharp memory LCD: file-static state -> embedded state.

Bytes-on-the-wire and existing compatible strings are
unchanged. A new compatible is now ~30 lines of descriptor data,
no new C code. Zero file-statics in any ESP32 driver, so
multi-instance is unblocked.

Also adds support for the Good Display 7.3" 7-color e-paper
panel; use compatible string good-display/gdep073e01.

bettio added 11 commits April 15, 2026 14:09
libAtomVM/utils.h already provides UNUSED.

Signed-off-by: Davide Bettio <davide@uninstall.it>
globalcontext_atomstring_from_term() was removed in libAtomVM 0.7.

Signed-off-by: Davide Bettio <davide@uninstall.it>
font.c was previously included verbatim by every driver via
array in the ESP32 build (and one more in the SDL build). Refactor
into a proper compiled module so the data is linked once.

font.c is renamed to font_data.c with the static qualifier removed
on the array. A new font_data.h declares the array as extern and
exposes FONTDATAMAX. All previous consumers (6 ESP32 drivers + the
SDL display.c) now include font_data.h instead of font.c, and both
CMakeLists.txt entries are updated to compile font_data.c.

Signed-off-by: Davide Bettio <davide@uninstall.it>
display_items.h was previously included by every driver via
init_item() and destroy_items() function bodies in the ESP32 build
(and one more in the SDL build). Refactor into a proper compiled
module so the bodies are linked once.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Extract the send_message helper (identical across 6 ESP32 drivers
and message_helpers.h) into a shared display_message module.
Also remove PendingReply, a vestigial struct defined in 6 files
but never instantiated or referenced.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Extract the FreeRTOS queue, consume_mailbox callback, and
process_messages task into a shared display_task module.  All
seven ESP32 drivers now embed struct DisplayTaskArgs and use
the shared dispatch infrastructure via CONTAINER_OF.

The consume_mailbox implementation adopts the ili948x variant
(non-blocking enqueue with drop-oldest-on-overflow and proper
disposal of dropped messages).

Also removes unused display_enqueue_message from ili934x and
stale mailbox.h include from display_driver.h.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Signed-off-by: Davide Bettio <davide@uninstall.it>
Extract writedata, writecommand, and writecmddata from the DCS
LCD drivers into a shared module using struct SPIDCBus.  Migrate
st7789, ili934x, and ili948x to embed the bus sub-struct.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Extract alpha_blend_rgb565, rgba8888_color_to_rgb565, and
related color helpers into a shared inline-header module.
Drop the unused struct Screen parameter.  Migrate st7789,
ili934x, and ili948x.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Unify the per-driver struct Screen into a shared DCSLCDScreen
that is the superset of all three: st7789's x_offset/y_offset,
ili948x's bytes/bytes_out for RGB888, and the common w/h and
pixel buffers.  Migrate st7789, ili934x, and ili948x.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Extract set_paint_area (with offset support from st7789) and
draw_buffer (with RGB888 path from ili948x) into a shared
module.  Consolidate generic MIPI DCS command macros under a
DCS_LCD_ prefix.  Zero-init DCSLCDScreen allocation for safe
access to uninitialized offset fields.  Migrate st7789,
ili934x, and ili948x.

Signed-off-by: Davide Bettio <davide@uninstall.it>
@petermm
Copy link
Copy Markdown
Contributor

petermm commented Apr 17, 2026

per AMP:

PR Review — Branch pr/12 (52 commits)

Reviewer: Amp (AI-assisted)
Date: 2026-04-17
Scope: ~4100 lines added, ~4500 removed across 46 files


Summary

This PR is a major architectural refactor that:

  1. Consolidates duplicated per-driver boilerplate (message queues, mailbox consumption, process_messages loops, send_message helpers) into shared modules: display_task, display_message, display_items.
  2. Replaces per-driver if/else init code with data-driven descriptor tables and compact byte-array init sequences for OLED (SSD1306/SSD1315/SH1106), DCS LCD (ILI9341/9342C/9486/9488/ST7789/ST7796), and e-paper (ACeP 5.65", GDEP073E01).
  3. Adds uFontLib custom font support to all ESP32 drivers (previously SDL-only).
  4. Fixes several bugs: off-by-one in draw_scaled_cropped_img_x, memory leaks in e-paper do_update, ufont crash on allocation failure, inverted IFF validation logic in ufont_parse, ufont text ignoring background color.
  5. Removes legacy drivers: ili934x_display_driver.c, ili948x_display_driver.c, st7789_display_driver.c, 5in65_acep_7c_display_driver.c, draw_common.h, monochrome.h, message_helpers.h.

Overall assessment: The refactoring direction is excellent — it significantly reduces code duplication and makes adding new display controllers trivial. The init-sequence table design is sound (with _Static_assert guardrails). Several issues should be addressed before or shortly after merge.


Architecture Highlights (Positive)

  • CONTAINER_OF pattern for recovering the driver struct from ctx->platform_data is clean and avoids unnecessary casts.
  • Init sequence format is well-designed: DCS LCD uses a sentinel 0x00 end marker (safe since NOP is not a real init command), while e-paper and OLED use length-framing (because 0x00 is PSR on Waveshare/GoodDisplay controllers). The _Static_assert on sequence sizes is a strong guardrail against byte-counting errors.
  • Descriptor tables (OLEDDesc, DCSLCDDesc, EPaperDesc) capture all controller variation as pure data, eliminating function-pointer complexity.
  • display_task.c cleanly abstracts the FreeRTOS task+queue pattern with a function-pointer callback for per-driver message handling, plus intercepts register_font generically.

Issues

🔴 P0 — uFont Global State & Lifetime

File: display_task.c:33, display_items.c:31

ufont_manager is a global variable shared across all display drivers. Each call to display_task_process_messages overwrites it (line 106). With multiple display ports, the last task to start wins and earlier registrations are lost.

Additionally, ufont_parse() stores raw pointers into the passed buffer (ufontlib.c), but the font binary comes from an Erlang message that is disposed immediately after try_handle_register_font returns (line 116-118). If the AtomVM runtime reclaims that memory, all registered fonts become use-after-free.

Recommendation: Make the font manager per-task (embed in DisplayTaskArgs), and deep-copy the font binary before parsing:

// display_task.h
struct DisplayTaskArgs
{
    QueueHandle_t messages_queue;
    void (*process_message_fn)(Message *message, Context *ctx);
    Context *ctx;
#ifdef ENABLE_UFONT
    UFontManager *ufont_manager;
#endif
};

// display_task.c — in try_handle_register_font:
size_t sz = term_binary_size(font_bin);
void *owned = malloc(sz);
if (!owned) { /* reply error, return true */ }
memcpy(owned, term_binary_data(font_bin), sz);
EpdFont *loaded_font = ufont_parse(owned, sz);
if (!loaded_font) { free(owned); /* reply error */ }

🔴 P0 — Uninitialized Display Items on Error Paths

File: display_items.c

display_items_init_item() sets only owns_data = false at entry (line 69), then has multiple early returns on error (unsupported format: lines 93-96, 128-132; invalid text: 163-165; failed ufont alloc: 201-205; failed ufont draw: 217-219). The caller (do_update) still includes these items in the render loop, which reads uninitialized primitive, width, height, x, y fields.

Suggested fix — initialize sentinel at function entry:

 void display_items_init_item(BaseDisplayItem *item, term req, Context *ctx)
 {
+    item->primitive = PrimitiveInvalid;
+    item->x = -1;
+    item->y = -1;
+    item->width = 0;
+    item->height = 0;
+    item->brcolor = 0;
     item->owns_data = false;
+    memset(&item->data, 0, sizeof(item->data));
+    item->source_x = item->source_y = 0;
+    item->x_scale = item->y_scale = 1;

Also: lines 186-190 leak text on the "unsupported font" path, and line 217-219 leaks surface.buffer on draw failure.


🟡 P1 — Silent Message Loss on Queue Overflow

File: display_task.c:42-57

When the queue is full, display_task_consume_mailbox drops the oldest message (disposing it) to make room. For gen_call messages, the caller's Erlang process will block forever waiting for a reply that never comes.

// Current: silently drops oldest message
if (xQueueSend(args->messages_queue, &msg, 0) != pdTRUE) {
    Message *old = NULL;
    if (xQueueReceive(args->messages_queue, &old, 0) == pdTRUE && old) {
        // ... dispose old, no reply sent ...
    }

Recommendation: Either use backpressure (block with a timeout) or send an error reply before disposing:

// Option A: backpressure (simplest, matches old behavior)
xQueueSend(args->messages_queue, &msg, portMAX_DELAY);

🟡 P1 — Port Creation Succeeds Even When Init Fails

Files: dcs_lcd_display_driver.c:272-278, epaper_display_driver.c:415-421, oled_display_driver.c (equivalent)

Each *_create_port() function creates a Context, calls display_init(), and returns the context unconditionally. But display_init() has multiple early return paths (invalid compat string, bad GPIOs, unsupported rotation, I2C/SPI failure). On any of these, the caller receives a live port with no worker task, a partial driver struct, and leaked allocations.

Recommendation: Have display_init() return bool and return NULL from *_create_port() on failure, freeing any partial allocations.


🟡 P1 — Pixel Write Boundary Check Off-by-One

Files: mono_draw.c:71, epaper_draw.c:37

Both use > instead of >=:

// mono_draw.c:71
if (xpos > screen->w) {   // allows write at xpos == w (out of bounds)

// epaper_draw.c:37
if (xpos > screen->w) {   // same issue

Fix:

-    if (xpos > screen->w) {
+    if (xpos >= screen->w) {

🟢 P2 — Typo in Error Message

File: display_driver.c:73

ESP_LOGE(TAG, "No matching display driver for given `comptaible`: `%s`.", compat_string);

Fix:

-    ESP_LOGE(TAG, "No matching display driver for given `comptaible`: `%s`.", compat_string);
+    ESP_LOGE(TAG, "No matching display driver for given `compatible`: `%s`.", compat_string);

🟢 P2 — Missing malloc/heap_caps_malloc NULL Checks

Several allocations are unchecked:

File Line(s) Allocation
dcs_lcd_display_driver.c 312, 318-319, 321-322 calloc(driver), heap_caps_malloc(pixels/pixels_out), heap_caps_malloc(bytes/bytes_out)
dcs_lcd_commands.c 64, 88 heap_caps_malloc(tmpbuf) for DMA transfer
epaper_display_driver.c 205, 295, 345 heap_caps_malloc(buf), malloc(driver)
oled_display_driver.c ~118 malloc(buf) in do_update — leaks items on early return

On ESP32 with limited heap, heap_caps_malloc(MALLOC_CAP_DMA) can realistically fail.


🟢 P2 — oled_display_driver.c Leaks buf and items on I2C Failure

File: oled_display_driver.c (do_update), around line 124

if (i2c_driver_acquire(driver->i2c_host, &i2c_num, ctx->global) != I2CAcquireOk) {
    fprintf(stderr, "Invalid I2C peripheral\n");
    return;  // leaks: buf, items (and items' owned data)
}

Fix:

 if (i2c_driver_acquire(driver->i2c_host, &i2c_num, ctx->global) != I2CAcquireOk) {
     fprintf(stderr, "Invalid I2C peripheral\n");
+    free(buf);
+    display_items_delete(items, len);
     return;
 }

🟢 P2 — dcs_lcd_draw.c:140 — Misleading Variable Name

uint16_t *pixmem32 = (uint16_t *) ...;  // named "32" but is actually 16-bit

Carried over from the old st7789_display_driver.c. Trivial rename:

-    uint16_t *pixmem32 = (uint16_t *) (((uint8_t *) screen->pixels) + xpos * sizeof(uint16_t));
+    uint16_t *pixmem16 = (uint16_t *) (((uint8_t *) screen->pixels) + xpos * sizeof(uint16_t));

(And update the two references at lines 162, 164.)


🟢 P2 — uFont Parser Minimum Size Guard

File: ufontlib.c:602

ufont_parse() reads at offsets 0 and 8 without checking buf_size >= 12:

 EpdFont *ufont_parse(const void *iff_binary, int buf_size)
 {
+    if (!iff_binary || buf_size < 12) {
+        return NULL;
+    }
     if (!ufont_iff_is_valid_ufl(iff_binary)) {
         return NULL;
     }

Design Notes (Non-blocking)

find_max_line_len receives i not items_len — intentional

In all draw modules (dcs_lcd_draw.c:243, epaper_draw.c:308, mono_draw.c:333), the call passes i (current item index) as the length argument:

int max_line_len = below ? 1 : dcs_lcd_find_max_line_len(screen, items, i, xpos, ypos);

This is intentional: it scans only items with lower index (higher z-order / painted first) to determine the maximum contiguous pixel run before hitting an overlapping item. The parameter name items_len in the function signature is somewhat misleading but the logic is correct and matches the SDL reference driver.

Duplicate compatible-string tables

display_driver.c duplicates the family-specific compatible tables (dcs_lcd_compat_table, oled_compat_table, epaper_compat_table) as an if/else chain. This creates a maintenance burden — adding a new controller requires editing two places. A future improvement could be a single registration table, but this is fine for now.

context_make_atom helper

display_items.h:31-34 keeps a // TODO: deprecated helper wrapper. Consider removing it and inlining globalcontext_make_atom at call sites as a follow-up.


Summary of Recommendations

Priority Issue Effort
🔴 P0 uFont global state / use-after-free Medium
🔴 P0 Uninitialized display items on error paths Small
🟡 P1 Silent message loss (queue overflow) Small
🟡 P1 Port creation succeeds despite init failure Medium
🟡 P1 Pixel write off-by-one (>>=) Trivial
🟢 P2 Typo comptaible Trivial
🟢 P2 Missing malloc NULL checks Small
🟢 P2 OLED do_update leaks on I2C failure Trivial
🟢 P2 Misleading pixmem32 name Trivial
🟢 P2 uFont parser min-size guard Trivial

bettio added 18 commits May 2, 2026 13:52
Extract the per-pixel scanline renderer (draw_x dispatcher and
five draw_*_x helpers) into a shared module.  All functions
take const struct DCSLCDScreen *screen as their first parameter.
Migrate st7789, ili934x, and ili948x.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Add driver for the Good Display GDEP073E01 7.3-inch ACeP panel
(800x480, 7-color e-ink).

Signed-off-by: Davide Bettio <davide@uninstall.it>
Add epaper_screen (shared type), epaper_color (palette-
parameterized ACeP 7-color dithering), and epaper_draw
(per-pixel draw pipeline).  Extend spi_dc_driver with
spi_dc_writedatan for variable-width writes.  Migrate
5in65_acep_7c and gdep073e01 drivers.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Extract the monochrome draw_x dispatcher, five draw_*_x helpers,
and Bayer 4x4 dithering into a shared module with struct
MonoScreen.  Migrate memory_display and ssd1306 drivers.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The pixels pointer update at the bottom of the inner loop used
j / x_scale, but j is the current iteration's index. After the
for-loop increments j, pixels still pointed at the old source
column. Use (j + 1) / x_scale so the pointer is correct for the
next iteration.

Fixes a leftmost-column artifact visible on 4x-scaled images.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Both e-paper drivers allocated a DMA scanline buffer with
heap_caps_malloc but never freed it. Add the missing free(buf)
after spi_device_release_bus in each function.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Replace the placeholder pure-primary RGB values with real-world
measurements taken from the physical panel. The calibrated values
produce more accurate nearest-color matching during dithering.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Both headers have zero includers after the monochrome draw
functions were extracted into mono_draw. The shared per-pixel
draw pipeline and Bayer dither now live in mono_draw.c/h.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Move ufontlib from sdl_display/ to the component root.  Fix the
inverted magic check in ufont_parse and accept standard IFF
container layout (FORM + size + uFL0 form type).  Wire up
register_font as a pre-dispatch handler in the shared display
task, ufont_manager initialization, and the epd_draw_pixel
rendering callback.  Honor FgColor for anti-aliased text.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Apply lower_snake_case to SPI and SPI+DC helpers, add module
prefixes to display_items, display_task, and display_message
public functions, fix word boundaries in controller-specific
init function names, convert enum values to PascalCase, and
make file-local helpers static.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Add a Style Exception comment explaining that FONTDATAMAX and
fontdata keep their original Linux kernel names (from
lib/fonts/font_8x16.c) to simplify comparison with upstream.

Per C_CODING_STYLE.md "Style Exception Documentation" section.
No code changes.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Convert data_len in spi_display_dma_write/spi_display_write and
items_len in display_items_delete/find_max_line_len/draw_x from
int to size_t.  Rename spi_data parameter to spi_disp.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The SDL build compiled ufontlib.c but never defined ENABLE_UFONT,
so the rendering path in display_items.c was compiled out.  The
SDL driver also carried an outdated duplicate of struct Surface and
epd_draw_pixel that was missing the fg_color field and hardcoding
black.

Remove the ESP_PLATFORM guard from the shared epd_draw_pixel in
display_items.c, remove the stale SDL-local copy, and add
-DENABLE_UFONT to the SDL CMake build.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The ufont rendering path hardcoded brcolor to 0 (transparent) when
storing the pre-rendered glyph surface as a PrimitiveImage.  This
caused the scanline draw_image_x to early-return on the first
transparent pixel, making the text invisible unless every pixel in
the glyph bounding box had non-zero alpha.

Use the background color from the display list tuple instead.

Signed-off-by: Davide Bettio <davide@uninstall.it>
struct SPI was the shared state struct across all drivers. After
the family split, the generic name no longer described the shape
of any given driver. The SSD1306 family also drove it further out
of truth: the driver uses I2C, not SPI.

Rename to family-specific types:

- DCS LCD drivers (ili934x, ili948x, st7789) -> DCSLCDDriver
- E-paper drivers (5in65_acep_7c, gdep073e01) -> EpaperDriver
- SSD1306 family -> OLEDDriver
- memory_lcd -> MemoryLCDDriver

Rename the matching FROM_CTX macros and the local `spi` variable
to `driver`.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The built-in font width constant was duplicated in four draw
modules. Move it next to FONTDATAMAX in font_data.h where it
belongs with the font geometry.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The ufont text rendering path allocates a surface buffer
proportional to the bounding box.  On ESP32 this can exceed
available heap for large text, and the missing NULL check
crashes in memset.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Define a compact byte-array format for controller init sequences
and an interpreter (dcs_lcd_execute_init_seq) that walks the table
sending commands via spi_dc_write_cmd_data.

Transcribe all 6 existing display_init_*() functions into const
byte arrays with _Static_assert on each array size to catch any
miscount at compile time.

Arrays: ILI9341, ILI9342C, ILI9486, ILI9488, ST7789 std,
ST7789 alt gamma 2.  No caller changes yet — the old static
init functions are still in use.

Signed-off-by: Davide Bettio <davide@uninstall.it>
bettio added 26 commits May 2, 2026 13:53
Move DCSLCDScreen from a file-static pointer into the DCSLCDDriver
struct across all three DCS LCD drivers (ili934x, ili948x, st7789).
Removes one global, one heap allocation, and one pointer
indirection per driver. Calloc replaces malloc so the embedded
screen's unused fields (offsets, bytes for non-9488) are
zero-initialised.

Also drop the explicit SWRESET, SLPOUT and DISPON commands each
driver issued manually; they are now part of the controller's init
sequence table. For st7789 this also drops the no-reset-GPIO
SWRESET branch.

Signed-off-by: Davide Bettio <davide@uninstall.it>
git mv st7789_display_driver.c -> dcs_lcd_display_driver.c;
rename the create_port entry point and the log TAG accordingly.
Pure rename -- sitronix,st7789 and sitronix,st7796 dispatch
through the new function; ILI compatibles still route through
their legacy drivers.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Generalise the driver into a single descriptor-driven
implementation that can run any of the six DCS LCD controllers
covered by the DCSLCDDesc table. A compatible-string lookup picks
the descriptor; init sequence, MADCTL bytes, default geometry,
default colour order, SPI clock and pixel size come from it.

Hardware-touching code becomes uniform across controllers: MADCTL
slots feed set_rotation (with 0xFF rejecting an unsupported
rotation), pixel_bytes selects between RGB565 and RGB888 scanline
paths, and the boot flow always emits an explicit INVON/INVOFF.

init_list, init_seq_type, width/height, x_offset/y_offset, and
color_order now apply to every controller (silently fixing the
hardcoded-geometry FIXME the ILI934x driver carried).

Signed-off-by: Davide Bettio <davide@uninstall.it>
Route the four ILI compatibles through the unified
dcs_lcd_display_create_port and drop the now-orphaned ili934x and
ili948x driver files. All six DCS LCD compatibles
(ilitek,ili9341/ili9342c/ili9486/ili9488 and
sitronix,st7789/st7796) are served by a single descriptor-driven
driver, with x_offset/y_offset, color_order, init_list, width/height
and an optional reset GPIO available uniformly.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The two e-paper drivers currently open-code their init sequences
as long chains of spi_dc_write_command/data calls, with
BUSY-polling (gdep073e01) or an embedded delay (acep 5.65")
interleaved between commands. Each additional panel would add
another ~30-60 lines in the same shape.

Extract the sequences to declarative byte arrays in the format
[CMD][FLAGS_LEN][DATA...][optional DELAY_MS], paired with a
size_t length constant. The interpreter takes an extra
wait_busy_between_cmds bool to cover the gdep073e01 convention
without bloating the per-row format. _Static_assert on array
sizes catches miscounted data bytes at build time.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Replace the per-panel chains of spi_dc_write_command/write_data
calls (acep: 35 lines with an inline vTaskDelay; gdep073e01: 14
steps each followed by wait_busy_level) with a single call to
epaper_execute_init_seq for each panel. The byte-array tables
come from epaper_commands.c.

For gdep073e01 the executor polls BUSY between commands via
wait_busy_between_cmds=true. For acep the 100 ms delay between
VDCS (0x82) and the second CDI (0x50) is encoded in the table via
the delay flag. Bytes-on-the-wire are unchanged.

Drop the now-unused gdep073e01 command-byte defines (PSR, PWRR,
POF, POFS, PON, BTST1/2/3, DTM, DRF, PLL, CDI, TCON, TRES, REV,
VDCS, T_VDCS, PWS, DSLP); the frame-protocol sites use raw hex
to match the acep driver.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Introduce struct EPaperDesc with everything a unified driver needs
to dispatch on compatible string: panel name, native dimensions,
SPI clock, palette pointer + size, init sequence, optional
per-frame preamble, and the post-frame refresh flags (DRF data
byte, POF busy-wait level, periodic white-out interval).

Instantiate two descriptors -- epaper_desc_acep7c and
epaper_desc_gdep073e01 -- populated from the constants currently
baked into the two driver files.

The ACeP descriptor also ships a frame_preamble_seq encoding the
0x61 resolution command that today is open-coded in the driver's
do_update and clear_screen paths.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Fold EpaperScreen into struct EpaperDriver as an embedded member
and drop the file-static pointer across both e-paper drivers
(acep, gdep073e01). The driver allocation via malloc() already
reserves space for it, removing the separate calloc() and its
attendant leak-on-driver-free.

A prerequisite for the unified e-paper driver: multi-instance
support needs every per-screen value to live behind the driver
pointer recovered by EPAPER_DRIVER_FROM_CTX, not behind a
file-static reachable from a single translation unit.

Signed-off-by: Davide Bettio <davide@uninstall.it>
git mv gdep073e01_display_driver.c -> epaper_display_driver.c;
rename the create_port entry point and the log TAG accordingly.
Pure rename -- the gdep compatible dispatches through the new
function, and the waveshare,5in65-acep-7c compatible still calls
the separate acep driver file.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Generalise epaper_display_driver.c to drive both compatibles from
the per-panel EPaperDesc descriptors. A compatible-string lookup
picks the descriptor; dimensions, SPI clock, palette, init
sequence, per-frame preamble, periodic-refresh interval, and
post-frame refresh flags all come from it.

The driver also accepts an init_list Erlang-side override in the
tuple format used by the DCS LCD driver, extended with
{wait_busy_level, 0|1} for the BUSY-pin polling that e-paper
controllers typically require mid-init.

Route good-display/gdep073e01 and waveshare,5in65-acep-7c through
the single epaper_display_create_port dispatch entry.

Signed-off-by: Davide Bettio <davide@uninstall.it>
The waveshare,5in65-acep-7c compatible now dispatches through
the unified epaper_display_driver.c, leaving the legacy
5in65_acep_7c_display_driver.c orphaned. Drop it and its
CMakeLists.txt entry.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Introduce oled_commands.h/.c with a length-framed init-sequence
executor and two init arrays (SSD1306/SH1106 minimal, SSD1315
full). The format ([CMD][FLAGS_LEN][DATA...][DELAY_MS]) matches
epaper_commands.h verbatim so the codebase converges on one
init-sequence convention.

Unlike an inline init transaction, the executor owns the I2C
transaction boundary -- one i2c_cmd_link per step -- so that any
future vTaskDelay between steps actually waits between commands
rather than being a no-op inside a queued cmd_link. Current
SSD13xx init steps don't use the DELAY flag.

Each array carries a _Static_assert on sizeof to catch miscounted
data bytes at compile time.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Replace the inline i2c_master_write_byte loop and its long
SSD1315/SSD1306 if/else fork with a single oled_execute_init_seq()
call dispatched on driver->type. Bytes-on-the-wire are unchanged;
the executor opens one i2c_cmd_handle_t per init step instead of
batching the entire sequence into one transaction, which on the
panel side just means extra stop/start conditions between commands.

The optional invert command and the unconditional display ON now
ride in a small post-executor transaction inside display_init.
They depend on a runtime opt and are not panel-specific data, so
they belong with the driver, not the per-variant init array.

Drop CMD_SET_CHARGE_PUMP, CMD_SET_SEGMENT_REMAP, and
CMD_SET_COM_SCAN_MODE: they were only referenced by the inline
init that is being removed here.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Define struct OLEDDesc in oled_commands.h with the per-controller
fields needed to drive SSD1306, SSD1315, and SH1106 from a single
unified driver: dimensions, I2C address, init sequence pointer +
length, and the two runtime-behavior flags that today require
if/else branching on a display_type_t enum (column reset before
each page write; data-stream prefix padding for SH1106's
132-pixel controller exposing a 128-pixel viewport).

Instantiate the three descriptors. SSD1306 and SH1106 share the
same minimal init array (charge-pump enable + segment / COM-scan
remap); SSD1315 uses its own full init.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Fold MonoScreen into struct MemoryLCDDriver as an embedded member
and drop the file-static pointer + its separate calloc. The
per-screen state now travels with the driver pointer recovered by
MEMORY_LCD_DRIVER_FROM_CTX, preparing multi-instance support for
this driver.

While here, accept width/height Erlang opts (defaults 400x240 for
the LS027B4DH01) and resolve the `// FIXME: hardcoded width and
height`. The new opt surface mirrors what DCS LCD already accepts.

struct Screen and its DMA buffers stay at file scope here; they
are specific to the Sharp frame format. vcom likewise stays a
file-global.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Move the Sharp frame-format DMA buffers (pixels, dma_out) and the
VCOM toggle state from file scope into struct MemoryLCDDriver as
embedded members. Drop the struct Screen wrapper entirely; w/h
are carried by the embedded MonoScreen and pixels/dma_out live
directly on the driver.

Rewrite get_vcom() as next_vcom(driver), advancing the toggle bit
on the driver pointer recovered by MEMORY_LCD_DRIVER_FROM_CTX.
All file-level globals are gone; nothing in this translation unit
now outlives the driver pointer.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Fold MonoScreen into struct OLEDDriver as an embedded member and
drop the file-static pointer + its separate calloc. The per-screen
state now travels with the driver pointer recovered by
OLED_DRIVER_FROM_CTX, a prerequisite for multi-instance use.

The controller panels are fixed at 128x64 for all three variants,
so the MonoScreen dimensions are initialised from the existing
DISPLAY_WIDTH / DISPLAY_HEIGHT constants rather than from opts.

Signed-off-by: Davide Bettio <davide@uninstall.it>
Generalise the dispatch to drive all three supported controllers
from the per-controller OLEDDesc descriptors. A compatible-string
lookup replaces the display_type_t enum branching: I2C address,
init sequence, per-page column-reset flag, and scanline prefix
padding all come from the descriptor.

Unknown or missing compatibles now log an ESP_LOGE and early-
return instead of silently defaulting to SSD1306, fixing a path
that quietly masked typos in the Erlang `compatible` opt.

Remove display_type_t, the driver->type field, and the hardcoded
I2C_ADDRESS macro -- all three subsumed by the descriptor.

Signed-off-by: Davide Bettio <davide@uninstall.it>
git mv ssd1306_display_driver.c -> oled_display_driver.c;
rename the create_port entry point and the log TAG accordingly.
Pure rename -- all three OLED compatibles (solomon-systech,ssd1306,
solomon-systech,ssd1315, sino-wealth,sh1106) now dispatch through
oled_display_create_port.

The previous filename attributed a Sino Wealth controller (SH1106)
to Solomon Systech and was obsolete since the file started driving
multiple variants.

Signed-off-by: Davide Bettio <davide@uninstall.it>
display_items_init_item has several early-return paths that left
primitive/x/y/w/h uninitialized. Callers malloc() the items
array, so the draw pipeline read garbage — either tripping the
"unexpected display list command" branch or, on unlucky bit
patterns, dereferencing junk pointers.

memset to zero on entry makes PrimitiveInvalid (= 0) the default
and gives the draw dispatchers a safe skip on the zero-bounds
case.

Also plug two leaks on the same paths: `text` on the
unsupported-font branch, `surface.buffer` on the failed ufont
draw.
ufont_parse stores raw pointers into its input buffer, which then
live in the persistent EpdFont. The buffer is term_binary_data of
a gen_server message that the dispatch loop disposes as soon as
the handler returns: an immediate UAF for heap binaries, and tied
to sender lifetime for refc binaries.

Copy into a heap-owned buffer before parsing. The copy lives with
the font for the lifetime of the display task, matching the
existing "fonts are not unregistered" semantic.
The drop-oldest path in display_task_consume_mailbox can silently
drop an update message to make room for a newer one (rendering a
stale frame is wasted when a fresher one is queued). The dropped
caller was left without a reply and timed out at gen_server:call
— an ugly failure mode for a frame-loss-tolerant design.

Send {Ref, ok} on arrival, before the enqueue-or-drop decision.
Drivers skip the reply on the update path so the caller never
sees a duplicate. draw_buffer gets the same treatment: it is
gen_call-shaped but fire-and-forget at the driver, today timing
out the caller.

register_font and load_image still reply synchronously: they
need to convey the operation outcome (registered handle /
decoded binary) and do not share the drop-for-newer rationale.
Cover the per-frame allocations in do_update, clear_screen, and
dcs_lcd_draw_buffer across all four ESP32 drivers. On failure,
skip the frame with a log line and free any already-acquired
resources. The caller's reply has already been sent at enqueue
time (see the prior pre-ack commit), so there is no reply
obligation.

Also fix an oled do_update early-return on i2c_driver_acquire
failure that leaked both `items` and `buf`.

Init-time allocations in *_create_port / display_init /
display_spi_init remain unchecked; context-teardown-on-init-
failure is tracked as a separate follow-up.
@bettio
Copy link
Copy Markdown
Collaborator Author

bettio commented May 2, 2026

per AMP:

PR Review — Branch pr/12 (52 commits)

I fixed all review comments, except:

  • Port creation succeeds despite init failure (this was already an existing issue)
  • Missing malloc NULL checks, mostly fixed (except parts that intersect "Port creation succeeds despite init failure")

Copy link
Copy Markdown
Contributor

@petermm petermm left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Final PR Review — Branch pr/12

Reviewer: Amp (AI-assisted)
Date: 2026-05-02
Scope: 52 original commits + 7 review-fixup commits, ~4100 lines added / ~4500 removed across 46 files
Verdict:Approve / merge, with two small follow-up tickets noted at the end.


What this PR does

A major architectural refactor of the AtomGL display driver library:

  1. Boilerplate consolidation — per-driver mailbox handling, message queues, and process_messages loops moved into shared display_task / display_message / display_items modules.
  2. Data-driven init sequences — replaces per-controller if/else ladders with descriptor tables and compact [CMD][FLAGS_LEN][DATA…][DELAY] byte arrays for OLED (SSD1306/SSD1315/SH1106), DCS LCD (ILI9341/9342C/9486/9488/ST7789/ST7796), and e-paper (ACeP 5.65" / GDEP073E01). _Static_assert(sizeof(...)) guards every sequence.
  3. uFontLib custom font support added to all ESP32 drivers (previously SDL-only).
  4. Bug fixes carried in the original 52 commits: off-by-one in draw_scaled_cropped_img_x, e-paper memory leaks in do_update, ufont allocation-failure crash, inverted IFF magic check (!ufont_iff_is_valid_uflufont_iff_is_valid_ufl), ufont text ignoring background color.
  5. Legacy drivers removed: ili934x_display_driver.c, ili948x_display_driver.c, st7789_display_driver.c, 5in65_acep_7c_display_driver.c, draw_common.h, monochrome.h, message_helpers.h.

Review fixup commits — what landed after first review

Commit Issue priority Summary Status
a3a61e3 🟡 P1 Off-by-one in draw_pixel_x: >>= in mono_draw.c and epaper_draw.c ✅ Verified
176acab 🟢 P2 ufont_parse: guard against !iff_binary || buf_size < 12 ✅ Verified
0c78709 🟢 P2 Typo: comptaiblecompatible in display_driver.c log ✅ Verified
e753bc8 🔴 P0 display_items_init_item: memset(item, 0, sizeof(*item)) on entry → PrimitiveInvalid (= 0) is the safe default; also plugs text and surface.buffer leaks ✅ Verified
6c0e995 🔴 P0 register_font: deep-copy font binary into a heap-owned buffer before ufont_parse. Resolves UAF after the gen_call message is disposed. ✅ Verified
9bd2e1e 🟡 P1 Pre-ack update / draw_buffer at enqueue time so callers never time out when the drop-oldest queue evicts a stale frame. Drivers add return to skip duplicate replies. ✅ Verified
b01a698 🟢 P2 NULL-check per-frame allocations in do_update / clear_screen / dcs_lcd_draw_buffer across all four ESP32 drivers; fixes OLED leak-on-i2c-failure ✅ Verified

Notes on the pre-ack design (9bd2e1e)

The "reply at enqueue" approach is cleaner than the original "use backpressure" suggestion: it preserves the frame-loss-tolerant intent of the drop-oldest queue while ensuring gen_server:call callers never block. Two correctness checks pass:

  • Each driver's process_message adds return after the update branch and after draw_buffer so the post-switch OK_ATOM reply tuple is not sent twice.
  • try_pre_ack_render_cmd matches only update / draw_buffer. register_font and load_image keep their synchronous reply because they must convey the operation outcome.

Architecture highlights (positive)

  • CONTAINER_OF pattern for recovering each driver struct from ctx->platform_data is clean and avoids unsafe casts.
  • Init sequence framing is well-chosen: DCS LCD uses a 0x00 end sentinel (safe — NOP isn't used as init), e-paper and OLED use length-framing (necessary because 0x00 is PSR / a valid init command on Waveshare/GoodDisplay parts). _Static_assert(sizeof(...)) catches byte-counting mistakes at compile time.
  • Descriptor tables capture all controller variation as pure data; no function pointers needed across the current set.
  • display_task.c cleanly abstracts the FreeRTOS task+queue pattern with a per-driver function-pointer for process_message, plus generic interception of register_font.

Outstanding follow-ups

The contributor explicitly deferred two items. Both are acceptable for merge; both deserve a tracking ticket.

Follow-up #1 — Per-task ufont_manager (was 🔴 P0, partial)

File: display_task.c

ufont_manager is still a process-global that every display task overwrites in display_task_process_messages line 148:

UFontManager *ufont_manager;                          // line 33

void display_task_process_messages(void *arg)
{
    struct DisplayTaskArgs *args = arg;

    ufont_manager = ufont_manager_new();              // line 148 — last task wins
    ...
}

display_items.c reads the same global:

#ifdef ENABLE_UFONT
#include "ufontlib.h"
extern UFontManager *ufont_manager;                   // display_items.c:31

Why this is fine for now: in the field, only one display port is created per VM, so the race is latent. The deep-copy fix already eliminates the use-after-free, which was the user-visible bug.

Why it should still be fixed eventually: any future use case with two displays (e.g. e-paper + small status OLED) will silently lose font registrations from the first-started task.

Suggested fix — embed the manager in DisplayTaskArgs:

--- a/display_task.h
+++ b/display_task.h
@@ -27,11 +27,16 @@
 #include <context.h>
 #include <mailbox.h>

+#ifdef ENABLE_UFONT
+#include "ufontlib.h"
+#endif
+
 struct DisplayTaskArgs
 {
     QueueHandle_t messages_queue;
     void (*process_message_fn)(Message *message, Context *ctx);
     Context *ctx;
+#ifdef ENABLE_UFONT
+    UFontManager *ufont_manager;
+#endif
 };

 NativeHandlerResult display_task_consume_mailbox(Context *ctx);
--- a/display_task.c
+++ b/display_task.c
@@ -30,8 +30,6 @@
 #include "display_message.h"
 #include "ufontlib.h"

-UFontManager *ufont_manager;
-
 static bool try_pre_ack_render_cmd(Message *message, Context *ctx)
 {
     ...
@@ -127,7 +125,8 @@ static bool try_handle_register_font(Message *message, Context *ctx)
     char *handle = interop_atom_to_string(ctx,
             term_get_tuple_element(req, 1));
     if (loaded_font != NULL && handle != NULL) {
-        ufont_manager_register(ufont_manager, handle, loaded_font);
+        struct DisplayTaskArgs *args = ctx->platform_data;
+        ufont_manager_register(args->ufont_manager, handle, loaded_font);
     }
     free(handle);

@@ -145,7 +144,7 @@ void display_task_process_messages(void *arg)
 {
     struct DisplayTaskArgs *args = arg;

-    ufont_manager = ufont_manager_new();
+    args->ufont_manager = ufont_manager_new();

     while (true) {
         Message *message;
--- a/display_items.c
+++ b/display_items.c
@@ -28,8 +28,7 @@
 #include <interop.h>

 #ifdef ENABLE_UFONT
-#include "ufontlib.h"
-extern UFontManager *ufont_manager;
+#include "display_task.h"

 struct Surface
 ...
@@ -178,8 +177,9 @@ void display_items_init_item(BaseDisplayItem *item, term req, Context *ctx)
         } else {
 #ifdef ENABLE_UFONT
             char *handle = interop_atom_to_string(ctx, font);
+            struct DisplayTaskArgs *args = ctx->platform_data;
             EpdFont *loaded_font = NULL;
             if (handle != NULL) {
-                loaded_font = ufont_manager_find_by_handle(ufont_manager, handle);
+                loaded_font = ufont_manager_find_by_handle(args->ufont_manager, handle);
                 free(handle);
             }

Effort: S (≈1h, one focused commit). No new tests needed — existing single-display path keeps working byte-identically.


Follow-up #2 — Context teardown on init failure (was 🟡 P1, deferred)

The contributor flagged this themselves in the b01a698 commit message:

"Init-time allocations in *_create_port / display_init / display_spi_init remain unchecked; context-teardown-on-init-failure is tracked as a separate follow-up."

Symptom: each *_create_port() always returns a live Context *, even when display_init() hit an early return (invalid compatible string, missing GPIO, I2C/SPI failure, unsupported rotation). Callers receive a port with:

  • no worker xTaskCreate,
  • partial / NULL platform_data,
  • leaked screen buffers, queues, and SPI device handles.

Subsequent mailbox traffic to that port silently hangs (the new pre-ack does not run because the worker never started).

Affected files:

  • dcs_lcd_display_driver.cdisplay_init has 4 early returns
  • epaper_display_driver.cdisplay_spi_init has 2 early returns
  • oled_display_driver.cdisplay_init has 2 early returns
  • memory_display_driver.c — implicit, only one failure path

Suggested approach — make init return bool, return NULL from *_create_port on failure:

--- a/dcs_lcd_display_driver.c
+++ b/dcs_lcd_display_driver.c
-static void display_init(Context *ctx, term opts);
+static bool display_init(Context *ctx, term opts);

 Context *dcs_lcd_display_create_port(GlobalContext *global, term opts)
 {
     Context *ctx = context_new(global);
     ctx->native_handler = display_task_consume_mailbox;
-    display_init(ctx, opts);
+    if (!display_init(ctx, opts)) {
+        context_destroy(ctx);
+        return NULL;
+    }
     return ctx;
 }

Then convert each return; in display_init() to goto cleanup; (or equivalent), and add a single cleanup tail:

static bool display_init(Context *ctx, term opts)
{
    struct DCSLCDDriver *driver = NULL;
    bool ok = false;

    /* … existing init flow, on any failure: goto cleanup … */

    ok = true;

cleanup:
    if (!ok && driver != NULL) {
        if (driver->display_args.messages_queue) {
            vQueueDelete(driver->display_args.messages_queue);
        }
        free(driver->screen.pixels);
        free(driver->screen.pixels_out);
        free(driver->screen.bytes);
        free(driver->screen.bytes_out);
        /* spi_bus_remove_device(driver->bus.spi_disp.handle) if init'd */
        free(driver);
    }
    return ok;
}

Apply the same shape to the other three drivers. The dispatcher in display_driver.c already treats NULL from a *_create_port as failure (the parent display_create_port returns NULL directly when no compatible matches), so no caller change is needed.

Effort: M (≈half a day, four similar commits). Worth doing because today this is the single class of "silent hang on misconfiguration" most likely to confuse new users wiring up a panel.


Items still in scope but explicitly non-blocking

These are nice-to-haves noted in the original review and not addressed in fixups:

  • display_driver.c duplicates the family-specific compatible strings as an if/else chain. A single registration table would be cleaner but is fine for the current 11-controller set.
  • display_items.h:31-34 keeps a // TODO: deprecated helper context_make_atom wrapper. Can be inlined as a follow-up.
  • dcs_lcd_draw.c:140 has a pixmem32 variable that holds 16-bit pixels — a misleading carry-over name; trivial rename:
--- a/dcs_lcd_draw.c
+++ b/dcs_lcd_draw.c
@@ -137,7 +137,7 @@ int dcs_lcd_draw_text_x(const struct DCSLCDScreen *screen,

     int drawn_pixels = 0;

-    uint16_t *pixmem32 = (uint16_t *) (((uint8_t *) screen->pixels) + xpos * sizeof(uint16_t));
+    uint16_t *pixmem16 = (uint16_t *) (((uint8_t *) screen->pixels) + xpos * sizeof(uint16_t));

     if (width > xpos - x + max_line_len) {
         width = xpos - x + max_line_len;
@@ -159,9 +159,9 @@ int dcs_lcd_draw_text_x(const struct DCSLCDScreen *screen,
         }

         if (opaque) {
-            pixmem32[drawn_pixels] = fgcolor;
+            pixmem16[drawn_pixels] = fgcolor;
         } else if (visible_bg) {
-            pixmem32[drawn_pixels] = bgcolor;
+            pixmem16[drawn_pixels] = bgcolor;
         } else {
             return drawn_pixels;
         }

Recommendation

Merge pr/12. All five P0/P1 issues from the first review are resolved or have a clear, low-risk path forward. The two outstanding items (per-task ufont_manager, init-failure teardown) should be filed as follow-up tickets but do not block.

Suggested ticket titles:

  1. display_task: per-task ufont_manager (multi-display safety)
  2. display drivers: clean teardown on init failure (return NULL from *_create_port)

@bettio bettio merged commit eb901e7 into atomvm:main May 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants