Soft-reset & board bring-up

August 12, 2026 · View on GitHub

Lessons from hardware bring-ups with PyDevices displaydev + LVGL (lv_test_timer) under mpftp soft-reset (July 2026).

Patterns apply when porting any displayif interface on any MCU port (esp32 / rp2 / mimxrt / samd). Lifecycle contract: idempotent-lifecycle.md. Matrix: port-matrix.md. Entry: AGENTS.md.

Board matrix (this chat / July 2026)

InterfaceBoard (pydevices/board_configs/fbdisplay/…)PanelNotes
mipidsiesp32-p4-wifi6-touch-lcd-4bMIPI DSISoft-reset / timer lifecycle reference
DotClockFramebufferqualia_tl040hds20 (+ CP cp_qualia_tl040hds20)720×720 RGB-666→565First MP DotClock bring-up; bounce + double-FB
DotClockFramebufferesp32-s3-touch-lcd-4_3800×480 ST7262Same RGB pin map family as LCD-7; GT911 diagonal touch map
DotClockFramebuffert-rgb_480480×480 ST7701XL9535 + CST820; verified with bounce + double-FB + show()
DotClockFramebufferesp32-s3-touch-lcd-7800×480 ST7262Same timings/pins as 4.3″; identity GT911 coords

DotClock: bounce vs panel double-FB vs auto_refresh (do not conflate)

These are three knobs. Agents repeatedly mixed them up.

MechanismWhat it isWhyPolicy
Bounce bufferDRAM refill from PSRAM FB (bounce_buffer_size_px = 20 * h_res)Without it, large DPI panels slide horizontally under load (PSRAM underrun)Always on for esp32 DotClock — never remove when debugging tear
Panel double-FB (num_fbs = 2)Paint back; refresh()/show() promotes after bounce adoptsPainting the live bounce source mid-scan → flicker / black strips. GUIs may bind both panel FBs via framebuffers() (share_framebuffer on FBDisplay) for LVGL DIRECTRequired for MP LVGL; auto_refresh=False so FBDisplay.show presents
auto_refreshWhether FBDisplay.show() skips refresh()Coupled to double-FB, not to bounceFalse on MP DotClock; CP FramebufferDisplay(auto_refresh=True) is a different architecture
Present refresh()Flip back→front after bounce adoptsDo not full-FB memcpy after flip (~100ms+ on 800×480). Dual-buffer GUIs sync dirty regions; show() advances the paint pointer onlyKeep bounce wait + draw_bitmap; no clone

History (same day, this campaign): Qualia black → continuous scanout; Qualia slide → add bounce (kept); Qualia flicker → double-FB + auto_refresh=False (623dc04); T-RGB black while FB had color → probes skipped show() on double-FB, then mistakenly switched to single-FB + auto_refresh=True (0e3de1a) — bounce was not removed; LCD-7 UI then black-with-edge under LVGL → restore double-FB + present via show().

Trap: with double-FB, fill_rect without show() leaves the panel on the old front (often black). Smoke tests must call display_drv.show() after paint. LVGL via display_driver already sets refresh_cb=display_drv.show.

Why not auto_refresh=True on MP? On CircuitPython Qualia, the app paints a separate displayio.Bitmap; FramebufferDisplay(..., auto_refresh=True) composites into the DotClock FB. Paint ≠ scanout buffer, so the panel can keep DMA-scanning while you draw elsewhere. On MicroPython, LVGL flush/blit write this panel framebuffer. With a single live FB (auto_refresh=True), you edit the buffer DMA is reading → mid-scan tear, flicker, or black-with-edge under animation. Double-FB fixes that (paint back, show()/refresh() promotes after bounce adopts the new front), which only works if auto_refresh=False so FBDisplay.show() actually calls refresh(). Bounce stays either way; do not copy CP’s auto_refresh=True onto MP DotClock without a Bitmap-style paint surface split.


Soft-reset architecture (read first)

On MicroPython MCU ports the soft-reset exit path is typically:

… → gc_sweep_all() → … → mp_deinit() → soft_reset loop
MechanismWhenWhat
--wrap=gc_sweep_all (src/ports/common/soft_reset.c)Before heap wipedisplayif_port_pre_gc_sweep(), optional weak and initialization-guarded lv_deinit(), then displayif_soft_reset_all()
--wrap=mp_deinit (same file)After sweepIdempotent second displayif_soft_reset_all()
displayif_register_soft_reset(fn)At first ctorPer-interface host teardown (mipidsi, displayif/DotClock, i80bus, picodvi, rgbmatrix, …)
displayif_port_pre_gc_sweep()Strong symbol on esp32Stop machine.Timer / clear handlers (weak empty default elsewhere)

Do not add soft-reset teardown by patching micropython/ports/*/main.c when these wraps suffice. Temporary upstream patches are easy to leave behind and hard for the next agent to find.

Teardowns must only touch non-GC host state (ESP-IDF handles, DMA, PIO, SPIRAM buffers owned via heap_caps_*, etc.). Never rely on __del__ alone.

Verify wraps landed in the linked ELF after a rebuild:

nm …/micropython.elf | rg 'wrap_gc_sweep|wrap_mp_deinit|displayif_port_pre_gc_sweep|displayif_soft_reset_all'

Troubleshooting method (what worked)

Use this loop on a new board / interface instead of guessing from one symptom.

1. Establish a reversible baseline

  • Backup the MCU filesystem before mass deletes or firmware experiments (mpftp cp :/ … or targeted get).
  • Note board + variant + partition autosize (P4 + LVGL often overflows stock app partition; mpftp autosize grows esp32_partitions/<board>.csv).

2. Prefer fast package install over serial spam

  • Push only thin host-side files with mpftp (wifi.py, secrets.py).
  • Use mip.install over Wi‑Fi for pydevices board packages / libs — much faster than recursive mpftp/mpremote for large trees.
  • Soft-reset between major FS changes so imports see a clean heap.

3. Separate failure classes early

Reproduce with the smallest script that still fails (import board_config, then import lv_test_timer, not the whole gallery). Classify:

ClassExamples
Host lifecycleInterrupt already taken, bus not found after soft-reset
Timer / GC raceGuru Meditation / Load access fault after soft-reset into LVGL or bindings
PresentationBlack panel, wrong colors, very slow full-screen updates
TimebaseUI timers run at wrong wall-clock rate
InputTaps ignored, inverted axes, release never delivered
Tooling noisempftp EOF timeout, flash write flicker from debug logs

Fix the class at its layer; do not paper over with board_config special cases.

4. Soft-reset is the acceptance test

For every host-owning change:

construct / import → soft-reset → construct / import again

Must succeed without hard reset. mpftp connect / Run paths soft-reset and skip main.py — that is the workflow users hit.

Tooling note: timeout waiting for first EOF right after soft-reset is often an mpftp session race, not a panicking board. mpftp resume / reconnect and retry once. A real Guru Meditation usually needs a hard reset and shows up in the serial boot log.

5. Prove native vs Python with evidence

  • Time hot paths (blit row, refresh) before rewriting.
  • After C changes: rebuild + flash firmware (mpftp firmware build/flash when available), then re-run the same soft-reset smoke.
  • Confirm symbols (nm) when debugging wrap/hook issues.
  • Remove temporary flash-backed debug instrumentation before calling a UI “fixed” — writing NDJSON to flash on every touch can look like flicker.

6. Revert failed hypotheses

If a fix does not move the acceptance test, remove it before trying the next approach. Do not stack silent fallbacks.


Symptom → likely cause (proven cases)

SymptomLikely causeFix location
Soft-reset then import → Guru Meditation / Load access fault in LVGL (get_native_obj, set_draw_buffers, weird .type pointing at a method)machine.Timer / esp_timer still armed; fires into swept Python callbacksdisplayif_port_pre_gc_sweep() (esp32); do not leave this only in micropython main.c
Soft-reset then second LVGL UI → Instruction/Store access fault in lv_draw_finalize_task_creation / lv_ll_ins_tailLVGL native display/draw state was not deinitialized before its GC-backed global root was sweptCommon pre-GC weak lv_deinit() before displayif host teardown
Soft-reset then reconstruct → ESP_ERR_NOT_FOUND / “No free interrupt” (DSI bridge, RGB panel, etc.)Host bus/panel/IRQ not released; __del__ never randisplayif_register_soft_reset + complete *_host_teardown; must run before heap wipe
Black panel, process “works” (mipidsi)Missing show / refresh_cb, backlight off, wrong fb pathdisplaydev + board_config; keep presentation wired
Black / backlight-only (dotclockframebuffer.DotClockFramebuffer Qualia)Separate malloc FB + refresh_on_demand=1, panel never started, or wrong data-pin orderContinuous scanout (refresh_on_demand=0), panel FB via esp_lcd_rgb_panel_get_frame_buffer, start DMA at ctor; Qualia needs BGR 5/6/5 pin tuple (not LCD-EV learn-guide order)
Horizontal “sliding” / tearing under load (Qualia 720×720)PSRAM cannot sustain 16bpp DPI alonebounce_buffer_size_px = 20 * h_res + dirty-row esp_cache_msync in native blit/fill/refresh
AttributeError: 'DotClockFramebuffer' object has no attribute 'refresh' (or blit)Custom attr replaces locals_dict lookupExpose methods in attr (same pattern as mipidsi.Display)
UI ~1–2 FPS / multi‑second full redrawsPython per-pixel / memoryview path into SPIRAM FB (MP has no memoryview.cast)Buffer typecode 'B' + native blit / fill_rect; fbdisplay calls them when present
Illegal instruction / crash inside LVGL draw or early init (not only soft-reset)LV_GLOBAL_CUSTOM not rooted in MP_STATE_VM(mp_lv_roots)lvgl-bindings conf / emit / generated
LVGL seconds advance ~½ wall clockFixed tick_inc(period) vs real elapsedlv.tick_inc(elapsed_ms) from wall time in the timer callback
Crash / corruption if Runtime timer runs during LVGL DisplayDriver setupTick into half-initialized LVGLStop Runtime timer around setup; arm loop only when ready (display_driver)
Touch down never seen / stuck pressedindev read_cb polls but does not always write PRESSED/RELEASEDAlways call the touch callback after poll so LVGL sees edges
Touch mirrored / invertedGT911 (or similar) axis flags wrong for panel orientationboard_config reverse_axis / reverse_y (validate by tapping known UI targets)
Flicker only while “debugging”Flash writes from agent NDJSON / probe files on the hot pathDelete instrumentation; keep functional fixes
Faint flicker / black with one edge while UI animates (DotClock + LVGL)Painting the live bounce-source FB mid-scan (single panel FB); or full-frame / per-blit msync fighting bounce on SPI0Keep bounce; double panel FBs + refresh()/show() present; skip FB msync when bounce on; auto_refresh=False. Do not “fix” by dropping bounce
Black / white panel but FB memory has color (DotClock)Double-FB: paint hit back buffer; panel still scanning front; no show()/refresh()Call display_drv.show() after paint; LVGL must use refresh_cb=show (display_driver)
LVGL seconds stuck at 0 after import display_driver then import lv_test_timerlv_test_timer top-level runtime.stop_timer() wipes on_tick subs; event_loop._timer_sub left dangling so _arm_sync_timer no-opsOnly stop_timer before first display_driver import; _arm_sync_timer must re-arm if runtime._timer is gone

Reference: dotclockframebuffer.DotClockFramebuffer on Qualia (ESP32-S3)

Reference board: Adafruit Qualia S3 + TL040HDS20 (720×720). Same native module serves Waveshare 4.3″/7″ ST7262 and LILYGO T-RGB (see matrix above). CP sibling: fbdisplay/cp_qualia_tl040hds20. Native: src/ports/esp32/mod_dotclockframebuffer.c.

Scanout model (MP LVGL vs CircuitPython)

  • Continuous DMA (refresh_on_demand = 0), not on-demand draw_bitmap of a separate malloc buffer.
  • Use the panel’s own framebuffers (esp_lcd_rgb_panel_get_frame_buffer); start the panel at construct (kick + init).
  • Bounce buffer (bounce_buffer_size_px = 20 * h_res) required for large PSRAM panels; without it the image walks horizontally under load. Skip per-blit esp_cache_msync when bounce is on (IDF does the same). Do not remove bounce when debugging present/tear issues.
  • Double panel FBs (num_fbs = 2): LVGL/blit paint the back buffer; refresh() promotes via draw_bitmap of that FB pointer, waits for bounce on_frame_buf_complete, then copies front→new back for PARTIAL updates. (auto_refresh=False so FBDisplay.show drives present.)
  • CP Qualia paints a separate displayio.Bitmap with FramebufferDisplay(auto_refresh=True) — paint ≠ panel FB, so CP can use live scanout without MP’s double-FB present path. Do not copy CP’s auto_refresh=True onto MP DotClock without that Bitmap split.
  • vsync_idle_low follows hsync_idle_low (CP DotClockFramebuffer behavior).

Python / FBDisplay contract

  • Buffer protocol typecode 'B' (byte-addressable). MicroPython has no memoryview.cast; 'H' pushed FBDisplay into a ~2s full-screen u16 loop.
  • Native blit / fill_rect with per-dirty-row msync (same idea as mipidsi.Display.blit on P4).
  • Custom attr must export refresh, blit, fill_rect, deinit, __del__locals_dict alone is not enough when attr is set.

Board config (not displayif, but easy to mis-blame)

  • Qualia pins / PCA9554 0x3f / expander reset — match CP adafruit_qualia_s3_rgb666, not generic LCD-EV wiring.
  • Data pins: BGR 5/6/5 order for this panel.

Checklist: new interface or new port

When adding or porting a backend that owns host resources:

  1. File-static BSS mirrors of live SDK/DMA/PIO handles
  2. Single internal teardown used by deinit, __del__, idempotent ctor, soft-reset
  3. displayif_register_soft_reset(teardown) on first successful init
  4. Soft-reset + reconstruct acceptance (no hard reset)
  5. No m_free of GC objects from soft-reset teardown
  6. If the port has async hardware timers that schedule Python (esp32 machine.Timer), ensure displayif_port_pre_gc_sweep() stops them — or implement the strong hook for that port
  7. Large framebuffers: PSRAM / sdkconfig sized (see PORT_MATRIX); prefer native blit paths into SPIRAM
  8. pydevices board_config: touch axes, backlight, FBDisplay refresh wiring
  9. If custom attr is set: export refresh / blit / fill_rect / deinit / __del__ there (locals_dict alone is skipped)
  10. Dot-clock RGB: continuous scanout + panel FB (not malloc + on-demand); bounce and double panel FBs for MP LVGL; auto_refresh=False; buffer typecode 'B'; smoke with show() after paint

Stubs under src/ports/common/notimpl/ stay stubs — no soft-reset registration.


PyDevices / LVGL interaction (outside this repo)

These are not displayif bugs but show up during the same bring-up:

  • eventsys.Runtime + multimer: one shared periodic timer; LVGL claims presentation via runtime.claim_display_refresh() / display_driver.
  • Interactive REPL / mpftp: run_forever / select paths must not assume a non-interactive process (see eventsys / multimer in pydevices).
  • Do not leave flash logging in display_driver, examples, or board_config on the touch/refresh path.

When changing only Python on-device, soft-reset + re-import is enough. When changing displayif C or LVGL bindings, rebuild/flash firmware first.


Quick commands (mpftp-oriented)

mpftp connect COM4
mpftp soft-reset
mpftp exec 'import board_config; print("ok")'
# or
mpftp exec 'import lv_test_timer; print(lv_test_timer.get_state())'

mpftp firmware build --port esp32 --board ESP32_GENERIC_P4 --variant C6_WIFI
mpftp firmware flash -d COM4

Disconnect the UI session (or mpftp disconnect) before flashing if the port is busy.


Seeded 2026-07-20 from ESP32-P4 mipidsi and ESP32-S3 DotClock boards in the matrix above (lv_test_timer). Update when a new port/interface reveals a durable failure mode — especially if bounce vs double-FB tradeoffs change.