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)
| Interface | Board (pydevices/board_configs/fbdisplay/…) | Panel | Notes |
|---|---|---|---|
mipidsi | esp32-p4-wifi6-touch-lcd-4b | MIPI DSI | Soft-reset / timer lifecycle reference |
DotClockFramebuffer | qualia_tl040hds20 (+ CP cp_qualia_tl040hds20) | 720×720 RGB-666→565 | First MP DotClock bring-up; bounce + double-FB |
DotClockFramebuffer | esp32-s3-touch-lcd-4_3 | 800×480 ST7262 | Same RGB pin map family as LCD-7; GT911 diagonal touch map |
DotClockFramebuffer | t-rgb_480 | 480×480 ST7701 | XL9535 + CST820; verified with bounce + double-FB + show() |
DotClockFramebuffer | esp32-s3-touch-lcd-7 | 800×480 ST7262 | Same 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.
| Mechanism | What it is | Why | Policy |
|---|---|---|---|
| Bounce buffer | DRAM 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 adopts | Painting the live bounce source mid-scan → flicker / black strips. GUIs may bind both panel FBs via framebuffers() (share_framebuffer on FBDisplay) for LVGL DIRECT | Required for MP LVGL; auto_refresh=False so FBDisplay.show presents |
auto_refresh | Whether FBDisplay.show() skips refresh() | Coupled to double-FB, not to bounce | False on MP DotClock; CP FramebufferDisplay(auto_refresh=True) is a different architecture |
Present refresh() | Flip back→front after bounce adopts | Do not full-FB memcpy after flip (~100ms+ on 800×480). Dual-buffer GUIs sync dirty regions; show() advances the paint pointer only | Keep 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
| Mechanism | When | What |
|---|---|---|
--wrap=gc_sweep_all (src/ports/common/soft_reset.c) | Before heap wipe | displayif_port_pre_gc_sweep(), optional weak and initialization-guarded lv_deinit(), then displayif_soft_reset_all() |
--wrap=mp_deinit (same file) | After sweep | Idempotent second displayif_soft_reset_all() |
displayif_register_soft_reset(fn) | At first ctor | Per-interface host teardown (mipidsi, displayif/DotClock, i80bus, picodvi, rgbmatrix, …) |
displayif_port_pre_gc_sweep() | Strong symbol on esp32 | Stop 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 targetedget). - 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.installover 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:
| Class | Examples |
|---|---|
| Host lifecycle | Interrupt already taken, bus not found after soft-reset |
| Timer / GC race | Guru Meditation / Load access fault after soft-reset into LVGL or bindings |
| Presentation | Black panel, wrong colors, very slow full-screen updates |
| Timebase | UI timers run at wrong wall-clock rate |
| Input | Taps ignored, inverted axes, release never delivered |
| Tooling noise | mpftp 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)
| Symptom | Likely cause | Fix 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 callbacks | displayif_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_tail | LVGL native display/draw state was not deinitialized before its GC-backed global root was swept | Common 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 ran | displayif_register_soft_reset + complete *_host_teardown; must run before heap wipe |
Black panel, process “works” (mipidsi) | Missing show / refresh_cb, backlight off, wrong fb path | displaydev + 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 order | Continuous 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 alone | bounce_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 lookup | Expose methods in attr (same pattern as mipidsi.Display) |
| UI ~1–2 FPS / multi‑second full redraws | Python 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 clock | Fixed tick_inc(period) vs real elapsed | lv.tick_inc(elapsed_ms) from wall time in the timer callback |
| Crash / corruption if Runtime timer runs during LVGL DisplayDriver setup | Tick into half-initialized LVGL | Stop Runtime timer around setup; arm loop only when ready (display_driver) |
| Touch down never seen / stuck pressed | indev read_cb polls but does not always write PRESSED/RELEASED | Always call the touch callback after poll so LVGL sees edges |
| Touch mirrored / inverted | GT911 (or similar) axis flags wrong for panel orientation | board_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 path | Delete 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 SPI0 | Keep 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_timer | lv_test_timer top-level runtime.stop_timer() wipes on_tick subs; event_loop._timer_sub left dangling so _arm_sync_timer no-ops | Only 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-blitesp_cache_msyncwhen bounce is on (IDF does the same). Do not remove bounce when debugging present/tear issues. - Double panel FBs (
num_fbs = 2): LVGL/blitpaint the back buffer;refresh()promotes viadraw_bitmapof that FB pointer, waits for bounceon_frame_buf_complete, then copies front→new back for PARTIAL updates. (auto_refresh=FalsesoFBDisplay.showdrives present.) - CP Qualia paints a separate
displayio.BitmapwithFramebufferDisplay(auto_refresh=True)— paint ≠ panel FB, so CP can use live scanout without MP’s double-FB present path. Do not copy CP’sauto_refresh=Trueonto MP DotClock without that Bitmap split. vsync_idle_lowfollowshsync_idle_low(CPDotClockFramebufferbehavior).
Python / FBDisplay contract
- Buffer protocol typecode
'B'(byte-addressable). MicroPython has nomemoryview.cast;'H'pushed FBDisplay into a ~2s full-screen u16 loop. - Native
blit/fill_rectwith per-dirty-row msync (same idea asmipidsi.Display.bliton P4). - Custom
attrmust exportrefresh,blit,fill_rect,deinit,__del__—locals_dictalone is not enough whenattris 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:
- File-static BSS mirrors of live SDK/DMA/PIO handles
- Single internal teardown used by
deinit,__del__, idempotent ctor, soft-reset -
displayif_register_soft_reset(teardown)on first successful init - Soft-reset + reconstruct acceptance (no hard reset)
- No
m_freeof GC objects from soft-reset teardown - If the port has async hardware timers that schedule Python (esp32
machine.Timer), ensuredisplayif_port_pre_gc_sweep()stops them — or implement the strong hook for that port - Large framebuffers: PSRAM / sdkconfig sized (see PORT_MATRIX); prefer native blit paths into SPIRAM
- pydevices board_config: touch axes, backlight,
FBDisplayrefresh wiring - If custom
attris set: exportrefresh/blit/fill_rect/deinit/__del__there (locals_dict alone is skipped) - 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 withshow()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 viaruntime.claim_display_refresh()/display_driver.- Interactive REPL / mpftp:
run_forever/ select paths must not assume a non-interactive process (seeeventsys/multimerin 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.