Skip to content

Experimental JC3248W535 (Guition 3.5" AXS15231B) support#74

Draft
theNailz wants to merge 15 commits intoKeralots:mainfrom
theNailz:feature/jc3248w535
Draft

Experimental JC3248W535 (Guition 3.5" AXS15231B) support#74
theNailz wants to merge 15 commits intoKeralots:mainfrom
theNailz:feature/jc3248w535

Conversation

@theNailz
Copy link
Copy Markdown
Contributor

Summary

Adds an experimental build target for the Guition JC3248W535 — a 3.5" 320x480 IPS ESP32-S3 board with an AXS15231B QSPI in-cell display + capacitive touch.

  • New env jc3248w535 in platformio.ini, new layout include/layout_320x480.h
  • Custom panel driver src/lgfx_panel_axs15231b_agfx.hpp wraps Arduino_GFX's AXS15231B inside a LovyanGFX Panel_Device (LovyanGFX mainline has no Panel_AXS15231B and no Bus_QSPI)
  • PSRAM-backed frame sprite (320x480x16bpp, ~300 KB) with single-call per-frame flush via pushRawPixels() — the chip cannot address arbitrary Y per draw in QSPI mode

What works

  • Full UI renders correctly (gauges, icons, progress, splash, clock mode)
  • Correct colors (LSB-first byte order on the wire — this chip deviates from MIPI DCS convention)
  • Touch as a bool wake button via AXS15231B over I2C (SCL=8, SDA=4, addr 0x3B)
  • Portrait rotations 0 and 2 with runtime live-apply via sprite-side rotation; panel MADCTL stays at 0 to preserve RASET-skip + LSB-first invariants
  • OTA, MQTT, web config portal unchanged

What's deliberately out of scope (follow-ups)

  • Landscape rotations (1, 3) — no LY_LAND_* layout variant exists for 320x480 yet; web-UI selection of 1/3 snaps to 0 with a Serial warning rather than rendering a broken layout
  • Dirty-flag gating of flushFrame() — currently pushes every loop tick unconditionally; fine on USB power, suboptimal for battery
  • Coordinate-based touch — touch is bool-only today (wake / button stand-in); no rotation-aware coord mapping

Architecture notes

Three chip-level invariants the driver preserves, discovered on-device:

  1. RASET is skipped in QSPI mode — the panel derives rows from the RAMWR/RAMWRC stream. Sending RASET causes sub-full-screen draws to collapse to the top of the CASET column window.
  2. Pixel bytes are read LSB-first on the wire — native little-endian ESP32 order, not the MIPI DCS MSB-first convention. Getting this wrong produces the classic RED→BLUE / GREEN→RED / BLUE→GREEN cyclic palette rotation.
  3. One CS cycle per whole-frame push, not per chunk — LovyanGFX's default Panel_Device::writePixels chunks a frame into many CS cycles, which produces full image destruction with repeating horizontal bands. The wrapper exposes pushRawPixels(buf, n) as an escape hatch the UI layer calls directly.

Existing board targets (esp32s3, cyd, etc.) are gated by BOARD_IS_* build flags and are unaffected.

Test plan

  • Build env jc3248w535 clean, flash to COM12 (ESP32-S3-WROOM-1, 16 MB flash, 8 MB PSRAM)
  • Splash + UI render upright at rotation 0
  • Web UI rotation change to 180° live-applies without reboot
  • Power-cycle preserves persisted rotation
  • Rotations 1 / 3 logged + snap to 0 (UI stays upright)
  • Touch wakes screen / cycles printer when tapped
  • No regression on esp32s3 and cyd envs (compile clean, no runtime change)

theNailz added 15 commits April 19, 2026 14:00
Lays down everything needed for a 320x480 Guition JC3248W535 build except
the AXS15231B QSPI panel driver itself, which is not in mainline LovyanGFX
(issue #699) and will follow in a separate commit.

- platformio.ini: new [env:jc3248w535] for ESP32-S3-N16R8 with QSPI/PSRAM
  memory config and 16MB partitions. Pins pre-wired from Guition reference
  and three independent community drivers (me-processware, byte-me404,
  ESPhome-JC3248W535EN). Touch RST/INT omitted — conflicting pin info
  across sources, the controller works fine polled.
- partitions_16mb.csv: dual 6.25MB OTA slots for the 16MB flash.
- include/layout.h: dispatch to layout_320x480.h when DISPLAY_320x480.
- include/layout_320x480.h: redesigned layout for the bigger 3.5" screen
  with larger gauges, always-visible AMS strip, and a more generous
  ETA/bottom zone. Not a stretch of the 240x320 layout.
- src/button.cpp: USE_AXS_TOUCH branch for the AXS15231B integrated touch
  controller. I2C @0x3b, 11-byte read-touchpad command packet returning
  14 bytes; protocol documented in the me-processware driver.
- src/display_ui.cpp: BOARD_IS_JC3248W535 placeholder with an explicit
  #error so the env is declared but cannot yet build. Panel driver lands
  in a follow-up.

Existing boards unaffected: verified esp32s3 env still builds cleanly.
AXS15231B is a QSPI IPS controller with no driver in mainline LovyanGFX
(issue #699). Implemented as a local header-only Panel_LCD subclass that
reuses LovyanGFX's standard Bus_SPI — the "QSPI" on this part is purely
protocol framing (commands wrapped in a 4-byte header, RAMWR prefixed with
{0x32, 0x00, 0x2C, 0x00}) rather than true quad-data transfers, so single-
wire MOSI on the D0 line is sufficient.

- src/lgfx_panel_axs15231b.hpp: Panel_AXS15231B modeled on lgfx::Panel_NV3041A.
  Inlined init sequence ported from moononournation/Arduino_GFX's
  axs15231b_320480_type1_init_operations. Executes a software reset, walks a
  packed (cmd, arg_count, args…) table, then SLPOUT + DISPON with the
  datasheet-mandated delays.
- src/display_ui.cpp: BOARD_IS_JC3248W535 branch now instantiates the real
  LGFX_JC3248W535 class (Bus_SPI @ 40 MHz, pin_dc=-1 since D/C is in-band).
- platformio.ini: bump LovyanGFX from ^1.1.16 to ^1.2.19 — Panel_LCD base
  class and the QSPI panel reference implementations (NV3041A, SH8601Z)
  that this driver cribs from landed in the 1.2 line. Verified esp32s3 and
  cyd still build on the new version.

Build: jc3248w535 compiles clean (1.3 MB firmware, 16% RAM, 20% of each OTA
slot). Hardware test on a physical board is still TBD.
Run a 5-phase color-cycle (1.5 s each) immediately after display init on
BOARD_IS_JC3248W535 to confirm whether pixel writes are actually landing
in GRAM. Will be removed once the driver is solid.

Observed on first flash: mostly solid light-blue panel with a small
~40x40 area at one corner showing varying lines across the cycle —
i.e. only the first few KB of the pixel stream reach the panel. Rest
of GRAM keeps its power-on state. Consistent with AXS15231B refusing
single-wire pixel data after the RAMWR/QSPI header (the chip switches
to 4-line input mode internally). Full QSPI via esp-idf spi_master
is the next step.
Adds two standalone diagnostic build envs that coexist with the in-progress
LovyanGFX production driver:

- jc3248w535_skel: custom Bus_QSPI diagnostic in src/skeleton_test.cpp.
  Direct spi_device_polling_transmit calls, no LovyanGFX. SPI mode 3, 40 MHz,
  vendor init bytes.

- jc3248w535_vendor: vendor ESP-IDF panel driver (src/vendor/esp_lcd_axs15231b.c,
  display-only fork) driven by a custom esp_lcd_panel_io_t shim
  (src/vendor/my_panel_io.c) that emits QSPI framing locally since
  arduino-esp32 3.0.17 lacks flags.quad_mode. Shim matches ESP-IDF v5.2
  esp_lcd_panel_io_spi behavior: cmd as a separate QIO transaction, color
  data streamed in 4 KB DMA chunks, CS handling per chunk.

Both envs run through init and drawing without errors but the panel still
shows random noise. Hardware is confirmed working via the vendor prebuilt
binary, so the bug is in our wire output. Next steps require either a logic
analyzer or a rebuild against the vendor's exact arduino-esp32 toolchain.

Also:
- Gitignore 212 MB lib/arduino_esp32s3_libs_vendor/ staging dir (unused
  after the esp32s3-folder-swap attempt failed to compile against the
  vendor's newer FreeRTOS layout).
- Minor WIP edits to production driver files (button.cpp, display_ui.cpp,
  lgfx_panel_axs15231b.hpp, main.cpp) carried over from earlier in the
  session; no functional change for other boards.
Replaces the hand-rolled QSPI bus / panel code in src/skeleton_test.cpp with
a minimal sketch built on moononournation/Arduino_GFX 1.5.x, which ships a
production-grade Arduino_AXS15231B panel + Arduino_ESP32QSPI databus. Paints
RED / GREEN / BLUE / WHITE / BLACK full-screen and halts.

Key settings (discovered through this session):

- Arduino_AXS15231B constructed with IPS=false. IPS=true sends INVON (0x21)
  during init, which on this panel revision ends up double-inverting —
  every color renders as its bitwise complement. IPS=false skips INVON and
  colors display correctly.
- ESP32QSPI_MAX_PIXELS_AT_ONCE overridden to 320 (one row per chunk). The
  library default 1024 px/chunk produced a noise band at rows ~120-240 on
  this board; 320 px chunks eliminate it.
- pclk dropped to 20 MHz for safety margin; skill recommends 32 MHz as the
  default start point but 20 is bulletproof on this board.

Clean full-screen colors verified end-to-end on the physical board.
Ablation of the prior baseline's two safety-margin settings:

- ESP32QSPI_MAX_PIXELS_AT_ONCE override removed — Arduino_GFX's default
  1024 px/chunk paints cleanly; the noise band we chased with smaller
  chunks was actually caused by the touch auto-reset loop contaminating
  every observation, not by chunk-boundary issues.
- gfx->begin() clock raised 20 MHz → 32 MHz (skill's recommended starting
  point). Full-screen fills remain clean.

IPS=false (no INVON) remains — that one is real; the panel inverts if you
let Arduino_AXS15231B send INVON.

End result: skel env uses library defaults for everything except the
well-documented IPS flag. Verified on physical board end-to-end.
…wrapper

Replaces the broken hand-rolled LovyanGFX Panel_AXS15231B (488 lines) with
a thin 180-line Panel_Device subclass that owns an Arduino_GFX
(Arduino_ESP32QSPI + Arduino_AXS15231B) internally and forwards LovyanGFX's
low-level drawing primitives (setWindow, writeBlock, writePixels,
drawPixelPreclipped, writeFillRectPreclipped, writeImage) to it.

Key points:

- `tft` stays a `lgfx::LovyanGFX&` — no changes to the 275 drawing call
  sites across the codebase. BambuHelper's rendering code is unaware the
  underlying pixel transport changed.
- `Arduino_AXS15231B` constructed with IPS=false (verified in skel env —
  IPS=true double-inverts colors on this panel revision).
- pclk 32 MHz (Arduino_GFX handles its own bus + chunking).
- Pin map hard-coded in the adapter since Arduino_GFX's databus takes
  pins at construction; the old AXS_QSPI_* build-flag pin defines were
  redundant and are removed.
- Also cleans out the AXS_MINIMAL_TEST halt hook in main.cpp and the
  temporary pre-splash red-rectangle diagnostic in display_ui.cpp; the
  production boot path now runs unmodified on this board.
- jc3248w535 env gets an explicit build_src_filter that excludes the
  skeleton_test / main_vendor diagnostics (they have their own setup/loop
  and would link-collide).

Verified on device: full boot, display.init / setRotation / fillScreen
all return cleanly, WiFi/AP/web server come up. No more custom QSPI bus.
Without the guard, the skel diagnostic's setup()/loop() collided with
BambuHelper main.cpp's when any non-skel env picked up src/skeleton_test.cpp
via the default build_src_filter. Wrapping the whole file in
`#ifdef BOARD_IS_JC3248W535_SKEL` turns it into an empty TU for every
other env.

All seven envs (esp32s3, cyd, ws_lcd_200, ws_lcd_154, esp32c3, jc3248w535,
jc3248w535_skel, jc3248w535_vendor) now build clean.
The AXS15231B in QSPI mode can't address arbitrary Y — every RAMWR resets
the internal y-pointer to 0 within the CASET column window, so per-call
draws at non-zero y always land at the top of the screen. Arduino_GFX
also unconditionally sends RASET after CASET which maps every sub-width
draw to the origin corner regardless of x/y. And with LovyanGFX's default
chunked pushSprite path, the multiple RAMWRC continuations across
separate CS cycles scramble the image.

Fix: treat the panel as a framebuffer sink only. Draw the whole UI into
a 320x480 PSRAM sprite, then flush via a new pushRawPixels escape-hatch
that emits exactly one Arduino_GFX writePixels call — single CS cycle,
one RAMWRC header, 150 internal VARIABLE-CMD continuations with CS held
LOW. Also subclass Arduino_AXS15231B to skip RASET and explicitly set
COLMOD=0x05 after init (Arduino_GFX's init table omits it, relying on
POR defaults that differ across batches).

Shapes, sizes and positions all render correctly with this baseline.
Colors are still rotated (RED<->BLUE<->GREEN cyclic, YELLOW->MAGENTA)
consistent with byte-swapped RGB565 reception — remaining follow-up.

Diagnostic sprite test gated behind DIAG_LGFX_POST_INIT build flag so
this commit can serve as a reproducible known-good state.
The AXS15231B in QSPI mode reads 16-bit pixel data LSB-first on the wire,
not MSB-first as MIPI DCS specifies. Arduino_GFX's MSB_32_16_16_SET
byte-swaps pixels from native LE to big-endian MSB-first before DMA,
which produced rotated colors on this panel: RED -> BLUE, GREEN -> RED,
BLUE -> GREEN, YELLOW -> MAGENTA (WHITE/BLACK unchanged because they're
palindromic).

Pre-swap each pixel with __builtin_bswap16 before handing it to
_agfx->writePixels(), which cancels out the internal swap so the net
wire byte order is LSB-first as the chip expects. Swap the buffer back
after push so the sprite is left consistent for the caller and repeat
pushes work.

Verified on-device: solid colors now render in their requested hues.
On JC3248W535 the display can only render correct pixels via a full-frame
raster flush (arbitrary Y is unaddressable in QSPI mode, and the chip
chokes on chunked multi-call pushes). Stand up a 320x480 16bpp PSRAM
LGFX_Sprite on this board, retarget the global `tft` pointer to it at
init, and flush to the panel once per loop() tick via pushRawPixels().
All existing tft.xxx() call sites keep working unmodified.

Make `tft` a `#define tft (*tft_ptr)` macro so the retarget is effective
at runtime — the previous C++ reference was bound at static-init time to
the panel and couldn't be rebound. Helper files (display_anim,
display_gauges, icons) used `tft` as a function-parameter name which
collided with the macro; renamed those parameters to `gfx`. No behaviour
change on any other board — `flushFrame()` is a no-op unless
BOARD_IS_JC3248W535 is set.

Force native portrait (rotation=0) on JC3248W535 — the sprite-push path
doesn't handle rotated framebuffers yet. dispSettings.rotation is
ignored on this board for now.

Drops the DIAG_LGFX_POST_INIT diagnostic scaffolding that got us here.
…is set

The auto-default-to-touchscreen branch in loadSettings() listed USE_CST816,
USE_XPT2046, and TOUCH_CS but not USE_AXS_TOUCH, so JC3248W535 boards with
no persisted btn_type would fall through to BTN_DISABLED and leave the
built-in capacitive touch uninitialised until the user manually enabled it
in the web UI. Add USE_AXS_TOUCH to the branch so fresh-NVS boards come up
with touch ready.

No effect on boards that already have btn_type saved in NVS — those keep
their persisted value. Users with prior settings need to flip the option
once in the web UI or clear NVS.
…buzzer

On the JC3248W535 the default button pin (4) is the same GPIO as
AXS_TOUCH_SDA, so picking the push-button or TTP223 option with the
default pin silently breaks the touchscreen bus. Mirror the existing
sanitizeBuzzerPin() idiom: check buttonPin against BACKLIGHT_PIN,
the active touch-bus pins, and buzzerSettings.pin on save and on
initButton(); zero the pin on conflict so the button becomes a no-op
instead of fighting a shared bus.
Users could select a rotation in the web UI but it was silently ignored on
this board — initDisplay() force-set rotation 0 because the sprite-push
path assumes a 320x480 raster order. Rotate the PSRAM sprite instead of
the panel: panel MADCTL stays at 0 (preserving the RASET-skip and
LSB-first byte-order invariants), and sprite storage dimensions stay
fixed for even rotations so flushFrame() is unchanged. Landscape (1, 3)
snaps to 0 with a Serial warning until a 480x320 layout exists.
The feature branch still carried investigation-era artifacts that
should not land on main:

- src/skeleton_test.cpp — standalone Arduino_GFX bring-up sketch,
  only useful while proving the driver worked on this hardware.
- src/main_vendor.cpp + src/vendor/* — vendor ESP-IDF AXS15231B
  panel driver + custom QSPI shim used to isolate framing bugs. The
  production wrapper superseded it; the files have been dead code
  since the sprite-push architecture landed.
- [env:jc3248w535_skel] / [env:jc3248w535_vendor] PlatformIO envs
  and their build_src_filter exclusion in [env:jc3248w535].

Also drop the "Option 3 from the skill" reference in the wrapper
header — that numbering doesn't match the skill today and the comment
reads better without it.

No behaviour change on the production jc3248w535 env.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant