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)
▼ ▼
┌───────────────────────────────┐ ┌──────────────────────┐
│ tft/ Renderer │ │ lvgl/ Renderer │
│ - Vendored SPI TFT driver │ │ - LVGL 9 │
│ - ScreenFramework (C++) │ │ - CoreS3 BSP │
│ - Mechanical buttons (A/B/C) │ │ - Capacitive touch │
└───────────────────────────────┘ └──────────────────────┘
Directory Layout#
display/
├── DeviceDisplay.h # Public interface (InitDeviceDisplay, ShowRestartingMessage)
├── 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 for ESP32-S3 (M5Stack CoreS3)
├── DeviceDisplay.cpp # Display bring-up, lifecycle, and sleep
├── 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
│ ├── LevelControlClusterWidget # Level slider with percentage
│ ├── 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
│ └── 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
│ ├── BridgedNodeScreen # Bridged node
│ └── AggregatorScreen # Aggregator
└── screens/ # Application screens, reached by navigation
├── HomeScreen.h/.cpp # Root menu (Devices, Select Device, 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
only) from the selected board; main/CMakeLists.txt switches on those:
Renderer |
Supported Targets |
Framework & Dependencies |
|---|---|---|
|
M5Stack Basic/Gray, WROVER-KIT |
|
|
M5Stack CoreS3 |
LVGL 9 + |
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})
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() │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ bsp_display_start() │ (Initializes PMIC rails, SPI panel,
└──────────────┬────────────────┘ touch I2C, and esp_lvgl_port task)
│
▼
┌───────────────────────────────┐
│ bsp_display_lock(kWaitForever)│
│ - NavigationStack::Init() │
│ - Push Home │
│ - Register Inactivity Timer │
│ bsp_display_unlock() │
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ bsp_display_backlight_on() │ (Delayed until first frame drawn)
└──────────────┬────────────────┘
│
▼
┌───────────────────────────────┐
│ InitDisplayDataModelListener()│ (CHIP thread, after Server::Init:
│ - Hub Init() │ the fabric table is only populated
│ - 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 │ │
│ │ - Backlight 0 │ │
│ │ - Wake Overlay│ │
│ └───────┬───────┘ │
└────────────────────────────┘───────────────────────────┘
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 (bsp_display_lock(kWaitForever))
{
// LVGL operations...
bsp_display_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 bsp_display_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)
│
▼
bsp_display_lock(kWaitForever)
│
▼
DisplayNotificationHub dispatches to matching widget callbacks
│
▼
Widget lambda updates LVGL labels/sliders directly
│
▼
bsp_display_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
bsp_display_lock, they are strictly serialized and cannot race.
Auto-Sleep and Wake-on-Touch#
An LVGL timer checks
lv_display_get_inactive_time()every second.After 30 seconds of inactivity,
bsp_display_backlight_off()sets backlight brightness to 0%.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 callsbsp_display_backlight_on(), deletes the overlay, and 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#
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.