Repository navigation
fix(m5stack-tab5): rotation audit, experimental opt-in ST7121 panel rotation (default off), and DSI underrun fixes - #738
Conversation
…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>
|
✅Static analysis result - no issues found! ✅ |
There was a problem hiding this comment.
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_callbacktoM5StackTab5::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.
…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>
|
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 Resolution: panel-side rotation is now gated behind |
…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>
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 Three traffic reductions landed in 513f565:
If streaks persist, please try in this order
If a serial console is attached during streaking, any |
…_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>
…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>
- 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>
There was a problem hiding this comment.
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
truehere 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 concurrentflush()orwrite_lcd_lines()can pass its acquire-load, then race the re-init writes todisplay_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);
…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>
|
Addressed the two suppressed comments from the latest Copilot review in b85dd72 (same commit as the DMA2D gating fix). 1. Valid: in the reversed init order ( Fixed by restructuring so the gate opens last:
2. Also valid: Of the two options offered, I took "prohibit re-initialization":
Both 🤖 Generated with Claude Code |
…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>
There was a problem hiding this comment.
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 initialrotation, 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° andpanel_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;
}
…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>
|
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! |
|
A correction to my earlier comment: please ignore what I said about #847 increasing the FPS.
|
Hardware results on a Tab5 (ST71xx panel, ESP-IDF v6.1)Measured with a small display benchmark added locally to the
DSI underruns were counted from the driver's "underrun happens" log lines. These are single runs. Branches tested: espp
Takeaways:
Build note: neither PR builds as-is on ESP-IDF v6.1. v6.1 added 🤖 Generated with Claude Code |
# 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
|
Updated the branch so it merges cleanly and closes the one correctness hole from the review:
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.
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 🤖 Generated with Claude Code |
…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
|
Pushed 250a7ef: the LVGL flush is asynchronous again, without giving up what the serialization fixed.
Same Tab5 (ST71xx, ESP-IDF v6.1), same build and benchmark as the table above, no DSI underruns, no draw timeouts:
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 |
There was a problem hiding this comment.
🟡 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
…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


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
0xACblock) 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 behindCONFIG_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)
Display::LvglConfig::rotation_callbackto a newM5StackTab5::on_display_rotation(); the rotation decision is centralized in a single predicate (panel_handles_rotation()) shared by the flush path and the callback.DisplayRotation<->lv_display_rotation_tmapping pinned down with explicit converters) and init-order hardening: the current rotation is (re)applied when the display driver comes up, correct in eitherinitialize_lcd()/initialize_display()order, with the thread-safety of the init-path call documented at the call site.CONFIG_M5STACK_TAB5_ST7121_HW_ROTATION(defaultn) with the corruption warning in its Kconfig help. When enabled, the ST7121 applies 0/180 via the display driver'sset_rotation()(MADCTL) andflush()skips the PPA for those orientations.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:
draw_bitmap: IDF 6.0 removedflags.use_dma2din favor ofesp_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).draw_bitmapcopy, halving the rotation path's PSRAM traffic.Hardware testing
Testing
CONFIG_M5STACK_TAB5_ST7121_HW_ROTATIONoff and on) with ESP-IDF 6.0, esp32p4.🤖 Generated with Claude Code