Display Subsystem#
This directory contains the display implementations for all-devices-app on
ESP32 targets.
Integration Boundary#
main.cpp interacts with the display subsystem through three functions declared
in DeviceDisplay.h:
void InitDeviceDisplay(); // from app_main, before the CHIP event loop starts
void ShowRestartingMessage();
void InitDisplayDataModelListener(); // from the CHIP thread, after Server::Init
No graphics library or hardware driver headers are exposed outside the
display/ directory.
┌────────────────────────────────────────────────────────┐
│ Application Logic (main.cpp) │
└───────────────────────────┬────────────────────────────┘
│ Calls public interface only
▼
┌────────────────────────────────────────────────────────┐
│ DeviceDisplay.h │
│ - InitDeviceDisplay() │
│ - ShowRestartingMessage() │
│ - InitDisplayDataModelListener() │
└──────────────┬───────────────────────────┬─────────────┘
│ (ESP32) │ (ESP32-S3, ESP32-S31)
▼ ▼
┌───────────────────────────────┐ ┌──────────────────────────┐
│ tft/ Renderer │ │ lvgl/ Renderer │
│ - Vendored SPI TFT driver │ │ - LVGL 9 │
│ - ScreenFramework (C++) │ │ - Capacitive touch │
│ - Mechanical buttons (A/B/C) │ │ - board/DisplayBoard.h │
└───────────────────────────────┘ └────────────┬─────────────┘
│ one backend per board
┌───────────┴───────────┐
▼ ▼
┌────────────────────┐ ┌────────────────────┐
│ CoreS3DisplayBoard │ │ MosaicoDisplayBoard│
│ m5stack_core_s3 BSP│ │ esp-mosaico-bsp │
│ esp_lvgl_port │ │ esp_lvgl_adapter │
└────────────────────┘ └────────────────────┘
Directory Layout#
display/
├── DeviceDisplay.h # Public interface (InitDeviceDisplay, ShowRestartingMessage, InitDisplayDataModelListener)
├── README.md # This document
│
├── tft/ # Button-driven renderer for ESP32
│ ├── DeviceDisplay.cpp # Display initialization and main task loop
│ ├── Button.h / Button.cpp # Polled mechanical buttons (BtnA, BtnB, BtnC)
│ └── screens/ # ScreenFramework subclasses (fixed tab views)
│ ├── DeviceInfoScreen.h/.cpp
│ ├── DeviceSelectionScreen.h/.cpp
│ └── QRCodeScreen.h/.cpp
│
└── lvgl/ # Touch-driven renderer (M5Stack CoreS3, ESP-Mosaico)
├── DeviceDisplay.cpp # Display bring-up, lifecycle, and sleep
├── Ui.h/.cpp # Palette, fonts, size metrics and shared builders (tiles, badges, pills, animations)
├── Theme.h/.cpp # LVGL theme built on the default one: cards, buttons, sliders, dialogs, tables
├── DisplayWake.h # WakeDisplay(): undo an inactivity blank (display lock held)
├── CommissioningMonitor.h/.cpp # Platform events -> stage text on the QR screen, Home on success
├── board/ # Board support package bindings, one backend compiled per board
│ ├── DisplayBoard.h # Start, Lock, Unlock, SetBrightness, BoardName, EdgeInset
│ ├── CoreS3DisplayBoard.cpp # M5Stack CoreS3: espressif/m5stack_core_s3 + esp_lvgl_port
│ └── MosaicoDisplayBoard.cpp # ESP-Mosaico: esp-mosaico-bsp + esp_lvgl_adapter
├── DisplayNotificationHub.h/.cpp # Bridges Matter DataModel changes to LVGL widgets
├── NavigationStack.h/.cpp # Push/pop screen navigation and breadcrumbs
├── DeviceScreenRegistry.h/.cpp # Registry of device screens, keyed by endpoint
├── DeviceScreenHook.h # DeviceFactory post-registration hook
├── DeviceScreenRegistration.h/.cpp # Per-device-type registration overloads
├── clusters/ # Reusable cluster widgets, one card each
│ ├── OnOffClusterWidget # State label and toggle button with caller-supplied labels
│ ├── LevelControlClusterWidget # Level slider with percentage and caller-supplied level name
│ ├── ColorControlClusterWidget # Mode selector plus hue/sat, xy and temperature groups
│ ├── FanControlClusterWidget # Speed slider and mode buttons
│ ├── TemperatureMeasurementClusterWidget # Measured value and setter
│ ├── OccupancySensingClusterWidget # Occupancy state and toggle
│ ├── BooleanStateClusterWidget # Boolean state with caller-supplied labels
│ ├── IdentifyClusterWidget # Read-only identify countdown
│ ├── ChimeClusterWidget # Sound selection, enable and play
│ ├── ValveConfigurationAndControlClusterWidget # Valve state, countdown, level and open/close
│ └── BridgedDeviceBasicInformationClusterWidget # Node label and reachability
├── devices/ # Matter device type screens (composed from cluster widgets)
│ ├── DeviceHeader.h/.cpp # Standard device metadata header card
│ ├── OnOffLoadScreen # Any OnOffLoad device (light, plug-in unit, mounted control)
│ ├── DimmableLoadScreen # Any DimmableLoad device
│ ├── ColorLightScreen # Color temperature and extended color lights
│ ├── FanLoadScreen # Fan, air purifier, extractor hood
│ ├── BooleanStateSensorScreen # Contact, water leak, freeze and rain sensors
│ ├── OccupancySensorScreen # Occupancy sensor
│ ├── TemperatureSensorScreen # Temperature sensor
│ ├── ChimeScreen # Chime
│ ├── SpeakerScreen # Speaker (mute and volume)
│ ├── WaterValveScreen # Water valve
│ ├── BridgedNodeScreen # Bridged node
│ └── AggregatorScreen # Aggregator
└── screens/ # Application screens, reached by navigation
├── HomeScreen.h/.cpp # Root menu (Devices, Update Device Type, System)
├── SystemMenuScreen.h/.cpp # Submenu (QR Code, Status, Operations)
├── CommissioningCodesScreen.h/.cpp # Matter onboarding QR code and manual setup code
├── DeviceInfoScreen.h/.cpp # Diagnostics (fabrics, IP, memory, active device)
├── DeviceSelectionScreen.h/.cpp # Device type switching with confirmation modal
├── DeviceEndpointsScreen.h/.cpp # Endpoint tree of registered device screens
└── DeviceOperationsScreen.h/.cpp # Reboot and factory reset triggers with confirmation modals
screens/ and devices/ both hold screens; the split is by what they render.
screens/ are fixed application views, declared in the global namespace as
void Show<Name>(lv_obj_t * parent) and pushed onto NavigationStack by name
from other screens. devices/ are per-device-type views in chip::app, take
the device as an argument, and are reached only through DeviceScreenRegistry.
DeviceEndpointsScreen renders an lv_table: one row per registered device
screen, indented by its position in the endpoint tree, with the endpoint number
in a second column. Rows whose device type has no screen are drawn in red and
cannot be opened.
Build Configuration#
Exactly one renderer is compiled per target. main/Kconfig.projbuild derives
CONFIG_HAVE_DISPLAY (any board with a panel) and CONFIG_DISPLAY_LVGL (CoreS3
and Mosaico) from the selected board; main/CMakeLists.txt switches on those:
Renderer |
Supported Targets |
Framework & Dependencies |
|---|---|---|
|
M5Stack Basic/Gray, WROVER-KIT |
|
|
M5Stack CoreS3 |
LVGL 9 + |
|
ESP-Mosaico (ESP32-S31) |
LVGL 9 + |
The LVGL renderer never includes a BSP header directly. Everything it needs from
the board (start the panel and LVGL task, take and release the LVGL lock, blank
and restore the light output) goes through display/lvgl/board/DisplayBoard.h,
and main/CMakeLists.txt compiles exactly one backend from
display/lvgl/board/ for the selected CONFIG_DEVICE_TYPE_*. Each backend
wraps the LVGL port of its BSP: esp_lvgl_port on the CoreS3,
esp_lvgl_adapter on the Mosaico.
The legacy tft/ driver accesses ESP32 hardware registers directly and does not
compile for ESP32-S3. Targets using other chips must use lvgl/ or add a
dedicated renderer.
Sources are picked up per directory, not per file:
if (CONFIG_DISPLAY_LVGL)
list(APPEND SRC_DIRS_LIST
"${CMAKE_CURRENT_LIST_DIR}/display/lvgl"
"${CMAKE_CURRENT_LIST_DIR}/display/lvgl/screens"
"${CMAKE_CURRENT_LIST_DIR}/display/lvgl/clusters"
"${CMAKE_CURRENT_LIST_DIR}/display/lvgl/devices"
)
endif()
# ...
idf_component_register(PRIV_INCLUDE_DIRS ${PRIV_INCLUDE_DIRS_LIST}
SRC_DIRS ${SRC_DIRS_LIST}
PRIV_REQUIRES ${BOARD_REQUIRES})
target_sources(${COMPONENT_LIB} PRIVATE ${BOARD_SRCS})
display/lvgl/board/ and devices/chime/ hold one file per board, so those are
added through BOARD_SRCS for the selected board instead of SRC_DIRS.
SRC_DIRS compiles every source file in each listed directory, so a new .cpp
in one of them builds without any CMake change. Adding a new subdirectory does
require an edit, in both SRC_DIRS_LIST and PRIV_INCLUDE_DIRS_LIST. The
directory is scanned at configure time, so an existing build directory needs
idf.py reconfigure to notice a new file.
LVGL Renderer Runtime Flow#
┌───────────────────────────────┐
│ InitDeviceDisplay() │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ DisplayBoard::Start() │ (BSP: power rails, panel, touch
└──────────────┬────────────────┘ and the LVGL task)
│
▼
┌───────────────────────────────┐
│ DisplayBoard::Lock() │
│ - InitTheme() │
│ - NavigationStack::Init() │
│ - Push Home │
│ - Register Inactivity Timer │
│ - SetBrightness(on) │ (Delayed until there is a frame)
│ DisplayBoard::Unlock() │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ InitDisplayDataModelListener()│ (CHIP thread, after Server::Init:
│ - Hub Init() │ the fabric table is only populated
│ - CommissioningMonitor::Init()│
│ - Push System > QR Code │ by then. Pushed only when
│ when FabricCount() == 0 │ the device is not commissioned)
└──────────────┬────────────────┘
│
▼
┌────────────────────► Active State ◄────────────────────┐
│ │ │
│ (Touch anywhere on glass) │ (30 seconds of │
│ │ inactivity) │
│ ▼ │
│ ┌───────────────┐ │
│ │ Display Sleep │ │
│ │ - Fade to 0 │ │
│ │ - Wake Overlay│ │
│ └───────┬───────┘ │
└────────────────────────────┘───────────────────────────┘
Look and Feel#
Theme.cpp extends the default LVGL dark theme with an apply callback, so every
widget is styled at creation: plain containers become cards, and buttons,
sliders, switches and message boxes get matching styles. Screens compose from
the builders in Ui.h and take sizes from Ui::Px(large, small), which picks
the first value on 480-class panels and the second on 320x240, so the CoreS3
keeps its compact layout. Fonts follow the same rule.
Thread Safety#
Two threads touch this code: the LVGL port task, which owns every lv_obj_t,
and the CHIP event loop, which owns the data model. Each has a lock, and the
ordering between them is fixed:
[!IMPORTANT]
The CHIP thread may take the display lock; it does so on every attribute change. The LVGL task must therefore never take the CHIP stack lock — that is the opposite order and deadlocks.
LVGL operations must run under the LVGL port mutex:
if (DisplayBoard::Lock())
{
// LVGL operations...
DisplayBoard::Unlock();
}
Callbacks dispatched from within the LVGL task (such as widget event handlers or timer callbacks) already execute with this lock held.
The one-way rule dictates how each direction is written:
Writes to the data model (a touch that sends a command) are posted to the CHIP event loop with
DeviceLayer::SystemLayer().ScheduleLambda().Reads of cluster state are done directly from the LVGL task through the cluster getters, without any lock. The values read are scalars, no CHIP API asserting the stack lock is involved, and a torn read only shows a stale value until the next notification repaints it.
Reads that must be accurate cannot use the shortcut above. They schedule the read onto the CHIP event loop, then take the display lock to apply the result, which means the widget renders a placeholder first.
Data Model Notifications & Cross-Thread Synchronization#
UI widgets display dynamic device state that can be updated either locally via touch or remotely via Matter protocol commands (over Wi-Fi/Thread).
1. UI to Matter (Touch Interactions)#
Local touch handlers must never synchronously block on
chip::DeviceLayer::StackLock while holding DisplayBoard::Lock() to avoid
ABBA deadlocks with the CHIP thread. Instead, user touch callbacks
asynchronously post cluster mutations using
DeviceLayer::SystemLayer().ScheduleLambda():
lv_obj_add_event_cb(toggleBtn, [](lv_event_t * event) {
auto * cluster = static_cast<Clusters::OnOffCluster *>(lv_event_get_user_data(event));
DeviceLayer::SystemLayer().ScheduleLambda([cluster]() {
cluster->SetOnOff(!cluster->GetOnOff());
});
}, LV_EVENT_CLICKED, &cluster);
2. Matter to UI (DisplayNotificationHub)#
Dynamic updates from Matter protocol commands flow into
DisplayNotificationHub, which implements
chip::app::DataModel::AttributeChangeListener:
Matter Protocol Command (CHIP Thread)
│
▼
DataModel::AttributeChangeListener::OnAttributeChanged(path)
│
▼
DisplayBoard::Lock()
│
▼
DisplayNotificationHub dispatches to matching widget callbacks
│
▼
Widget lambda updates LVGL labels/sliders directly
│
▼
DisplayBoard::Unlock()
3. Automatic Lifecycle & RAII Unregistration#
To prevent dangling pointers when screens are popped from NavigationStack,
widgets subscribe using their parent container (card):
DisplayNotificationHub::Instance().Subscribe(
card, cluster.GetPaths()[0].mEndpointId, Clusters::OnOff::Id,
[stateLabel, toggleBtn, btnLabel, &cluster](const ConcreteAttributePath & path) {
if (path.mAttributeId == Clusters::OnOff::Attributes::OnOff::Id) {
UpdateOnOffDisplay(stateLabel, toggleBtn, btnLabel, cluster.GetOnOff());
}
});
DisplayNotificationHub::Subscribe automatically hooks the LV_EVENT_DELETE
event on card. When the screen or card widget is destroyed, the hub
immediately removes the subscription, ensuring no callback ever executes on a
freed widget. Because both unregistration and event dispatch execute under
DisplayBoard::Lock(), they are strictly serialized and cannot race.
Commissioning Progress#
CommissioningMonitor registers a platform event handler from
InitDisplayDataModelListener() and turns the commissioning session into a
status line under the manual pairing code:
Event |
Status line |
|---|---|
|
Controller connected over BLE |
|
Passcode verified, secure session open |
|
Credentials received, joining Wi-Fi |
|
Joined Wi-Fi, waiting for the controller |
|
Operational session established |
|
Commissioning successful (green) |
|
Commissioning failed: timed out (red) |
|
Commissioning failed: controller disconnected |
Before any of these the line reads “Waiting for a controller”, or “Commissioned”
when the node already has a fabric. A commissioned node shows a notice in place
of the codes: the root node has no Basic Commissioning Method, so a window
opened later carries its own passcode and the printed codes would not pair. The
fabric count behind this comes from a FabricTable::Delegate, so a fabric
removed by a controller brings the codes back. Every update wakes a blanked
panel. On success, and only while the QR screen is the one showing, an LVGL
one-shot timer pops the stack back to Home after the verdict has been on screen
for a moment, and the line returns to “Commissioned”.
Auto-Sleep and Wake-on-Touch#
An LVGL timer checks
lv_display_get_inactive_time()every second.After 30 seconds of inactivity, an LVGL animation fades the brightness to 0 through
DisplayBoard::SetBrightness().A transparent full-screen overlay is added to
lv_layer_top().The first touch on the sleeping display triggers
LV_EVENT_PRESSEDon the overlay, which callsWakeDisplay(): brightness back on, overlay deleted, resets the inactivity timer. The wake touch is absorbed so underlying widgets are not activated.
Adding New Components#
Adding a Screen to lvgl/#
File names carry the Screen suffix; function names do not, except in
devices/, where Show<Name>Screen keeps the per-device entry points apart
from the fixed views.
Declare the entry point in
display/lvgl/screens/<Name>Screen.h:#pragma once #include <lvgl.h> void Show<Name>(lv_obj_t * parent);
Implement widgets in
display/lvgl/screens/<Name>Screen.cpp:#include "<Name>Screen.h" void Show<Name>(lv_obj_t * parent) { lv_obj_set_flex_flow(parent, LV_FLEX_FLOW_COLUMN); lv_obj_set_style_pad_all(parent, 10, LV_PART_MAIN); lv_obj_t * label = lv_label_create(parent); lv_label_set_text(label, "Content"); }
Navigate from existing views:
#include "<Name>Screen.h" #include "NavigationStack.h" static void OnButtonClicked(lv_event_t * event) { NavigationStack::Push("<Title>", Show<Name>); }
No build change is needed: display/lvgl/screens is already listed in
SRC_DIRS. Run idf.py reconfigure if an existing build directory does not
pick the new file up.
Adding Device Controls#
Device screens are not pushed by name. They are registered as the device is
created and rendered later from DeviceEndpointsScreen. DeviceFactory is
instantiated with DeviceScreenHook (see main/AppDeviceFactory.h), which
calls RegisterDeviceScreen(device, DeviceScreenRegistry::Instance()) on the
CHIP thread once the device has been registered with the data model.
To give a device type a screen:
Add
display/lvgl/devices/<Name>Screen.h/.cppexposingvoid Show<Name>Screen(lv_obj_t * parent, <DeviceType> & device), composed from the widgets inclusters/. Prefer a capability type (OnOffLoad,DimmableLoad,FanLoad) over a concrete device type so several device types can share one screen.Declare
void RegisterDeviceScreen(<DeviceType> &, DeviceScreenRegistry &);inDeviceScreenRegistration.h.Define it in
DeviceScreenRegistration.cpp, filling in a title, the endpoint, and arenderFnthat calls the screen.Include the device type header in
DeviceScreenRegistration.hif it is not already there.
[!WARNING]
Step 2 is not optional.
DeviceScreenHookdetects support withstd::void_t<decltype(RegisterDeviceScreen(...))>, so a definition that is not declared in the header — or an overload that is ambiguous — makes the trait false. The build succeeds and the device silently falls back toRegisterMissingDeviceScreen, appearing in the list asNo UI - device type 0x…. If a new device type shows up that way, a missing declaration is the first thing to check.
Devices that own several endpoints register one entry per endpoint. Entries are
placed in the tree by their parent endpoint, which DeviceScreenRegistry reads
from the data model provider injected with SetEndpointSource().
Adding a New Target Board#
If the board has a touch panel and an ESP-IDF BSP with an LVGL port, reuse the
lvgl/ renderer:
Add
display/lvgl/board/<Board>DisplayBoard.cppimplementing the functions declared inDisplayBoard.hon top of the BSP.Add a
DEVICE_TYPE_<BOARD>choice inmain/Kconfig.projbuildand include it in theHAVE_DISPLAYandDISPLAY_LVGLdefaults.Select the backend in the
BOARD_SRCSblock ofmain/CMakeLists.txtand add the BSP tomain/idf_component.ymlunder atarget ==rule, or throughEXTRA_COMPONENT_DIRSif it is not on the registry.Add an
sdkconfig_<board>.defaultswith the LVGL requirements of the BSP (color depth,LV_CONF_SKIP, allocator) plusLV_USE_QRCODE,LV_USE_TABVIEWandLV_USE_MSGBOXfor the screens here.
For anything else, add a renderer:
Create directory
display/<target>/.Implement all three entry points declared in
DeviceDisplay.hindisplay/<target>/DeviceDisplay.cpp:InitDeviceDisplay(),ShowRestartingMessage()andInitDisplayDataModelListener().main.cppcalls all three, so omitting one fails the link; a renderer with no data model listener can leave that one empty.Add a Kconfig option in
main/Kconfig.projbuild.Update
main/CMakeLists.txtto conditionally compiledisplay/<target>/when that Kconfig option is set.