SDK
kscreen
LVGL on the Kode Dot's glass for an Active Object of your own: the display, the flush, the touch feed and the render pass, with the display_lvgl example.
What it is
kscreen puts an LVGL screen on the Kode Dot. LVGL is the library that draws the interface, and getting it onto the panel takes the same four jobs in every firmware that draws:
- Create the display.
- Hand each rendered rectangle to the owner of the panel.
- Feed the touch samples to LVGL.
- Run the render pass.
kscreen does those four, so you write the screens and nothing else.
kscreen is a struct inside an Active Object, the owner: the Active Object of your app that builds the screens. The owner hands kscreen the five events it needs and keeps everything else for itself, including its state machine, its buttons and its data. Only the owner's thread calls LVGL, so LVGL needs no lock.
An app that names kscreen gets the UI kit (sdk/kui) and LVGL with it, and kscreen_open() calls kui_init() on the display it creates, so the kit's styles are ready when the owner builds its screens.
kodeOS's own interface does not use kscreen: kgui drives LVGL itself. The example examples/display_lvgl runs on it.
How it is connected
flowchart LR touch["AO_Touch"] subgraph mine["Your app"] owner["Your owner AO"] -- "kscreen_touch()" --> ks["kscreen"] owner -- "kscreen_tick()" --> ks ks -- "lv_timer_handler()" --> lvgl["LVGL + kui"] end touch -- "TOUCH_EVT_SIG" --> owner ks -- "DISPLAY_FLUSH_SIG<br/>+ FlushEvt" --> disp["AO_Display"] disp --> panel["CO5300 panel"] class touch ao class disp ao class owner app class panel chip
The owner touches kscreen in five places, and the rest of the owner is yours:
- The constructor calls
kscreen_ctor(&me->screen, &me->super, APP_TICK_SIG), binding the renderer to its owner and naming the owner's private tick signal (a number fromMAX_SIGup). - The initial transition calls
kscreen_start(&me->screen), which subscribes the owner toDISPLAY_STATE_SIG,DISPLAY_REPAINT_SIGandTOUCH_EVT_SIGin that order, then asks AO_Display to announce the panel. The subscriptions come first because the answer can arrive the instant the request is posted. - On
DISPLAY_STATE_SIGthe owner callskscreen_open(&me->screen, Q_EVT_CAST(DisplayStateEvt)). OnKODE_OKit builds its screens and moves to the state that draws. - While drawing,
APP_TICK_SIGgoes tokscreen_tick(),TOUCH_EVT_SIGtokscreen_touch()andDISPLAY_REPAINT_SIGtokscreen_repaint(). - At start, the owner is started at
KODE_PRIO_USER_GUI, the level the SDK keeps for a renderer, afterkode_init()has returned.
Ask, do not wait
The panel is announced once, from inside kode_init(), and the bus keeps no copy. kscreen_start() therefore asks, and the answer says whether the panel came up at all, which is how "late" and "never" are told apart. A later DISPLAY_STATE_SIG, the answer to someone else's display_state_request(), finds the renderer open, and kscreen_open() returns KODE_EXISTS.
The display
LVGL gets a display of the glass's size in RGB565. When the panel offers two framebuffers, LVGL draws straight into them in direct mode: one is on the glass while LVGL draws the next frame in the other, and kscreen starts the two in step so that LVGL always draws in the buffer the panel is not showing. LVGL repaints only what changed and copies the rest over itself.
A panel driver with a single framebuffer gets two draw buffers instead, a tenth of the glass each, in PSRAM, and LVGL renders into them one part at a time.
The flush
kscreen posts one FlushEvt to AO_Display for each frame in direct mode. It names the framebuffer and the rows that changed. AO_Display owns the panel, flips to that framebuffer at the next refresh, and copies nothing. The rows tell it what to write back from the cache and what to give the screenshot and the mirror.
LVGL waits for a flush inside its own render call, so this thread is not reading its queue while it waits. The wait is a blocking take on the semaphore of kdrv_display_flush_wait() instead of a spin loop, and it is one of the SDK's three documented exceptions to "everything is an event". It lasts 500 ms at most.
When the end of a flush never comes, the frame is lost, one error line is logged and the screen keeps working. The alternative is a renderer that never runs again with the board healthy underneath.
The touch feed
kscreen creates a pointer input in LVGL's event mode: LVGL reads the pointer when kscreen asks, once for each TOUCH_EVT_SIG sample, and once more in each render pass while a finger rests on the glass.
The default 33 ms read timer against AO_Touch's 30 ms sampling would read some samples twice and skip others, so a drag's measured speed would jump between 0, 1 and 2 times the real one. With the event mode, a drag that stops before the lift does not throw.
The render pass
A one-shot timer starts each pass. kscreen_tick() runs lv_timer_handler(), which says how long until LVGL's next timer is due, and re-arms the timer for that long plus one tick, never later than KODE_GUI_TICK_MS (10 ms).
It is never periodic. A periodic timer would keep posting while a slow frame renders, and a full queue is a QP assert. LVGL's clock is khal_time_ms(), so a pass that overruns costs nothing and animations do not drift.
The frame rate
The panel refreshes every 16.8 ms (59.4 Hz), and a frame reaches the glass only at a refresh. A frame drawn in under 16.8 ms keeps the refresh rate, and one that needs longer waits for the next refresh.
LVGL draws a frame on both cores with two draw threads that start in lv_init(), each with a 32 KB stack in PSRAM, at KODE_PRIO_USER_GUI. A _Static_assert in kscreen.c keeps LV_DRAW_THREAD_PRIO equal to that level, because a draw thread above it would stall everything the renderer yields to.
The repaint
AO_Display publishes DISPLAY_REPAINT_SIG after the display row of the production test (test component=display). During that row it ends every flush without showing its pixels, so the glass holds the row's last field while LVGL believes it is up to date. kscreen_repaint() realigns the buffers, invalidates the screen and the top layer, and the next pass paints everything back.
Specifications
The files
The files of the component.
| File | What it does |
|---|---|
include/kscreen.h | kscreen_t and the seven calls |
src/kscreen.c | The display and its modes, the flush and its wait, the pointer, the render pass, the repaint and the rotation |
Kconfig | KSCREEN_ROTATE_PAD |
README.md, CMakeLists.txt, idf_component.yml | The component's guide, build and manifest |
kode and kui are required publicly, because kscreen.h speaks their types: QActive and QTimeEvt, the panel's events, lv_display_t.
kdrv, khal and kbsp are private: the flush completion is kdrv_display's semaphore, the clock and the PSRAM heap are khal's, and the glass's size is a board fact. kscreen.h includes kode_ao.h and lvgl.h.
The calls
The seven calls, in the order an owner uses them.
| Call | When | What it does |
|---|---|---|
kscreen_ctor(s, owner, tick_sig) | The owner's constructor | Binds the renderer to its owner and to the owner's private tick signal |
kscreen_start(s) | The owner's initial transition, which runs after kode_init() has returned | Subscribes to DISPLAY_STATE_SIG, DISPLAY_REPAINT_SIG and TOUCH_EVT_SIG, then calls display_state_request() |
kscreen_open(s, d) | On DISPLAY_STATE_SIG | lv_init(), the display, the flush, the touch feed in event mode, then kui_init(), and arms the tick for KODE_GUI_TICK_MS. KODE_OK when LVGL is up; KODE_DENIED when d says the panel is not up; KODE_EXISTS when already open; KODE_NO_RESOURCES when the draw buffers, the display or the input could not be had. Every failure is logged, leaves nothing allocated and does not arm the tick |
kscreen_tick(s) | On the tick signal | One extra input read while a finger rests, one lv_timer_handler(), then re-arms the one-shot. Does nothing before a successful open |
kscreen_touch(s, t) | On TOUCH_EVT_SIG | Feeds one sample to LVGL |
kscreen_repaint(s) | On DISPLAY_REPAINT_SIG | Invalidates the screen and the top layer |
kscreen_set_rotation(s, rot) | Any time after open, from the owner's thread, outside kscreen_tick() | Turns the glass to LV_DISPLAY_ROTATION_0, _90, _180 or _270, back to 0 included. The screen is redrawn whole. KODE_OK when the glass is turned that way (already, or now); KODE_DENIED before open or for another value; KODE_NO_RESOURCES when the buffers could not be had, with the glass left as it was |
The E5 test's screens
A build with CONFIG_KODE_TEST_DOT adds one call. kscreen_start() also subscribes the owner to TEST_DOT_SCREEN_REQ_SIG, and the owner passes each request to kscreen_test_dot_event(s, e). The call deletes the canvas the last request built, builds the screen or the pattern the request names on LVGL's top layer with kui_test_dot (sdk/kui), draws and flushes it at once, and answers with test_dot_screen_done(kind, seq). An automatic screen over an automatic screen repaints only the cells that changed. The request for no screen deletes the canvas and answers nothing. Before kscreen_open() has succeeded nothing is shown and nothing answers, and AO_TestDot's wait runs out. The test itself is on sdk/kaos/test-dot.
The renderer
kscreen_t lives inside the owner's struct. The owner may read some fields and never writes any. The table lists the fields.
| Field | For the owner? | Meaning |
|---|---|---|
disp | read | The lv_display_t; NULL until kscreen_open() succeeds. For settings of your own, a scroll throw or a theme |
indev | read | The pointer input, in event mode |
pressed, x, y | read | Whether a finger is down, and the last sample's coordinates, for gestures |
tick | kscreen's | The QTimeEvt of the render pass |
owner | kscreen's | The Active Object this lives in; the sender of every FlushEvt |
direct, posted, band_y1, band_y2 | kscreen's | Direct mode, a flush with AO_Display not yet waited for, the changed rows of the frame being drawn |
rot, part_buf[2], rot_buf | kscreen's | The turn of the glass, the two partial-mode draw buffers, and where a band is turned before it goes to the panel |
The display modes
The three ways LVGL draws and flushes.
| Mode | LVGL draws into | The flush |
|---|---|---|
| Direct (panel with two framebuffers, glass unturned) | The panel's back framebuffer, a whole frame (410 x 502 x 2 bytes, about 402 KB) in PSRAM | One FlushEvt per frame with the changed rows, last set. AO_Display flips at the next refresh and copies nothing |
| Partial (a panel with one framebuffer) | Two draw buffers of KBSP_LCD_V_RES / 10 rows, 41,000 bytes each, in PSRAM | One FlushEvt per band |
| Turned (90, 180 or 270 degrees) | The same two partial buffers | Each band is turned into a third buffer, packed at the turned band's own width, then flushed as the rectangle it covers on the panel |
A turned screen redraws more slowly: a change of the whole screen runs at about 20 frames a second against 59 unturned, and a smaller change faster. The three buffers, 41,000 bytes each in PSRAM, are taken on the first turn and kept.
Touch follows by itself, because LVGL turns every point. The pad follows when CONFIG_KSCREEN_ROTATE_PAD is on: at 270 degrees the pad's right reads as up, through buttons_rotation_request(). TOP and BOT are side buttons and never turn.
The partial buffers are PSRAM and never internal RAM, whatever it costs a redraw, because the radio stack needs the internal pool. The launcher's full redraw measured 54 ms with 2 x 40 KB in PSRAM against 37 ms in internal RAM.
The numbers and the option
The timings, sizes and the one Kconfig option.
| Thing | Value |
|---|---|
KSCREEN_FLUSH_TIMEOUT_MS | 500: the longest LVGL waits for the panel to finish one flush |
KODE_GUI_TICK_MS | 10: the longest between two render passes |
| Panel refresh | 16.8 ms, 59.4 Hz |
LV_DEF_REFR_PERIOD | 16 ms (board file) |
| LVGL draw units | 2, each with a 32 KB stack in PSRAM, at priority 3 (KODE_PRIO_USER_GUI) |
| Owner priority | KODE_PRIO_USER_GUI, 3. The level holds a single Active Object, so one kscreen per image |
KSCREEN_ROTATE_PAD | y: the four pad buttons publish the direction they point on a turned glass. Off, the app gets the buttons as wired and maps them itself |
The example
examples/display_lvgl shows a title, a counter at 0 and a hint. TOP or a tap anywhere adds one, and BOT goes home to kodeOS. With the console up, screenshot shows what the Active Object drew. It needs no SD card, radio or network.
Its files:
| File | What it does |
|---|---|
main/display_lvgl_main.c | app_main(): kode_init(), then the constructor and start of the one Active Object. Declares KODE_APP_MANIFEST("Display", "example") |
main/display_lvgl_ao.c, main/display_lvgl_ao.h | The Active Object: a running parent that sends BOT home, a waiting state until the panel is announced, a drawing state that builds the screen and feeds kscreen |
main/CMakeLists.txt, main/idf_component.yml | REQUIRES kode, PRIV_REQUIRES kdrv kscreen; pins kodediy/kode and kodediy/kscreen at one tag |
sdkconfig.defaults | The partition table path, the app boot role, the console kept, KODEOS_RADIO=n, ESP_HOSTED_ENABLED=n, BT_ENABLED=n, and Montserrat 28 and 48 for the example's own labels, because the board compiles only Montserrat 14 |
Gotchas
Example code
An Active Object that renders with kscreen and draws one pill of the UI kit in the middle of the glass. The Kode Dot shows a rounded grey row reading "Hello" over "from the kit". It handles the panel's announcement and the tick. A full owner also routes TOUCH_EVT_SIG to kscreen_touch() and DISPLAY_REPAINT_SIG to kscreen_repaint(), as display_lvgl does.
/*
* kscreen_hello.c - 2026-10-28 - from Kode with love
*
* Renders with kscreen from an Active Object of your own and draws one pill
* of the UI kit in the middle of the glass: "Hello" over "from the kit".
*/
#include "kscreen.h"
#include "kui.h"
enum { TICK_SIG = MAX_SIG };
static QActive s_ao;
static kscreen_t s_screen;
static QEvtPtr s_queue[KQP_AO_QUEUE_LEN];
static StackType_t s_stack[KQP_AO_STACK_BYTES / sizeof(StackType_t)];
/* Open the display on the panel's announcement, then render on each tick. */
static QState Hello_running(QActive * const me, QEvt const * const e) {
(void)me;
if ((e->sig == DISPLAY_STATE_SIG) &&
(kscreen_open(&s_screen, (DisplayStateEvt const *)e) == KODE_OK)) {
lv_obj_add_style(lv_screen_active(), &kui_style_screen, 0);
lv_obj_center(kui_pill_create(lv_screen_active(), "Hello", "from the kit"));
} else if (e->sig == TICK_SIG) {
kscreen_tick(&s_screen);
}
return Q_HANDLED();
}
/* Subscribe, and ask the panel to announce itself. */
static QState Hello_initial(QActive * const me, void const * const par) {
(void)me;
(void)par;
kscreen_start(&s_screen);
return Q_TRAN(&Hello_running);
}
/* Bring the system up, then start the renderer at its own priority. */
void app_main(void) {
kode_init();
QActive_ctor(&s_ao, Q_STATE_CAST(&Hello_initial));
kscreen_ctor(&s_screen, &s_ao, TICK_SIG);
QACTIVE_START(&s_ao, KODE_PRIO_USER_GUI, s_queue, Q_DIM(s_queue),
s_stack, sizeof(s_stack), (void *)0);
}Compiled against the SDK with ESP-IDF v6.1 on 2026-10-28. TBD: a run on a Kode Dot.
In the SDK
components/kscreen: the component,include/kscreen.handsrc/kscreen.c.examples/display_lvgl: a counter on the glass, with touch, a button and BOT to go home.components/kui: the UI kit that kscreen brings with it.