Skip to main content

The Display Stack

The display path is four layers, each replaceable without touching the others:

Display stack layers
ui_screens.c what to show (LVGL widgets, screens, fonts)
LVGL 9 how to render (layout, text shaping, draw)
esp_lvgl_port when to flush (task, buffer, DMA-done wiring)
esp_lcd + panel drv how to transmit (SPI transactions, panel commands)
ST7735S the glass (128x160 RGB565)

Why LVGL, and why version 9

The first display stack was hand-rolled: a framebuffer, a text renderer, manual widget drawing. It produced the project's founding bug report: garbled frames, tearing, and Arabic rendered as disconnected letters. The postmortem found three independent causes (framebuffer in PSRAM being DMA'd, full-frame retransmits racing updates, and no Arabic shaping at all), and the conclusion was that all three are solved problems inside a mature UI library.

LVGL was chosen over the alternatives for three specific capabilities: bidirectional text and Arabic contextual shaping built in (the killer feature for this product; see the Arabic chapter), a first-party ESP-IDF port maintained by Espressif (esp_lvgl_port), and a rendering model designed around partial buffers on RAM-starved chips. Version 9 specifically, because its draw pipeline and the port's DMA integration are current; the 8.x line is in maintenance.

The panel driver decision

ESP-IDF's esp_lcd framework splits "panel IO" (SPI transactions) from "panel driver" (the controller's command set). LVGL's port speaks to any esp_lcd panel handle. For the ST7735S there were three options:

  1. LVGL's own ST7735 driver: bypasses esp_lcd, so the port's DMA wiring is lost. Rejected.
  2. Reuse ESP-IDF's ST7789 driver (a cousin controller): close, but its init sequence and gamma tables are for different glass.
  3. A minimal custom esp_lcd panel driver (lcd_st7735s.c, ~200 lines implementing the panel vtable): keeps the port's DMA path and the exact init sequence, gamma curves, and the panel's 2-row physical offset that this specific glass was tuned with.

Option 3 won because the init sequence is where hundreds of trial hours live: frame rate control, power sequencing, both gamma tables. Those values came from bring-up against the physical panel, and no generic driver carries them. The lesson generalizes: with esp_lcd, a custom panel driver is cheap; custom plumbing is what to avoid.

Two panels, one build flag

The build-time choice SAFI_LCD_PANEL selects between the verified ST7735S and the 1.47 inch ST7789V3 IPS module (172x320, the GMT147SPI class, 8 pins, no MISO, no SD slot). Everything the resolution touches derives from one header (display_ui.h): the SPI bus transfer size, the LVGL buffer, the screenshot row buffer, and the widget layout.

The ST7789 path reuses option 2 from the list above, and this time it is the right call: ESP-IDF ships an esp_lcd ST7789 driver whose vtable is richer than the custom ST7735S one, and IPS ST7789 modules work fine on controller-default gamma. What the IDF driver deliberately does not do, the glue adds after esp_lcd_panel_init():

esp_lcd_panel_set_gap(panel, 34, 0); // 172 columns centered in 240x320 RAM
esp_lcd_panel_invert_color(panel, true); // IPS stack needs INVON

The 34 is (240 - 172) / 2: the module's glass is centered in the controller's memory, which also makes the offsets rotation-invariant. Everything downstream (byte swap, DMA, flush) is byte-for-byte the verified ST7735S pipeline.

Layout scales instead of forking. The screens were designed on 128x160; two macros, UI_SX(n) = n * width / 128 and UI_SY(n) = n * height / 160, scale every offset. On the ST7735S both reduce to the identity at compile time, so the verified layout cannot regress; on the taller panel, vertical positions stretch 2x while round and font-coupled sizes (spinner, volume arc, bar heights) follow the width ratio to avoid distortion. Fonts follow the width ratio too (arabic 18/24/30 plus Montserrat 16/20 instead of 14/18/22 plus 12/14): the 1.47 inch glass is physically narrower at 2.2x the dot pitch, so matching the relative footprint keeps strings fitting while the higher density keeps them crisp. Each build compiles exactly one panel driver and one font set; the ST7789 image costs about 290 KB more flash, all fonts.

One budget note: a quarter frame at 172x320 would be 27.5 KB of internal DMA RAM, most of the headroom TLS and audio live on. The ST7789 build therefore renders in eighth-frame slices (13.8 KB). The panel is SPI-bound either way, so slice count is not the bottleneck; if scrolling Arabic ever shears visibly on real glass, the divisor is a one-line change.

Why a build flag and not runtime detection: both panels are write-only (no MISO), so there is nothing to probe. A Kconfig choice costs zero bytes at runtime and CI compiles both variants on every push.

The buffer strategy, and the PSRAM corruption story

LVGL renders into a draw buffer, then the port DMAs it to the panel. Where that buffer lives is the whole game on this chip:

  • A full frame is 128 x 160 x 2 bytes (RGB565) = 40 KB.
  • The original stack allocated it in PSRAM, because 40 KB of internal RAM hurts. The result was the "dislocated box with noise" screenshots in the original bug report: the ESP32-S3's SPI DMA cannot coherently read PSRAM; transfers raced the cache and shipped stale or torn lines to the glass. The failure is intermittent, load-dependent, and looks exactly like a broken panel, which is why it survived so long.
  • The rule since (now written into tasks and memory): DMA buffers are internal, always.

With the buffer forcibly internal, size becomes a budget question. Safi uses a quarter-frame buffer (10 KB): LVGL renders dirty regions in up to four slices, the port streams each slice as it completes. Cost: a full-screen redraw takes four DMA passes instead of one. Benefit: 30 KB of internal RAM returned to the TLS and audio budgets, which was the difference between HTTPS handshakes succeeding and failing during playback. On a 128x160 panel the extra passes are invisible; ~25 ms full-frame at 10 MHz SPI either way, and typical UI updates dirty far less than a full frame.

swap_bytes is set because the panel wants big-endian RGB565 while LVGL renders little-endian; the port swaps during the copy, free.

Flush discipline

esp_lvgl_port owns the render task (taskLVGL, priority 4, pinned to core 1, per the task map) and the DMA-done callback that releases each slice. Two properties matter to the rest of the system:

  • Rendering never blocks the audio core; worst case it delays its own next frame.
  • All LVGL API calls from other tasks go through the port's lock with a bounded 200 ms timeout, a discipline enforced by the UI layer.

Backlight

The backlight pin is driven by the LEDC PWM peripheral at 5 kHz, 8-bit duty: flicker-free dimming for free, brightness as a 0-255 setting, and the dim/off policy timed by display_task. Hardware PWM rather than software toggling because the peripheral exists and costs nothing.

The test pattern escape hatch

CONFIG_SAFI_DISPLAY_BOOT_TEST_PATTERN draws raw color bars through the panel driver, below LVGL, at boot. It exists for hardware bring-up: when a new panel shows garbage, the pattern splits the question "is it wiring/init or is it the UI stack?" in one look. It defaults off and costs boot time when on, which is why it is a build option and not a setting.