Skip to main content

Screens and the UI Bridge

The UI layer answers one question: how do a dozen concurrent components safely share one 128x160 screen? Safi's answer is a hard rule, one bridge task, and a facade.

The rule and the bridge

Only display_driver calls LVGL. Everything else publishes events.

Enforcement is structural: no other component even includes an LVGL header. The bridge (display_task.c) subscribes to twenty-three event types (playback, volume, seek, WiFi, NFC, battery, OTA progress, stream metadata, SD mount state, time sync, shutdown, input) and translates each into calls on the screen facade (display_ui.h).

Why so strict: LVGL is not thread-safe, and the alternative (each component grabbing a lock and poking widgets) fails in two reliable ways. First, lock misuse under load: any missed lock is heap corruption with a random corpse. Second, priority coupling: the audio task rendering a "now playing" label would hold the UI lock at priority 10, and every render hiccup would become an audio hiccup. Routing through events means the audio task's UI cost is one queue post, constant and tiny; rendering happens at UI priority on the UI core.

Inside display_driver, the few facade functions callable from other contexts take the port's LVGL lock with a bounded 200 ms timeout and drop on failure: a jammed renderer costs a missed status update, never a deadlocked caller. Timeouts-and-drop rather than waiting forever, because for status display, stale-then-corrected beats stuck.

The screen catalog

ui_screens.c builds each screen once (lazily) and switches between them; screens are state, not popups:

ScreenTriggerContent notes
SplashbootMark, version, and the boot report lines in red
Idle clocknothing playingTime (--:-- until SNTP) in 12- or 24-hour form, the AM/PM suffix drawn in a Latin font the Arabic clock font lacks, ticking on each minute rollover; date, IP address, battery
Now PlayingEVENT_AUDIO_PLAY_STARTSurah name in shaped Arabic (22 pt), reciter, progress bar, elapsed/total, stream title via ICY
Browseencoder pressReciter then surah lists from the SD index
WiFi statusconnect/disconnect eventsState plus IP
OTA progressEVENT_OTA_PROGRESSBar plus "do not power off"
ErrorEVENT_SYSTEM_ERROR, battery emptyShort message

Two overlays float above whatever screen is active, on LVGL's top layer with auto-dismiss timers: the volume arc (2 s) and the NFC toast (2 s). Overlays are deliberately not screens: volume feedback must not navigate away from Browse.

The status bar (WiFi, battery and charging, volume) persists across screens and updates from events alone.

Backlight policy

display_task also owns the backlight timers: full brightness on any activity event, dim after 30 s, off after 5 min. It is the same task on purpose: activity is defined as "any event a human caused," and the bridge sees every one of them anyway.

The screenshot endpoint

GET /api/debug/screenshot returns the live screen as a BMP. It exists because the display was developed and verified headless: the panel was not wired during most of firmware bring-up, so the UI needed a way to be seen without glass.

Implementation (ui_screenshot.c): under the LVGL lock, lv_snapshot_take() renders the active screen into a buffer; the handler streams it as a bottom-up 24-bit BMP over chunked HTTP, converting RGB565 to RGB888 row by row (BMP because the format is a 54-byte header plus raw pixels, encodable with zero libraries and readable by everything).

It earns its permanent place three ways: UI regression checks in CI-adjacent scripts (fetch, compare), remote support ("send me a screenshot" for a device in another city), and documentation figures. It costs one small buffer during capture and is auth-and-rate-limited like any debug endpoint.

What died in the rewrite

The LVGL migration deleted the entire hand-rolled predecessor: the custom framebuffer renderer, a bitmap text engine with no shaping, and per-component draw calls. None of it was portable to the "one owner" model, and the Arabic chapter explains why its text output was unshippable anyway. The commit that removed it is the most satisfying diff in the repository's history.