Skip to content

fix(m5stack-tab5): rotation audit, experimental opt-in ST7121 panel rotation (default off), and DSI underrun fixes - #738

Merged
finger563 merged 19 commits into
mainfrom
feat/tab5-st7121-rotation
Nov 2, 2026
Merged

finger563 merged 19 commits into
mainfrom
feat/tab5-st7121-rotation

Conversation

@finger563

@finger563 finger563 commented Aug 28, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Audit of how the M5Stack Tab5's three display variants (ILI9881 / ST7123 / ST7121) handle rotation, plus fixes for DSI scan-out underrun (display streaking) found while testing on hardware.

The branch originally routed 0°/180° rotation on the ST7121 TDDI variant to the panel itself (MADCTL GS/SS scan-direction flip) instead of the ESP32-P4 PPA. Hardware testing showed the 180° MADCTL flip renders corrupted on (at least some) ST7121 units — the init sequence programs full TDDI gate/source mux tables (the 0xAC block) matched to the normal scan direction, and a MADCTL GS flip alone reorders gate scanning without swapping them. Panel-side rotation is therefore experimental and opt-in, gated behind CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION (default off). The default build keeps the pre-branch known-good behavior: PPA rotation for all orientations and zero runtime MADCTL writes.

What landed

Rotation audit + plumbing (default behavior unchanged)

  • Audit conclusions documented in code: these are MIPI-DSI video-mode (DPI) panels — a fixed 720x1280 raster with no axis swap — so panel-side rotation could only ever cover 0/180; 90/270 must stay on the PPA. And on the ST7121 specifically, the init-time gate/source mux tables make even runtime 0/180 MADCTL flips unsafe.
  • Wired Display::LvglConfig::rotation_callback to a new M5StackTab5::on_display_rotation(); the rotation decision is centralized in a single predicate (panel_handles_rotation()) shared by the flush path and the callback.
  • Enum unification (DisplayRotation <-> lv_display_rotation_t mapping pinned down with explicit converters) and init-order hardening: the current rotation is (re)applied when the display driver comes up, correct in either initialize_lcd() / initialize_display() order, with the thread-safety of the init-path call documented at the call site.
  • Experimental opt-in: CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION (default n) with the corruption warning in its Kconfig help. When enabled, the ST7121 applies 0/180 via the display driver's set_rotation() (MADCTL) and flush() skips the PPA for those orientations.
  • ILI9881 / ST7123 behavior unchanged; touch mapping (touchpad_convert) is invariant to the transform mechanism; camera PPA pass unaffected.

DSI underrun / streaking fixes (active by default)

Streaking traced to DSI bridge FIFO underrun from PSRAM read contention against the DPI panel's continuous ~140 MB/s framebuffer scan-out. Three traffic reductions:

  • DMA2D re-enabled for draw_bitmap: IDF 6.0 removed flags.use_dma2d in favor of esp_lcd_dpi_panel_enable_dma2d(), which was never called — every flush had silently regressed to a CPU memcpy (~5.4 MB of PSRAM/cache traffic per full frame).
  • PPA rotates directly into the DPI framebuffer (output block placement) instead of PPA -> scratch buffer -> draw_bitmap copy, halving the rotation path's PSRAM traffic.
  • PPA burst length 128 -> 64 on both PPA clients (display + camera); full-length PPA bursts are known to starve the DSI scan-out even at 200 MHz hex PSRAM ((draw/ppa) cause DSI underrun under heavy load on ESP32-P4 lvgl/lvgl#9590).

Hardware testing

  • ST7121 unit: 180° MADCTL scan flip renders corrupted -> panel-side rotation gated off by default (see PR discussion for the full root-cause analysis and the streaking diagnosis / escalation ladder).

Testing

  • Tab5 example builds clean in both configurations (CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION off and on) with ESP-IDF 6.0, esp32p4.

🤖 Generated with Claude Code

…21 variant

All three Tab5 display variants previously rotated every non-zero LVGL
orientation at flush time via the ESP32-P4 PPA (with an lv_draw_sw_rotate
fallback); the espp::Display rotation_callback was left null, so the
display driver's set_rotation() (MADCTL) support was never used.

Route rotation through the espp::Display machinery for the ST7121:

- Wire the Display rotation_callback to a new on_display_rotation() which,
  for the ST7121, forwards 0/180 to the driver's set_rotation() (MADCTL
  GS/SS scan-direction flip done by the panel itself) and restores the
  natural scan direction for 90/270.
- Skip the PPA/software buffer rotation in flush() when the panel handles
  the current rotation (new panel_handles_rotation()); the logical frame is
  then written unrotated and the panel flips it at scan-out, which lands
  partial areas exactly where LVGL's rotated mapping expects them.
- 90/270 keep the PPA path: the Tab5 panels are MIPI-DSI DPI (video mode)
  panels streaming a fixed 720x1280 raster, so the panel cannot swap axes.

The ILI9881 and ST7123 variants keep their existing PPA rotation path, and
the touch transform is unchanged: touchpad_convert() maps physical to
logical coordinates from the LVGL rotation, which is independent of how
the display rotation is implemented.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Copilot AI lite review requested due to automatic review settings August 28, 2026 16:18
@github-actions

github-actions Bot commented Aug 28, 2026 •

Copy link
Copy Markdown

✅Static analysis result - no issues found! ✅

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Warning

Copilot couldn't run its full agentic review because it didn't start before the timeout. Make sure your repository has a runner available, or add a copilot-code-review.yml file specifying one with the runs-on attribute. See the docs for more details.

This PR updates M5Stack Tab5 rotation handling so the ST7121 (TDDI) panel uses the panel’s MADCTL scan-direction flip for 0°/180° rotation, avoiding PPA/software rotation artifacts with partial flush areas.

Changes:

  • Wire Display::LvglConfig::rotation_callback to M5StackTab5::on_display_rotation() to forward 0°/180° rotation to the ST7121 driver.
  • Add panel_handles_rotation() to decide when to skip PPA/software rotation.
  • Update flush() to bypass rotation when the panel applies it (ST7121, 180°).

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
components/m5stack-tab5/src/video.cpp Adds rotation callback handling, panel-rotation decision logic, and adjusts flush rotation path accordingly.
components/m5stack-tab5/include/m5stack-tab5.hpp Declares the new rotation callback handler and helper used by the updated flush logic.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
finger563 and others added 2 commits August 28, 2026 14:35
…ply initial rotation, document MADCTL ordering

- Pin the espp::DisplayRotation <-> lv_display_rotation_t value
  correspondence with a static_assert and route all conversions through
  explicit to_lv_rotation()/to_display_rotation() helpers; the panel-vs-PPA
  decision in on_display_rotation() now uses the exact same
  panel_handles_rotation() predicate flush() uses.
- Explicitly (re)apply the current rotation at the end of initialize_lcd()
  so the panel MADCTL matches flush()'s expectation from the first frame
  even if initialize_display() was called before initialize_lcd() (the
  normal order is already covered: LVGL sends LV_EVENT_RESOLUTION_CHANGED
  synchronously from lv_display_set_rotation() in Display's constructor,
  before any flush can run).
- Document why the runtime MADCTL write is safe w.r.t. the DPI pipeline:
  it travels on the DSI generic/DBI command channel, hardware-arbitrated
  against the video stream and independent of the framebuffer/DMA path;
  worst case is a single transient frame during a rotation change, which
  is stated and accepted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ntal Kconfig (default off)

Hardware testing showed the MADCTL GS/SS 180-degree scan flip renders
corrupted on (at least some) ST7121 units. Root-cause analysis: the init
sequence programs full TDDI gate/source mux tables (the 0xAC block), which
vendor inits pair to a specific scan direction - a MADCTL GS flip alone
reorders gate scanning without swapping those tables, producing interleaved
garbage. (The bit positions themselves match Espressif's esp_lcd_st7121
mirror implementation, whose generic template was likely never validated on
this glass either; ESPHome's mipi_dsi driver documents the same 0/180-only
constraint for DSI panels generally, so the design was sound - this panel's
init tables are what make runtime flips unsafe.)

Default behavior is now EXACTLY the pre-branch known-good path: PPA/software
rotation for all orientations and ZERO runtime MADCTL writes (the rotation
callback is a full no-op), which also makes A/B comparison for unrelated
display artifacts clean. The panel-side path remains available for
experimentation via CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION, and the rest of
the branch (init-order hardening, single-predicate rotation decision, enum
unification, documented DSI command-channel safety) stands.

Both configurations build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563

Copy link
Copy Markdown
Contributor Author

Hardware test result (ST7121 unit): the MADCTL 180° scan flip renders corrupted — addressed in 33eb080.

Root cause analysis: the ST7121 init sequence programs full TDDI gate/source mux tables (the 0xAC block), which vendor inits pair to a specific scan direction. A MADCTL GS flip alone reorders gate scanning without swapping those tables → interleaved garbage. Corroborating evidence: espp's GS/SS bit positions match Espressif's official esp_lcd_st7121 mirror implementation (generic template code, likely never validated on this glass), and ESPHome's mipi_dsi driver documents the same 0/180-only constraint for DSI panels generally — the design was sound; this panel's init tables are what make runtime flips unsafe.

Resolution: panel-side rotation is now gated behind CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION (default off). The default build is exactly the pre-branch known-good behavior — PPA rotation for all orientations and zero runtime MADCTL writes. The branch's remaining value stands: the audit conclusion (documented in code), init-order hardening, the single-predicate rotation decision, and enum unification. Both configurations build clean.

@finger563
finger563 requested a balanced review from Copilot August 28, 2026 21:02

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

Comment thread components/m5stack-tab5/Kconfig Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
…n-out (display streaking)

The Tab5's DPI (video mode) panel continuously scans its PSRAM framebuffer
at ~140 MB/s via DW-GDMA; when concurrent PSRAM/AXI traffic starves that
read stream the DSI bridge FIFO underruns and the panel shows streaks along
the physical scan-line axis. The flush path was generating far more PSRAM
traffic than needed:

- On ESP-IDF 6.0 the esp_lcd_dpi_panel_config_t::flags.use_dma2d flag no
  longer exists (replaced by esp_lcd_dpi_panel_enable_dma2d()), so every
  esp_lcd_panel_draw_bitmap silently regressed to a CPU memcpy through the
  cache (~1.8 MB read + ~1.8 MB write + ~1.8 MB writeback per full frame).
  Call esp_lcd_dpi_panel_enable_dma2d() so copies run on the 2D-DMA engine
  again (matches M5Stack's UserDemo and esp-bsp's m5stack_tab5).

- The PPA rotation path rotated into a PSRAM scratch buffer and then
  draw_bitmap-copied that into the framebuffer. The PPA output supports
  placing a block inside a larger picture, so rotate directly into the DPI
  framebuffer at the rotated offset instead, halving that path's PSRAM
  traffic. The CPU software-rotate fallback keeps the scratch buffer.

- Register both PPA clients (display rotation and camera preview) with
  data_burst_length = 64 instead of the default 128: full-length PPA bursts
  are known to starve the DSI scan-out DMA even with 200 MHz hex PSRAM
  (lvgl/lvgl#9590; LVGL exposes the same knob as LV_PPA_BURST_LENGTH).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563

Copy link
Copy Markdown
Contributor Author

Display streaking: root cause + fixes (513f565)

The streaking is consistent with DSI bridge FIFO underrun from PSRAM read contention: the DPI (video-mode) panel scans its PSRAM framebuffer continuously at ~140 MB/s via DW-GDMA, and when competing PSRAM/AXI traffic starves that stream the bridge discards pixels, which renders as streaks along the physical scan-line axis in any orientation. This matches "worse with HW_ROTATION off" (that config runs the PPA + an extra full-frame copy on every flush). Note: on this board's pre-v3.0 P4 silicon the driver's underrun happens error log may never fire (the underrun interrupt is only supported on rev v3.0 per IDF v5.5.2 release notes), so absence of that log does not rule underrun out.

Three traffic reductions landed in 513f565:

  1. DMA2D re-enabled for draw_bitmap — IDF 6.0 removed flags.use_dma2d in favor of esp_lcd_dpi_panel_enable_dma2d(), which we never called, so every flush had silently regressed to a CPU memcpy (~5.4 MB of cache/PSRAM traffic per full frame). M5Stack's UserDemo and esp-bsp's m5stack_tab5 both use DMA2D here.
  2. PPA rotates directly into the DPI framebuffer (output block placement) instead of PPA -> scratch buffer -> draw_bitmap copy — halves the rotation path's PSRAM traffic.
  3. PPA burst length 128 -> 64 on both PPA clients (display + camera) — full-length PPA bursts are known to starve the DSI scan-out even at 200 MHz hex PSRAM ((draw/ppa) cause DSI underrun under heavy load on ESP32-P4 lvgl/lvgl#9590).

If streaks persist, please try in this order

  1. Rule out the camera: comment out initialize_camera() in the example. The CSI/ISP pipeline writes ~100+ MB/s into PSRAM; camera-induced DSI underrun at max PSRAM config is documented ((draw/ppa) cause DSI underrun under heavy load on ESP32-P4 lvgl/lvgl#9590). If this fixes it, the remaining fix is camera-side (lower preview rate/resolution).
  2. PPA burst 32: change PPA_DATA_BURST_LENGTH_64 -> PPA_DATA_BURST_LENGTH_32 in video.cpp and camera.cpp.
  3. Larger L2 cache: in example/sdkconfig set CONFIG_CACHE_L2_CACHE_512KB=y (keep CONFIG_CACHE_L2_CACHE_LINE_128B=y) — fewer CPU misses to PSRAM.
  4. Disable power management: set CONFIG_PM_ENABLE=n (M5Stack's UserDemo ships without PM; the DPI driver pins CPU freq, but PM still adds latency/jitter elsewhere).
  5. XIP experiment: toggle CONFIG_SPIRAM_XIP_FROM_PSRAM (currently y). It removes flash-cache stalls but adds instruction-fetch traffic to PSRAM; Espressif's own P4 external-RAM guide says to profile both ways.
  6. Lower DPI clock: dpi_clock_freq_mhz 70 -> 65/60 in video.cpp (ST7121/ST7123 branches). Caveat: the TDDI touch scan is timed against the pixel clock, so verify touch still works.
  7. AXI-ICM QoS (last resort, experimental): IDF ships hal/axi_icm_ll.h with per-master token-bucket regulators (axi_icm_ll_set_qos_burstiness(AXI_ICM_MASTER_CPU/DMA2D, ..., AXI_ICM_ACCESS_READ)) that could throttle the CPU/DMA2D masters so the DW-GDMA scan-out wins arbitration — nothing in IDF programs it yet, so this would be a hand-rolled experiment.

If a serial console is attached during streaking, any lcd.dsi.dpi: ... underrun happens message would confirm the diagnosis outright (only possible on rev v3 silicon).

…_rotation() call

on_display_rotation()'s ordering comment claimed it runs on the LVGL
thread, but initialize_lcd() also calls it directly on the init thread.
Scope the LVGL-thread claim to the rotation-callback invocation and spell
out at the call site why the direct init-path call is safe in either init
order: in the documented order display_ is still null (no LVGL display,
flush callback, or handler task exists, and the Display constructor
re-fires the callback synchronously before any flush); in the reversed
order the MADCTL write rides the DSI command channel, which never touches
the DPI framebuffer or its DMA, so at worst one transient frame — and
with CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION off (default) the call is a
no-op entirely.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563 finger563 changed the title fix(m5stack-tab5): use ST7121 driver (MADCTL) rotation for 0/180 instead of PPA/software rotation fix(m5stack-tab5): rotation audit, experimental opt-in ST7121 panel rotation (default off), and DSI underrun fixes Aug 28, 2026
@finger563
finger563 requested a balanced review from Copilot August 29, 2026 00:47

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 1 comment.

Comment thread components/m5stack-tab5/src/video.cpp Resolved
…rently flushing LVGL thread

initialize_lcd() writes lcd_handles_.panel early (before panel->init(),
DMA2D enable, and caching the dpi_framebuffer_/dpi_framebuffer_bytes_
pair), while flush() gated only on a plain null-check of that pointer.
With the reversed init order (initialize_display() first, the app
already pumping LVGL on another thread), flush() could race those plain
writes: undefined behavior, and observably a not-yet-initialized panel
or a torn framebuffer pointer/size pair.

Publish the LCD state through a single std::atomic<bool>
lcd_initialized_ gate: initialize_lcd() clears it on entry, writes
every field (lcd_handles_, dpi_framebuffer_ + bytes, display_driver_,
display_controller_), and store-releases it true as its final step;
flush(), write_lcd_lines() and on_display_rotation() load-acquire it
and no-op until it is true, so every plain write happens-before any
read that observes the gate open. The init-path rotation call runs
after the release store, and a rotation event that arrives while the
gate is closed is harmless: initialize_lcd() re-applies the current
LVGL rotation once the driver is up.

Also restructure on_display_rotation()'s Kconfig-disabled early-return
into #if/#else/#endif, fixing the cppcheck unreachableCode finding from
static analysis.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563
finger563 requested a balanced review from Copilot August 29, 2026 02:52

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
- flush(): rotate a local copy of the LVGL area instead of mutating the
  const area pointer via const_cast (lv_display_rotate_area on a copy)
- Tie the direct-to-framebuffer PPA rotation to the single-framebuffer
  panel configuration: name the num_fbs value (kNumDpiFramebuffers) and
  static_assert it is 1 where the framebuffer pointer is cached, since
  with multiple DPI framebuffers the cached pointer could target a
  non-scanned buffer
- Replace the hard-coded 128-byte cache-line alignment with the value
  the PPA driver itself validates against, queried via
  esp_cache_get_alignment(MALLOC_CAP_SPIRAM | MALLOC_CAP_DMA) (used for
  both the framebuffer alignment check and the scratch-buffer
  allocation); add esp_mm to the component requirements for the header

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (2)

components/m5stack-tab5/src/video.cpp:498

  • Publishing true here exposes the panel before its initial MADCTL state is applied below. In the supported reversed-init order, an already-running LVGL thread can flush in this window; with experimental 180° rotation, panel_handles_rotation() skips PPA even though the panel is still in its old scan direction. Apply the initial panel rotation before opening the gate (using an init-only helper that does not require the gate).
  lcd_initialized_.store(true, std::memory_order_release);

components/m5stack-tab5/src/video.cpp:154

  • Clearing an atomic flag does not wait for readers that already observed the previous true. A concurrent flush() or write_lcd_lines() can pass its acquire-load, then race the re-init writes to display_driver_, display_controller_, or framebuffer state despite the stated guarantee. Either prohibit re-initialization while readers run, or use reader/writer synchronization that waits for in-flight operations before rebuilding this state.
  lcd_initialized_.store(false, std::memory_order_release);

Comment thread components/m5stack-tab5/src/video.cpp Resolved
…ublication-gate lifecycle

- Skip esp_lcd_dpi_panel_enable_dma2d() on the ILI9881 variant: the repo
  documents (esp32-p4-function-ev-board, esp32-p4-nano) that the DMA2D
  draw_bitmap path corrupts RGB565 channel order on ILI9881C-family DSI
  panels; keep it for the ST7121/ST7123 TDDI variants (no corruption
  reported there, the underrun fix targets them, and M5Stack's own demo
  ships DMA2D-enabled on mostly-ST71xx units). Update the stale
  cross-reference in the function-ev-board note.
- Apply the initial panel rotation BEFORE store-releasing
  lcd_initialized_: with the reversed init order an already-running LVGL
  thread could flush in the window between the gate opening and the
  MADCTL apply, skipping PPA rotation with the panel still in its old
  scan direction. The init path now uses a gate-free
  apply_panel_rotation() helper (same thread as the state writes) and
  the gate opens last.
- Refuse LCD re-initialization once the gate has opened: clearing the
  atomic would not wait for readers that already observed true, so a
  concurrent flush()/write_lcd_lines() could race the re-init writes;
  the gate is now monotonic (false -> true, never back).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@finger563

Copy link
Copy Markdown
Contributor Author

Addressed the two suppressed comments from the latest Copilot review in b85dd72 (same commit as the DMA2D gating fix).

1. video.cpp:498 — gate published before the initial MADCTL apply

Valid: in the reversed init order (initialize_display() first), an already-running LVGL thread could flush in the window between lcd_initialized_.store(true) and the initial on_display_rotation() call that followed it — panel_handles_rotation() would skip PPA rotation while the panel's MADCTL still held its old scan direction.

Fixed by restructuring so the gate opens last:

  • The MADCTL/rotation core moved into a gate-free private helper, apply_panel_rotation(). on_display_rotation() (the LVGL rotation callback) is now just the lcd_initialized_ acquire-load check followed by the helper.
  • initialize_lcd() calls apply_panel_rotation() directly before the store-release. This bypasses the gate, which is safe: it runs on the same init thread that just wrote display_driver_/display_controller_ (no synchronization needed against itself), and any concurrent flush() still no-ops on the still-closed gate. So by the time any flush can observe the gate open, the panel scan direction already matches panel_handles_rotation()'s decision.
  • The per-init-order safety analysis at the call site (documented order: display_ null, nothing can race, redundant-but-harmless vs the espp::Display ctor's synchronous rotation event; reversed order: MADCTL rides the DSI command channel, arbitrated in hardware against the video stream) was kept and updated for the new ordering.

2. video.cpp:154 — clearing the gate does not wait for in-flight readers

Also valid: lcd_initialized_.store(false) on re-entry cannot un-publish state from a flush()/write_lcd_lines() that already passed its acquire-load of true; rebuilding lcd_handles_/display_driver_/the framebuffer pair underneath such a reader would be a race despite the gate.

Of the two options offered, I took "prohibit re-initialization": initialize_lcd() now refuses to run once the gate has opened (warn + early-return true), rather than adding reader/writer synchronization (e.g. an rwlock or RCU-style quiescence wait) to the per-frame flush hot path for a re-init this BSP never performs — the panel hardware is fixed at boot, and no espp BSP supports LCD re-init. Consequences, now documented on the flag:

  • The gate is monotonic: false -> true, never back. The store(false) is gone; on entry the gate is provably closed (first call, or retry after a failed attempt that never reached the final store-release), so no reader can have observed true while the state is being built — retry-after-failure stays supported.
  • The stale "clears this flag on entry" / "mid re-init" wording in the header and flush() comments was updated to match.

Both CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION states build cleanly (default off, and =y).

🤖 Generated with Claude Code

@finger563
finger563 requested a balanced review from Copilot August 29, 2026 20:11

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 6 out of 6 changed files in this pull request and generated 3 comments.

Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/CMakeLists.txt Resolved
…tial panel rotation

initialize_lcd()'s init-path apply_panel_rotation() called
lv_display_get_rotation() from the init thread when display_ already existed
(reversed init order) - reading LVGL state without the app's LVGL lock while
the GUI task may be running (LV_USE_OS == LV_OS_NONE). Use the configured
initial 'rotation' instead; runtime rotation changes are still applied by
on_display_rotation() (the LVGL rotation callback, on the GUI task). Both
Kconfig states build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 5 out of 5 changed files in this pull request and generated 1 comment.

Suppressed comments (1)

components/m5stack-tab5/src/video.cpp:794

  • With the experimental option enabled, this drops every rotation event that occurs after initialize_display() but before the LCD gate opens. initialize_lcd() then applies the compile-time initial rotation, not the current/pending value; for example, if LVGL changes to 180° during the documented reversed-init window, the panel is programmed to 0°, while the first flush observes 180° and panel_handles_rotation() skips PPA rotation. Preserve pending rotations and synchronize the final apply/publication step so an event cannot be lost between applying MADCTL and opening the gate.
  if (!lcd_initialized_.load(std::memory_order_acquire)) {
    return;
  }

Comment thread components/m5stack-tab5/src/video.cpp Resolved
finger563 and others added 2 commits August 31, 2026 09:18
…e global LVGL default

flush() and rotated_display_width()/height() read the rotation from
lv_display_get_default() rather than the display actually being used (the
flush callback's disp argument / the BSP's own display). Correct in a
single-display setup but wrong if another LVGL display is the default;
matches the fix already applied to the sibling P4 BSPs. Builds clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…s single-flight-safe

With the IDF6 DMA2D hook, esp_lcd_panel_draw_bitmap() is asynchronous and
single-flight (a second call while one is in flight returns
ESP_ERR_INVALID_STATE). Both flush() and the public cross-thread
write_lcd_lines() issued draws without serialization or checking the return,
so a concurrent direct write could make an LVGL flush's draw fail with no
on_color_trans_done -> LVGL waits forever; and a direct write's completion
could be taken for an LVGL flush completion.

Now every panel draw goes through draw_and_wait() under panel_op_mutex_: the
ISR only gives a completion semaphore (no longer signals LVGL); each draw
holds the mutex across the draw and its completion wait (only one transfer in
flight); draw_and_wait() checks the return and skips the wait on failure, and
flush() ALWAYS calls lv_display_flush_ready() afterwards so a failed draw
never hangs LVGL (frame dropped); the PPA direct-to-FB path takes the same
mutex around its framebuffer write. Both Kconfig states build clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Alaining

Alaining commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Hi @finger563, thanks for this PR. I built extra functionality on top of it. Please check #847

New functionality: Rotated screen remains running at high FPS (within 5FPS wrt non-rotated) and screen tearing was fixed. This was achieved by using more than one DPI frame buffer.

Thanks!

@Alaining

Copy link
Copy Markdown
Contributor

A correction to my earlier comment: please ignore what I said about #847 increasing the FPS.

  • feat(m5stack-tab5): tear-free rendering with two DPI frame buffers #847 is now just optional tear-free rendering, off by default: 2 frame buffers, LVGL draws into
    the hidden one, and the flush switches buffers and waits for frame-done. The numbers are in its
    description.
  • The price of a tear-free UI: 1.2 MB more PSRAM, no display rotation in that mode, and heavy
    animations run at half the panel rate (29 FPS) instead of tearing.

@finger563

Copy link
Copy Markdown
Contributor Author

Hardware results on a Tab5 (ST71xx panel, ESP-IDF v6.1)

Measured with a small display benchmark added locally to the m5stack-tab5 example. It runs before the GUI starts, sets LVGL's refresh timer to 1 ms, and runs each phase for 8 s at 0° and again at 90°:

  • small: a counter label changes every frame;
  • full: the whole screen changes colour every frame;
  • bar: a full-height bar sweeps across the screen (tearing check);
  • heavy: 24 gradient boxes move every frame.

DSI underruns were counted from the driver's "underrun happens" log lines. These are single runs. Branches tested: espp main (base), #738, and #847, both PRs with main merged in locally (see the note below).

Phase base 0° base 90° #738 0° #738 90° #847 (1 FB) 0° #847 (1 FB) 90° #847 (2 FBs, direct) 0°
full 25.0 fps 10.1 fps 35.9 fps 13.1 fps 35.9 fps 13.1 fps 57.8 fps
heavy 14.8 fps 7.9 fps 18.2 fps 9.7 fps 18.2 fps 9.7 fps 19.2 fps
bar 181 fps 167 fps 283 fps 224 fps 283 fps 224 fps 57.7 fps
small 996 fps 1000 fps 1000 fps 1000 fps 1000 fps 1000 fps 57.7 fps
DSI underruns 0 0 0 0 0 0 0
PSRAM free at start 24.5 MB 24.5 MB 24.5 MB 28.3 MB

small and bar above about 58 fps only show how fast LVGL renders and flushes a small dirty area; the panel refreshes at about 58 Hz. In 2-FB direct mode each frame waits for a panel refresh, so those phases cap at the panel rate. The 2-FB mode can't rotate, so it has no 90° column. Its 3.7 MB of extra free PSRAM is because the example's full-frame LVGL draw buffers aren't needed in that mode.

Takeaways:

Build note: neither PR builds as-is on ESP-IDF v6.1. v6.1 added esp_lcd_dpi_panel_event_callbacks_t::on_vsync, so the designated initializer in video.cpp (cbs = {.on_color_trans_done = ..., .on_refresh_done = nullptr}) fails with -Werror=missing-field-initializers. main already zero-initializes the struct and assigns the callbacks. Rebasing onto main fixes it; the only conflict is that initializer, and keeping main's form plus the PRs' additions resolves it. The rebase also brings in BSP APIs that downstream users rely on (touch_state(), touch_state_convert(), sdcard_component()).

🤖 Generated with Claude Code

finger563 and others added 2 commits October 31, 2026 16:21
# Conflicts:
#	components/m5stack-tab5/src/video.cpp
draw_and_wait() returned true when the DPI driver's transfer-done callback
did not arrive within its bounded wait, so a wedged DMA2D copy that may
still be reading the caller's buffer was treated like a finished one: the
LVGL flush handed the draw buffer back for re-rendering and the camera
task reused its frame, with nothing in the log. Return false and warn
(rate-limited) instead; the late completion, if it comes, is still
drained by the next call rather than being paired with that call.

Also drop the stale "asynchronous" note on write_lcd_lines(), which has
been synchronous since the draws were serialized.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563

Copy link
Copy Markdown
Contributor Author

Updated the branch so it merges cleanly and closes the one correctness hole from the review:

  • Merged main (the only conflict was the zero-initialised esp_lcd_dpi_panel_event_callbacks_t from fix(bsp): initialize new DPI panel event callback members for IDF v6.1 #782 vs. the draw-completion semaphore; resolved keeping both).
  • draw_and_wait() no longer reports a timed-out transfer as success (33d836c). It returned true after its 200 ms bounded wait expired, so a wedged DMA2D copy that might still be reading the caller's buffer looked finished: the LVGL flush handed the draw buffer back for re-rendering and write_lcd_lines() callers reused theirs, silently. It now returns false and warns (rate-limited); the late completion, if it ever arrives, is still drained by the next call instead of being paired with it. The stale "asynchronous" note on write_lcd_lines() is gone too.

Hardware check on a Tab5 (ST71xx, ESP-IDF v6.1): display benchmark phases at 0° and 90° (small updates, full-screen redraws, sweeping bar, render-heavy animation), 60 s total, no draw timeouts, no DSI underruns, no errors; then the example GUI came up normally.

phase 0° 90° (PPA)
small 1000 fps 514 fps
full 35.5 fps 12.0 fps
bar 246 fps 170 fps
heavy 13.1 fps 6.8 fps

Left as-is, for a follow-up if wanted: the flush is now synchronous on the GUI task (before, the ISR signalled LVGL so a DMA2D copy overlapped rendering of the next area). A pending_op_ recorded under panel_op_mutex_ would let the ISR call lv_display_flush_ready() for LVGL draws and give the semaphore for direct writes, keeping the pipelining while still fixing the completion mix-up this PR addresses.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

…t writes

Serializing the panel draws (previous commit) made the LVGL flush wait
for its own DMA2D copy, so rendering of the next area no longer
overlapped the copy of the current one. Restore that overlap without
giving up the single-flight guarantee or the completion mix-up fix:

- flush() issues its draw_bitmap and returns at once; the transfer-done
  ISR signals LVGL (lv_display_flush_ready) and clears the in-flight
  flag. Only flush() sets lvgl_flush_pending_, so a direct write's
  completion never reaches LVGL.
- Every panel operation (an LVGL draw, write_lcd_lines(), the PPA
  rotation into the framebuffer, the software rotation into the scratch
  buffer) first waits for the transfer in flight under panel_op_mutex_
  (wait_for_transfer_idle()), so only one transfer is ever in flight and
  no engine writes a buffer another is still reading. A timed-out
  transfer is given up on and LVGL released, rather than stalling every
  later draw.
- write_lcd_lines() stays synchronous: callers may reuse their buffer on
  return.

Tab5 (ST71xx, ESP-IDF v6.1) display benchmark, same build either way:
small updates 1000 -> 993 fps, full-screen redraws 35.5 -> 38.3 fps,
sweeping bar 246 -> 345 fps, render-heavy animation 13.1 -> 15.6 fps;
rotated (PPA) phases unchanged; no DSI underruns.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU
@finger563

Copy link
Copy Markdown
Contributor Author

Pushed 250a7ef: the LVGL flush is asynchronous again, without giving up what the serialization fixed.

  • flush() issues its draw_bitmap and returns; the transfer-done ISR signals LVGL (lv_display_flush_ready) and clears an in-flight flag. Only flush() sets lvgl_flush_pending_, so a direct write's completion never reaches LVGL.
  • Every panel operation (LVGL draw, write_lcd_lines(), the PPA rotation into the framebuffer, the software rotation into the scratch buffer) first waits for the transfer in flight under panel_op_mutex_ (wait_for_transfer_idle()), so only one transfer is ever in flight and no engine writes a buffer another is still reading. A timed-out transfer is given up on and LVGL released instead of stalling every later draw.
  • write_lcd_lines() stays synchronous, so callers may reuse their buffer on return.

Same Tab5 (ST71xx, ESP-IDF v6.1), same build and benchmark as the table above, no DSI underruns, no draw timeouts:

phase synchronous (before) asynchronous (now)
small, 0° 1000 fps 993 fps
full, 0° 35.5 fps 38.4 fps
bar, 0° 246 fps 345 fps
heavy, 0° 13.1 fps 15.7 fps
90° phases (PPA) 514 / 12.0 / 170 / 6.8 571 / 12.0 / 171 / 6.8

The gain lands on mid-sized partial flushes, the usual LVGL workload. Rotated output is unchanged because the PPA call still blocks; making that one non-blocking too would be a separate change.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 Changes recommended

Critical DMA completion races and timeout handling can release source buffers while transfers remain active, allowing unsafe memory reuse and misattributed completions.

4 open issues
1 resolved since last review

Review effort: Balanced

Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
Comment thread components/m5stack-tab5/src/video.cpp Resolved
…eep pre-gate rotation requests; rotation event uses its own display

Review follow-ups on the asynchronous flush:

- The completion ISR no longer clears transfer_in_flight_; the task that
  consumes the completion token (wait_for_transfer_idle() or
  draw_and_wait(), under panel_op_mutex_) clears it. Clearing the flag in
  the ISR before giving the token let a task on the other core see "idle",
  issue its own draw and then consume the previous transfer's token as its
  own. A transfer that times out now stays in flight (its token is still
  owed) rather than being forgotten: later operations wait for it again
  and skip their own work while it is outstanding, so a late completion
  is never attributed to a later transfer and two engines never work the
  same memory. LVGL is released by the ISR when the completion comes.
- on_display_rotation() records the latest request before the gate check,
  and initialize_lcd() applies that request (sequentially consistent with
  the gate) instead of the compile-time default, so a rotation set between
  initialize_display() and initialize_lcd() is no longer dropped with
  CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION enabled.
- espp::Display's LV_EVENT_RESOLUTION_CHANGED handler reads the rotation
  of the display that emitted the event and dispatches to the Display
  registered with the callback, not the global default display.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DFjjnq3XCTRAXENSxsCxJU

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔵 Needs a closer look

Critical concurrency risks involving overlapping DMA2D/PPA transfers and mismatched panel rotation during initialization require human assessment.

No open issues 🎉

4 resolved since last review

Review effort: Balanced

@finger563
finger563 merged commit a99a107 into main Nov 2, 2026
180 checks passed
@finger563
finger563 deleted the feat/tab5-st7121-rotation branch November 2, 2026 00:30
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants