UI Design & LVGL¶
Why native LVGL¶
The alternative to a native LVGL interface would be a web dashboard running in a browser embedded in the firmware (ESPHome's esp32_s3_box style, or a custom web server). That approach has real costs on a microcontroller:
- The WebSocket or HTTP stack competes with the audio stream for sockets
- The browser's layout engine re-renders on each update, triggering full repaints
- If the network is unreliable, the screen shows a loading spinner or a blank page
LVGL compiles into the firmware. The screen layout is computed once at boot. Updates are surgical: when a temperature value changes, only the label for that value is invalidated and redrawn. The rest of the screen is untouched. On the 360 MHz RISC-V core, with the framebuffer in 32 MB of PSRAM, a changed value redraws in under 10 ms; the whole screen takes 133 ms (measured on the tablet, firmware 3.2.0, 2026-09-28).
An added side effect: if the Wi-Fi drops or Home Assistant restarts, the last known state stays on screen exactly as it was.
Icons: vector fonts instead of images¶
Older touchscreen panels (Nextion, TFT_eSPI setups) typically use bitmap image files for icons. Each icon is a .png or .jpg embedded in flash, decoded at runtime.
This project uses Material Design Icons rendered as a TTF font (materialdesignicons-webfont.ttf). LVGL's font engine renders glyphs from the font file at the size specified in the style.
The difference in practice: - A bitmap sun icon at 45 px might weigh 8 KB as a PNG, decoded to 8 KB in RAM at runtime - The same icon as an MDI glyph at 45 px costs ~200 bytes in the font file, rendered on-the-fly by the font engine - Scaling is free: the same glyph can render at 24 px, 45 px, or 72 px without separate assets
A second icon font (IconeMeteo.ttf) covers weather-specific symbols not present in the MDI set.
Anti-aliasing note: fonts are configured at bpp: 1 (1 bit per pixel) rather than the default 4 bpp. This halves the font's RAM footprint and significantly reduces the CPU cost of rendering on each frame. At 45 px on a 1280 × 720 display, the visual difference between 1 bpp and 4 bpp is negligible at normal viewing distance.
Global style system¶
LVGL allows two approaches for styling widgets:
- Inline styles — set properties directly on each object:
lv_obj_set_style_bg_color(obj, color, LV_PART_MAIN) - Style objects — create a named
lv_style_t, set properties on it once, and attach it to multiple objects
This project uses approach 2 exclusively, via tab5-styles.yaml. All style objects are declared globally and attached by name in tab5-lvgl.yaml.
Why this matters on an ESP32-P4 with 768 KB of internal SRAM: - Each inline style property creates a local style record in the widget's internal data structure - With 80+ widgets, that's 80+ separate style allocations, fragmented across PSRAM - A shared style object, attached to 10 similar widgets, uses 1 allocation shared by all 10
In practice, moving from inline to global styles freed roughly 40 KB of PSRAM in this project.
Themes. Every shared style reads the active palette (UIColor) through a lambda, and the twenty-one themes swap the palette, the shapes (radius, borders, shadows) and three display fonts while the screen runs (ADR-0029). That is why a widget never carries a colour of its own: ESPHome would freeze it at compile time and no theme could change it.
Dynamic color¶
LVGL supports inline color tags in label text strings. A label can receive a string like:
"#FF6B35 27°C# Salon"
LVGL parses the hex color tag and renders "27°C" in orange, then "Salon" in the label's default color. No additional code required.
This is used for the work hours of the central card, early shifts in their own colour. The colours are picked on the device, from the palette of the active theme, and the text is built again when the theme changes: a colour written into a text is not repainted by a style (a white written there made the hours invisible in the light themes, fixed before 3.6.0).
Dimming inactive elements¶
When a button or control is unavailable (e.g., the climate control screen when the AC is off), the common approach is to hide the element with lv_obj_add_flag(obj, LV_OBJ_FLAG_HIDDEN). This works but leaves an empty hole in the layout.
This project uses recolor opacity instead:
lv_obj_set_style_img_recolor(obj, lv_color_hex(0x808080), LV_PART_MAIN);
lv_obj_set_style_img_recolor_opa(obj, 180, LV_PART_MAIN);
This overlays a grey tint on the element without hiding it. The layout stays symmetric, and the visual feedback clearly communicates "inactive" without disrupting the screen geometry.
CPU constraints to keep in mind¶
A few LVGL patterns that look innocent but are expensive on a microcontroller:
Rounded corners on transparent objects. If an object has bg_opa: 0 (transparent background) and radius > 0, LVGL still computes the clipping mask for the rounded corners every time the widget is redrawn, for nothing. Fix: set radius: 0 on transparent containers, or remove bg_opa: 0 if the object has no visual background.
Unused padding. Every non-zero padding value causes LVGL to include the widget in collision detection passes. Containers that are purely structural (used for positioning children, not for display) should have pad_all: 0 and border_width: 0.
Frequent full-screen invalidations. Calling lv_obj_invalidate(lv_scr_act()) forces a full repaint on the next tick. The correct pattern is to call lv_obj_invalidate(specific_widget) so only the changed area is redrawn.