SDK
CO5300 display
The AMOLED panel of a Kode Dot over MIPI-DSI: how it is brought up, how a frame reaches the glass without tearing, and every call of the driver.
What it is
The screen of a Kode Dot is a 410 x 502 pixel AMOLED with rounded corners. Every pixel makes its own light, so a black pixel is off and the panel draws current only for what it shows. The controller on the panel module is a Chipone CO5300. It receives the picture over MIPI-DSI, a serial link made for displays, on one data lane.
The driver does three things. It starts the panel in the order the hardware demands, it puts rectangles of pixels on the glass without tearing, and it sets the brightness. It knows nothing about what is drawn. Whatever renders the screen, LVGL in kodeOS or code of your own, hands it rectangles in RGB565, 16 bits per pixel.
The corners are cut with a radius of 114 px (KBSP_LCD_CORNER_R_PX). Anything anchored to a corner of the 410 x 502 rectangle is hidden by the glass.
How it is connected
flowchart LR subgraph p4["ESP32-P4"] gui["AO_Gui or<br/>your renderer"] -- "DISPLAY_FLUSH_SIG" --> ao["AO_Display"] ao -- "kdrv_display_flush()" --> drv["kdrv_display"] drv -- "esp_lcd_co5300" --> dsi["MIPI-DSI, 1 lane"] gpio7["GPIO7"] end subgraph module["Panel module"] panel["CO5300 panel"] touch["CST820"] end dsi --> panel tca["TCA6408A P7"] -- "VCI_EN" --> panel gpio7 -- "reset, shared" --> panel gpio7 -- "reset, shared" --> touch class gui app class ao ao class panel chip class touch chip class tca chip
AO_Display owns the panel (KBSP_RES_PANEL) and is the only caller of the driver, at priority 15. A renderer never calls kdrv_display_flush() itself. It posts DISPLAY_FLUSH_SIG and AO_Display passes the rectangle on.
Two calls are the documented exceptions. The renderer calls kdrv_display_flush_wait() and kdrv_display_flush_abort() directly, because LVGL waits for a flush inside its own render call and cannot read its event queue meanwhile. See sdk/kaos/display for the owner.
The four wires
Four wires matter:
- The panel's supply is pin P7 of the expander,
VCI_EN, active high (KBSP_IOEXP_BIT_DSP_PWR). The driver never touches the expander. AO_Display asks the expander's owner, AO_IoExp, to drive P7 high (first the level, then the direction, so the pin never drives low on its way up) and calls the driver only after the second answer. Seesdk/kdrv/tca6408a. - The reset is GPIO7 (
KBSP_PIN_DSP_TP_RST), active low. The touch controller is on the same net, so resetting the panel resets the touch controller as well. Seesdk/kdrv/cst820. - The MIPI D-PHY has its own 2.5 V rail, channel 3 of the ESP32-P4's internal LDO (
KBSP_LCD_DPHY_LDO_CHAN,KBSP_LCD_DPHY_LDO_MV). It must be up before any DSI traffic. - The link is one data lane at 360 Mbps. The pixel clock is 13 MHz. The stream needs about 210 Mbps in burst mode, so 360 Mbps leaves a factor of 1.7. At 480 Mbps the single lane has too little margin: on some content the panel misread lines and the lower half of a settings sheet shook by a few pixels.
The chip itself is driven by Espressif's esp_lcd_co5300 component, pinned to exactly 2.2.0. What belongs to this driver is the sequence around it.
Bring-up
Bring-up runs in AO_Display's own thread, about 245 ms of mandatory settling on a warm reset, which is why kode_init() starts AO_Display last. kdrv_display_init() does this:
- Waits 50 ms for the panel's supply to settle.
- Asks whether a panel is fitted. It drives GPIO7 high, then probes the touch controller at
0x15(KBSP_I2C_ADDR_TOUCH) every 20 ms for up to 300 ms. The probe comes before any DSI traffic because the panel driver's first act is to read the panel's ID over DSI, and a read from a panel that is not there never returns. A main board with no panel pays the whole 300 ms once, logs a warning, returnsKODE_ERRORand boots with the console only. A late answer logspanel assembly answered after N ms, which is what a cold power-on looks like. - Takes the D-PHY LDO channel.
- Opens the DSI bus (one lane, 360 Mbps) and the DBI control channel (8-bit commands, 8-bit parameters).
- Creates the CO5300 in DPI video mode: 13 MHz, RGB565, two framebuffers, reset on GPIO7, which
esp_lcdpulses. It runs the init table below. - Masks the bridge's underrun interrupt, turns on the DSI host's error latches, sends display-on and brightness 0, registers the frame callbacks and fetches the two framebuffers.
Then it registers a shutdown handler once. esp_restart() runs it in the task that asked for the restart, and it drives GPIO7 low and leaves it low. The panel and the touch controller stay in reset, black, through the restart, so the last picture does not stay on the glass. Only esp_restart() runs shutdown handlers.
After a panic, a watchdog reset, a brownout or the reset buttons, the bootloader pulses the same line low on every boot instead.
The first frame
The panel starts black and lights with its first frame. Across a warm reset the panel keeps its supply and the contents of its own RAM, and it shows them as soon as it receives display-on at any brightness above zero. So the init table writes brightness 0, and bring-up writes 0 again after display-on. A brightness asked for before the first frame is kept, with no write.
The first frame to reach the glass writes the kept value (100 % when nobody asked), once, and logs panel lit at the first frame. AO_Display hands over the stored brightness before it announces the panel, so the panel lights once, at the level it stays at.
Two framebuffers and the flip
Two framebuffers make the flush tear-free. In DPI mode the P4 scans a framebuffer continuously, and writing into the one being scanned shears whatever moves. The driver assembles chunks in the back buffer and flips at a frame boundary:
- The first chunk after a flip settles the back buffer's debt. The back buffer last showed the frame before the one on the glass, so the rows the last flip changed are copied from the front buffer into it. The debt is that frame's own rows and nothing more. If older rows were kept, every flip would copy the whole 402 KB frame.
- A chunk that is not the last one is copied into the back buffer, row by row, and its completion is signalled at once.
- The last chunk flips. The driver hands
esp_lcd_panel_draw_bitmap()a pointer inside the back buffer, which writes back the cache for the changed rows and the debt rows and points the scan at that buffer. Nothing is copied. - The completion of a flip is given when the frame being scanned ends, and the flip request does not release it. Released earlier, the next frame wrote into the buffer still being scanned, and whatever reached PSRAM ahead of the scan appeared on the glass one refresh early, in 3 to 7 frames out of 100 while a sheet was dragged. The wait costs the renderer at most one refresh, 16.8 ms.
- An empty frame signals the completion and does not flip. Flipping it would aim the next frame at the buffer being scanned with its debt copy skipped.
If the pair of framebuffers is not available the driver logs single framebuffer: flushes will tear and draws straight at the scan.
Completion and link health
Completion is one semaphore with one source. LVGL's rule is one completion per flush, and a binary semaphore swallows a second give without a word, after which the next wait never returns. So the semaphore is private to kdrv_display.c, and exactly five things end a flush:
- The DMA transfer callback (single-framebuffer path).
- The end-of-frame interrupt that completes a flip.
- A chunk that needs no transfer.
- An empty frame.
kdrv_display_flush_abort().
Both callbacks are IRAM_ATTR. With CONFIG_LCD_DSI_ISR_CACHE_SAFE the interrupt keeps running while the flash cache is off, which is exactly when an app image is being written. kdrv_display_init() refuses to start the panel (KODE_DENIED) if it cannot create the semaphore, because a panel without one draws a single band and hangs its renderer with no error.
The frame interrupt also counts the health of the link. Once per frame it reads the bridge's raw underrun flag and the DSI host's two status registers, and counts each kind of fault once per frame. The counters are statics in internal RAM written only by the interrupt, so they cost no task and no event. A screenshot cannot see these faults, because it reads the framebuffer, which is right whatever reached the glass.
The link and the panel's memory
The CO5300 keeps the picture in a memory of its own, the GRAM, and shows it with nobody driving it. So the DSI link can go while the screen stays lit, and a small change can be written into the GRAM alone. Four calls serve this. They exist for the rest of the Kode Dot (sdk/kaos/lowpower). They run in the thread of AO_Display only, and only while the renderer is parked. None of them resets the panel or sends its init table.
kdrv_display_link_set(up)takes the DSI link down or brings it back. Down stops the video scan-out, frees both framebuffers, deletes the control IO and the bus (and with it the lock that blocks light sleep) and powers the D-PHY rail off. The panel is not touched: whatever its memory holds stays on the glass at the brightness it was left at. Up raises the rail, the bus and the control IO, sends the full column and row window of the init table (columns 22 to 431, rows 0 to 501) on the control IO, and only then restarts the scan-out, with no reset and no init sequence. Any DCS command ends a memory write that is still open, so a panel that a GRAM write left in the middle of a RAMWR, or on a small window, cannot fold the video into the wrong place and show noise. The scan-out replaces the memory with black from its first frame, and the link behaves as afterkdrv_display_init(): dark until the first frame reaches the glass. Either direction is a no-op when the link is already there, and a failed up leaves the link down. Down waits up to three frames for a flip already handed to the engine.kdrv_display_buffers_epoch()is a number that changes every time the framebuffers are replaced, which only a successful link up does. A consumer that holds the pointers fromkdrv_display_back_buffer()andkdrv_display_front_buffer()keeps the epoch it read them under, and when it changes the pointers are stale. It is one word, safe from any thread.kdrv_display_panel_sleep(sleep)puts the panel to sleep (brightness 0, display off, sleep-in) or wakes it (sleep-out, display on). The memory keeps its content in sleep-in, but the glass is dark. Both directions wait 120 ms for the panel to settle. The call needs the link up, so a caller that wants the link down and the panel asleep sleeps the panel first. A panel put to sleep stays asleep across a link down and up. This is OFF's glass.kdrv_display_gram_write(x, y, w, h, rgb565)writes a rectangle of RGB565 straight into the GRAM with the link down. It builds a command-only link for the write (the D-PHY rail, the bus and the control IO, with no video, no framebuffer and no panel object), sends the column and row window and the memory write, then the window of the whole glass again, because the video scan-out streams a full frame through the window and a window left on the rectangle would fold the next frames into it. It waits for the packets to leave and takes the link down again. Every wait on the host's FIFOs is bounded by 20 ms, and a wait that runs out reads the flag once more before it fails, because a task that another task stalled (a flash erase stops the flash cache on both cores for milliseconds) finds the FIFO drained by the host. About 8,000 pixels take about 10 ms. With the link up the framebuffer is the picture, so the call is refused. The CO5300 takes a window that starts on an even column and an even row and is an even number of columns and rows wide, and the call refuses anything else instead of rounding it. A pixel is sent low byte first, the order LVGL keeps RGB565 in.
The framebuffers are new after a link up. A link down frees both framebuffers, and a link up allocates a pair that is black and not necessarily at the same addresses. Nothing in the driver stops a renderer that kept the old pointers from drawing into freed memory, and LVGL in direct mode renders into its buffers before any flush, so a refused flush protects nothing. So the renderer must be parked from before the link goes down until after it is up, and it must fetch its buffers again from the two getters before it paints the whole glass. On the consumer's side that is the repaint path: DISPLAY_REPAINT_SIG, which ends in kscreen_repaint() and its buffer re-alignment. Both directions log the two addresses at NOTICE.
Specifications
The board
| Thing | Value |
|---|---|
| Panel | AMOLED, controller Chipone CO5300, 410 x 502 pixels (KBSP_LCD_H_RES, KBSP_LCD_V_RES) |
| Corner radius | 114 px (KBSP_LCD_CORNER_R_PX) |
| Pixel format | RGB565. RGB888 is not used: the chip driver demands h * v % 8 == 0 at 24 bpp and 410 x 502 does not satisfy it |
| Link | MIPI-DSI bus 0, 1 data lane, 360 Mbps |
| DPI clock | 13 MHz. hsync, back porch, front porch 2, 5, 7; vsync, back porch, front porch 2, 5, 7 |
| Refresh | 16829 µs, 59.4 Hz (KDRV_DISPLAY_FRAME_US; a _Static_assert ties it to the timing) |
| D-PHY rail | P4 LDO channel 3 at 2500 mV |
| Supply | Expander pin P7 (VCI_EN), active high, owned by AO_IoExp |
| Reset | GPIO7, active low, shared with the CST820 |
| Framebuffers | Two, 410 x 502 RGB565, 411640 bytes each, stride 820 bytes |
| Column offset | 22: the init table sets columns 22 to 431 and rows 0 to 501 |
| Presence probe | CST820 at 0x15, every 20 ms, 300 ms at most |
| Chip driver | esp_lcd_co5300 ==2.2.0 |
The vendor init table the driver passes to esp_lcd_co5300:
| Command | Parameter | Meaning |
|---|---|---|
0xFE | 0x00 | Command page select |
0x3A | 0x55 | COLMOD: RGB565 |
0x35 | 0x00 | Tearing-effect output on |
0x53 | 0x20 | Write control display |
0x51 | 0x00 | Brightness 0, dark until the first frame |
0x63 | 0xFF | High-brightness-mode brightness |
0x2A | 0x0016, 0x01AF | Column address set, 22 to 431 |
0x2B | 0x0000, 0x01F5 | Row address set, 0 to 501 |
0x11 | none, 60 ms | Sleep out |
0x29 | none | Display on |
Brightness writes are DCS commands through the DSI host's command FIFO, which drains one low-power command per frame. The driver spaces writes at least 20 ms apart (BRIGHT_GAP_MS) and refuses to wait more than 50 ms for a full FIFO (BRIGHT_FIFO_WAIT_MS), returning KODE_TIMEOUT and writing nothing.
The driver
| Call | What it does |
|---|---|
kdrv_display_init() | The whole bring-up above. Idempotent. KODE_ERROR when no panel answers or the hardware fails, KODE_DENIED when the flush semaphore cannot be created. Blocking, about 245 ms |
kdrv_display_flush(x1, y1, x2, y2, pixels, last) | One RGB565 rectangle, x2 and y2 exclusive. Returns when the work is queued; completion is kdrv_display_flush_wait(). last marks the final chunk of a frame and flips. KODE_DENIED before init. KODE_ERROR for a rectangle off the panel, inverted or negative, or a NULL pixels |
kdrv_display_flush_wait(ms) | Waits for the flush in flight. false when ms runs out, so a lost completion costs a frame and a log line, not the interface |
kdrv_display_flush_abort() | Completes the flush in flight without a transfer, for a caller that finds it has nothing to send after the renderer already waits |
kdrv_display_brightness(percent) | 0 to 100, clamped. Before the first frame the value is kept and the call returns KODE_OK. Afterwards one paced DCS write: KODE_TIMEOUT when the FIFO stays full, KODE_ERROR when the panel refuses |
kdrv_display_back_buffer(), kdrv_display_front_buffer() | The framebuffer the next frame is assembled in, and the one on the glass, for a renderer that draws whole frames itself. NULL before the panel is up or on a single framebuffer |
kdrv_display_link(&out) | Copies the link counters |
kdrv_display_link_set(up), kdrv_display_buffers_epoch(), kdrv_display_panel_sleep(sleep), kdrv_display_gram_write(x, y, w, h, rgb565) | The link and the panel's memory, for the rest of the Kode Dot. A rectangle that is odd, empty or off the screen answers KODE_DENIED. kdrv_display_link_set() answers KODE_DENIED on a down with a frame open and KODE_TIMEOUT when a flip never completed |
kdrv_display_capture_begin(&image, &len) | Copies the front framebuffer into a PSRAM buffer that is already a BMP file. KODE_NO_RESOURCES without KODEOS_SCREENSHOT, on a single framebuffer or without PSRAM; KODE_DENIED for a NULL argument or when a capture is open |
kdrv_display_capture_end(), kdrv_display_capture_painted(), kdrv_display_capture_selftest() | Frees the capture; says whether any frame reached the glass since boot; checks that the header the driver writes is a well-formed BMP, without taking a capture |
kdrv_display_band(y, h, &rows, &len), kdrv_display_dirty_take(&y1, &y2) | The mirror's two calls: a pointer to h rows of the front buffer, top-down, and the row range that changed since the last call |
Drawing in place. A caller that produces whole frames, such as a PPA scaler or a video decoder, writes straight into kdrv_display_back_buffer() and flushes with that same pointer as pixels. Nothing is copied and the panel flips to that buffer.
The rectangle must be whole rows (x1 0, x2 the width) sent alone with last set, or the driver logs a warning and returns KODE_ERROR. The buffer changes at every flip, so ask for it again each frame, after kdrv_display_flush_wait().
The PPA refuses an output whose size is not a multiple of the cache line: a 410 x 502 RGB565 frame is 411640 bytes, 56 past one. Its tallest valid output is 410 x 496, and the caller fills the last six rows itself.
The capture is the file. The buffer is a 128-byte header (SHOT_HDR_LEN) followed by the pixels, rows bottom-up as BMP wants. The header is a BITMAPFILEHEADER and a BITMAPINFOHEADER with the three RGB565 masks (0xF800, 0x07E0, 0x001F) where a palette would be. A BITMAPV4HEADER is not used: macOS ImageIO reads a 16 bpp V4 header only when its colour-space field is zero and rejects LCS_sRGB, the one value that is true.
The link counters, in frames:
| Field | Counts frames with |
|---|---|
frames | A frame scanned out |
underruns | The bridge's FIFO ran dry in the line: stripes, diagonals and blue dots on the glass, PSRAM too slow |
dpi | A DSI host pixel-path error |
ack | An error the panel acknowledged |
phy | A D-PHY error |
other | Any other DSI host error bit, the command path's included |
st0, st1 | The last non-zero value of the host's two status registers, for decoding |
The console prints the same record with display_info.
The files
| File | What it does |
|---|---|
include/kdrv_display.h | The public contract: the calls, kdrv_display_link_t, KDRV_DISPLAY_FRAME_US |
src/kdrv_display.c | Bring-up, the flush and flip, the completion semaphore, brightness pacing, the restart handler, the capture and the mirror's two reads |
Gotchas
Example code
Read the panel's link counters every two seconds. The glass stays black because this app draws nothing. On the serial console you see a line such as frames=120 underruns=0 that grows by about 119 frames each time. The app's CMake lists kdrv in REQUIRES so that kdrv_display.h resolves.
/*
* display_link.c - 2026-10-28 - from Kode with love
*
* Prints how many frames the Kode Dot's panel link has scanned and how many
* of them ran dry, every two seconds, on the serial console. The glass stays
* black because this app draws nothing. On a healthy link underruns stays 0.
*/
#include "kode.h"
#include "kdrv_display.h"
/* Bring the system up, then report the DSI link counters forever. */
void app_main(void)
{
kdrv_display_link_t link;
kode_init();
while (1) {
kdrv_display_link(&link);
KLOG_I("link", "frames=%lu underruns=%lu",
(unsigned long)link.frames, (unsigned long)link.underruns);
kode_delay_ms(2000);
}
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kdrv/display: the driver,kdrv_display.c.kdrv_display.h: the calls and the link record.components/kaos/display: AO_Display, the owner.