1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
|
# Display Fix Plan — AXS15231B QSPI Driver
## Board Info
- **Board:** Guition JC3248W535C_I_Y (Board C, `/dev/ttyACM0`)
- **Display IC:** AXS15231B (QSPI, 4 data lines)
- **Resolution:** 320x480 portrait (native), 480x320 landscape (via rotation)
- **Pins:** CS=45, CLK=47, D0=21, D1=48, D2=40, D3=39, BL=1
## QSPI Protocol (from ArduinoGFX source)
Register writes and pixel data use different QSPI framing:
| Operation | cmd | addr | flags | Data format |
|-----------|-----|------|-------|-------------|
| Register write (C8D8) | `0x02` | `LCD_CMD << 8` | `MULTILINE_CMD \| MULTILINE_ADDR` | Big-endian |
| Register write (C8D16) | `0x02` | `LCD_CMD << 8` | `MULTILINE_CMD \| MULTILINE_ADDR` | Big-endian |
| Register write (C8D16D16) | `0x02` | `LCD_CMD << 8` | `MULTILINE_CMD \| MULTILINE_ADDR` | Big-endian |
| Pixel data (first chunk) | `0x32` | `0x003C00` | `SPI_TRANS_MODE_QIO` | Big-endian (byte-swapped) |
| Pixel data (continuation) | — | — | `MODE_QIO \| VAR_CMD \| VAR_ADDR \| VAR_DUMMY` | Big-endian (byte-swapped) |
- CS: Manual GPIO control (`spics_io_num = -1`)
- Bus: permanently acquired via `spi_device_acquire_bus()`
- SPI config: `command_bits=8, address_bits=24, dummy_bits=0, mode=0, HALFDUPLEX`
## Root Cause: Byte-Order Mismatch
The ESP32-S3 is little-endian. The framebuffer stores RGB565 pixels as `[low_byte, high_byte]`. The AXS15231B expects pixels in big-endian order `[high_byte, low_byte]` over QSPI.
ArduinoGFX handles this by byte-swapping each pixel in `writePixels()` and `writeRepeat()` using the `MSB_16_SET(var, val)` macro: `var = (val >> 8) | (val << 8)`.
Our driver was sending raw little-endian pixels, causing the display to interpret the byte-swapped values as colors. Example:
| Intended color | RGB565 hex | Display sees (no swap) | Display sees (with swap) |
|---------------|-----------|----------------------|------------------------|
| Pink 0xF79F | `[9F, F7]` | R=19, G=63, B=23 (green) | R=30, G=60, B=31 (pink/white) |
| Red 0xF800 | `[00, F8]` | R=0, G=0, B=0 (black!) | R=31, G=0, B=0 (red) |
| Cyan 0x07FF | `[FF, 07]` | R=31, G=63, B=7 (yellow) | R=0, G=63, B=31 (cyan) |
## Root Cause: PSRAM Cache Coherency
ArduinoGFX allocates its pixel transfer buffer in **internal DMA SRAM**:
```cpp
_buffer = (uint8_t *)heap_caps_aligned_alloc(16, ESP32QSPI_MAX_PIXELS_AT_ONCE * 2, MALLOC_CAP_DMA);
```
Our framebuffer lives in PSRAM (8MB). When we modified the PSRAM framebuffer in-place (byte-swap), the CPU cache held the modified values but the SPI DMA controller read stale data from physical PSRAM. Result: black screen.
A separate allocation (even in PSRAM) works because it gets clean, freshly-written cache lines.
## Reference Implementations Studied
| Repo | Chip | Bus | Notes |
|------|------|-----|-------|
| [me-processware/JC3248W535-Driver](https://github.com/me-processware/JC3248W535-Driver) | AXS15231B | Arduino_ESP32QSPI | Arduino_Canvas wrapper, same pins |
| [F1ATB/JC3248W535-Demo](https://github.com/F1ATB/JC3248W535-Demo) | AXS15231B | Arduino_ESP32QSPI | Minimal demo, rotation=1 landscape |
| [AudunKodehode/JC3248W535EN-Touch-LCD](https://github.com/AudunKodehode/JC3248W535EN-Touch-LCD) | AXS15231B | Arduino_ESP32QSPI | Full library, QR codes, JPEG, coordinate transforms |
| [ArduinoGFX Arduino_ESP32QSPI.cpp](https://github.com/moononournation/Arduino_GFX) | — | — | Reference QSPI protocol implementation |
All use identical pin assignments and bus configuration.
## Checklist
### Done
- [x] Created worktree on branch `feature/display-fix`
- [x] Tracked untracked display files into branch
- [x] Added Board C support to Makefile (`flash-c`, `lock-c`, etc.)
- [x] Diagnosed root cause: QSPI protocol, not standard SPI
- [x] Fetched and analyzed ArduinoGFX QSPI source code
- [x] Discovered correct QSPI framing: `cmd=0x02` for regs, `cmd=0x32/addr=0x003C00` for pixels
- [x] Rewrote driver with correct QSPI protocol
- [x] Build succeeds, flash succeeds
- [x] Display shows recognizable text ("TollGate", "starting") — protocol confirmed working
- [x] Identified byte-swap requirement (green text = wrong byte order)
- [x] Identified PSRAM cache coherency issue (in-place swap = black screen)
- [x] Studied 3 reference implementations + ArduinoGFX source
- [x] Text positions adjusted for 320x480 portrait centering
- [x] Internal DMA byte-swap buffer (MALLOC_CAP_DMA, 4KB chunks)
- [x] **CRITICAL FIX: Added RAMWR (0x2C) before pixel data** — fixed wrapping/double-vision
- [x] Display shows correct colors: cyan TollGate + yellow starting... centered on black
- [x] Reduced font scale to 2/1 for clean readability
- [x] Implemented full UI: BOOT, READY (QR cycling), PAYMENT, ERROR screens
- [x] WiFi events trigger display state transitions (READY ↔ ERROR)
- [x] Color-coded wallet balance (green/yellow/red)
- [x] **ALL SCREENS VERIFIED WORKING ON HARDWARE**
### In Progress
- [ ] (nothing)
### TODO
- [ ] Run `make test-unit` to check for regressions
- [ ] Commit, push, and prepare for merge to master
- [ ] Restore render-on-change logic (proven correct, black screen was from swap not logic)
- [ ] Use saturated colors: cyan `0x07FF`, yellow `0xFFE0`, white `0xFFFF`
- [ ] Build, flash, verify correct colors and stable text
- [ ] Verify QR code rendering in READY state
- [ ] Verify payment/error screen states
- [ ] Remove debug log from flush
- [ ] Run `make test-unit` to check for regressions
- [ ] Commit working display driver
- [ ] Push to remote
## Implementation Plan
### 1. Internal DMA swap buffer in `axs15231b.c`
At init, allocate a static buffer:
```c
#define FLUSH_CHUNK_PIXELS 2048 // 4096 bytes, fits in internal DMA RAM
static uint8_t *s_swap_buf = NULL;
// In axs15231b_init():
s_swap_buf = heap_caps_aligned_alloc(16, FLUSH_CHUNK_PIXELS * 2, MALLOC_CAP_DMA);
```
### 2. Byte-swap flush loop
```c
void axs15231b_flush(void) {
// ... CASET, RASET ...
int total_pixels = s_width * s_height;
int offset = 0;
bool first = true;
cs_low();
while (offset < total_pixels) {
int chunk = min(FLUSH_CHUNK_PIXELS, total_pixels - offset);
// Byte-swap from PSRAM framebuffer into DMA buffer
uint8_t *src = (uint8_t *)(s_fb + offset);
for (int i = 0; i < chunk * 2; i += 2) {
s_swap_buf[i] = src[i + 1];
s_swap_buf[i + 1] = src[i];
}
// Send via QSPI
spi_transaction_ext_t t = {0};
if (first) {
t.base.flags = SPI_TRANS_MODE_QIO;
t.base.cmd = 0x32;
t.base.addr = 0x003C00;
first = false;
} else {
t.base.flags = SPI_TRANS_MODE_QIO | SPI_TRANS_VARIABLE_CMD |
SPI_TRANS_VARIABLE_ADDR | SPI_TRANS_VARIABLE_DUMMY;
}
t.base.tx_buffer = s_swap_buf;
t.base.length = chunk * 16;
spi_device_polling_transmit(s_spi, (spi_transaction_t *)&t);
offset += chunk;
}
cs_high();
}
```
### 3. Render-on-change in `display.c`
Only re-render when:
- `s_force_render` is set (state change, init)
- QR mode cycles (every 5s in READY state)
This eliminates the 1Hz full-screen redraw that caused text to "move around."
### 4. Color choices
| Element | Old color | New color | Reason |
|---------|-----------|-----------|--------|
| Boot title | `0xF79F` (near-white) | `0x07FF` (cyan) | High contrast on black |
| Boot subtitle | `0xB5B6` (gray) | `0xFFE0` (yellow) | Visible, warm accent |
| Ready label | `0xB5B6` | `0x07FF` | Consistent accent |
| Payment bg | `0x07E0` (green) | `0x07E0` | Keep — bright green is clear |
| Error bg | `0xF800` (red) | `0xF800` | Keep — bright red is clear |
|