Skip to content

DLC32 MAX LCD — Board Support Package

The DLC32 MAX LCD BSP targets a compact ESP32-based CNC pendant that pairs an 8 MB ESP32-WROOM-32U-N8 with a 3.5″ 320 × 480 (portrait) capacitive-touch SPI display (ST7796 controller). The board is the MKS ESP-TFT35 V4.1 with a TS35 panel; display settings were confirmed on hardware on 2026-09-30. It is a display-and-touch-only board: there is no SD card, no rotary encoder, no potentiometer, and no buzzer enabled by default. All user input is touch-screen-driven.

The BSP lives under boards/dlc32_max_lcd/ and is one sibling in the family of boards documented alongside the pibot_pendant_v1_0 BSP and the esp32s3 display boards. It integrates with the shared Hardware Peripheral Drivers, the Core Platform, and the UI Framework.


  1. Architecture Overview
  2. Hardware Specification
  3. BSP Component Structure
  4. Board Initialization Sequence
  5. Display Subsystem
  6. Touch Subsystem
  7. Control Events
  8. FreeRTOS Task Layout
  9. Flash Partition Layout
  10. Build System
  11. Firmware Variants
  12. sdkconfig Variants
  13. Key Differences from Other Boards

UI Framework (Core 1)

Core Platform

Hardware Peripheral Drivers

BSP - boards/dlc32_max_lcd/

board_config.h

Pin & timing constants

board_init.c

Hardware init

control_types.h/.c

LVGL custom events

control_event.h

control_event_t

lv_conf.h

LVGL compile config

tasks_def.h

Task sizes & priorities

disp_st7796_spi_def.h

touch_ft6336u_def.h

serial_def.h ...

Driver instances

disp_st7796_spi

SPI display driver

touch_ft6336u

I2C touch driver

disp_backlight

GPIO backlight

bus_i2c

Shared I2C bus

activity_manager

Wake / idle mgmt

esp3d_snapshot

Screen capture

esp3d_log

LVGL

UIManager / ESP3DXUi

Screens

The BSP is the only layer that is board-specific. Everything above it (UIManager, screens, GCode host, communication transports) is shared across all boards. board_init() is the single entry point called by ESP3DX::begin() before the UI task starts.


ComponentDetail
SoCESP32-WROOM-32U-N8 (Xtensa LX6 dual-core, 240 MHz)
Flash8 MB (no PSRAM)
Display3.5″ 320 × 480 SPI TFT, used in fixed portrait — ST7796
TouchFT6336U capacitive controller, I2C
BacklightGPIO 5, active-low (inverted), PWM dimming
CNC serialUART2 (hardware)
Physical inputs1 physical button (GPIO 35); 3 virtual buttons via touch UI
SD cardNone (disabled in board config)
BuzzerGPIO 22 footprint — disabled by default
EncoderNot fitted
PotentiometerNot fitted
FunctionGPIONotes
LCD MOSI (SDA)23SPI3_HOST
LCD MISO (SDO)19SPI3_HOST
LCD SCLK18SPI3_HOST
LCD CS25
LCD DC/RS33
LCD RST27
Backlight5Active-low (inverted), LEDC PWM 5 kHz
Touch SDA0I2C0
Touch SCL4I2C0
Touch INT21
Touch RST14
UART2 RX16CNC serial in
UART2 TX17CNC serial out
Physical button35Input only, no pull-up
Buzzer22GPIO placeholder, not enabled

boards/dlc32_max_lcd/
├── board_config.cmake # CMake board selection & capabilities
├── partitions_8mb.csv # Custom partition table (dual OTA, no factory)
├── flash_params.json # Flash parameters for esptool
├── README.md # Quick-start reference
├── sdkconfig.8mb.wifi # SDK defaults — WiFi transport
├── sdkconfig.8mb.bt # SDK defaults — BT Serial + BLE
├── sdkconfig.8mb.bt_serial # SDK defaults — BT Serial only
├── sdkconfig.8mb.bt_ble # SDK defaults — BLE only
├── sdkconfig.8mb.serial # SDK defaults — Serial only
├── build_scripts/
│ ├── build_one.py # Build a single named variant
│ ├── common.py # Build helpers (build_variant, check_variant, ...)
│ └── variants.py # All 12 variant definitions (VARIANTS dict)
└── components/
└── bsp/
├── board_config.h # All pin/timing constants
├── board_init.h/.c # board_init() and LVGL callbacks
├── control_event.h # control_event_t struct
├── control_types.h/.c # control_family_t + custom LVGL events
├── tasks_def.h # FreeRTOS task sizes and priorities
├── lv_conf.h # Board-level LVGL configuration
├── disp_st7796_spi_def.h # ST7796 driver instance config
├── disp_backlight_def.h # Backlight config instance
├── touch_ft6336u_def.h # FT6336U driver instance config
├── serial_def.h # UART2 config instance
├── bt_serial_def.h # BT Serial config instance
└── bt_ble_def.h # BLE config instance

The *_def.h files hold const driver configuration structs instantiated directly from the constants in board_config.h. This pattern avoids dynamic configuration and keeps all hardware constants in one place.


board_init() runs once on Core 0 before the LVGL task is created. It initialises hardware in strict dependency order.

control_eventsLVGLtouch_ft6336u (I2C)st7796_spidisp_backlightactivity_managerboard_init()ESP3DX::begin()control_eventsLVGLtouch_ft6336u (I2C)st7796_spidisp_backlightactivity_managerboard_init()ESP3DX::begin()Idle/wake tracking startsBacklight off during initalt[ESP3D_BRIGHTNESS_CONTROL_FEATURE]SPI3 @ 20 MHz, 320x480 portraitmirror-X only (board-specific)I2C0 @ 400 kHz, addr 0x38alt[ESP3D_TOUCH_FEATURE]Touch poll period: 10 msalt[ESP3D_TOUCH_FEATURE]Registers custom LVGL event codesalt[ESP3D_DISPLAY_FEATURE]board_init()activity_manager_init()backlight_configure() then backlight_set(0)st7796_spi_configure()esp_lcd_panel_mirror(panel, true, false)bus_i2c_init() then touch_ft6336u_configure()lv_init() then lv_display_create(320,480)lvgl_displayallocate DMA draw buffer (single, 8 lines)lv_display_set_flush_cb(lvgl_flush_cb)esp_timer_start_periodic (LVGL tick, 20 ms)register on_color_trans_done to notify_lvgl_flush_readylv_indev_create(POINTER) with touch_read_cbcontrol_events_init()ESP_OK

Each step returns ESP_OK or logs an error and returns immediately. If any step fails the application will not reach the UI task; the failure is visible in the serial log before the display is active.


ParameterValue
ControllerST7796 (SPI mode)
SPI hostSPI3_HOST
Clock speed20 MHz (40 MHz gives a uniform black screen with the backlight on and no error in the logs; 10 MHz also works)
Resolution320 × 480 px (portrait)
Colour depth16-bit RGB565
Byte swapEnabled (DISPLAY_SWAP_COLOR_FLAG = 1)
OrientationPortrait (DISPLAY_ORIENTATION = 0), fixed: DYNAMIC_ROTATION_FEATURE OFF, DEFAULT_ORIENTATION PORTRAIT, FIXED_UI OFF
MirrorMirror-X only, applied in board_init() with esp_lcd_panel_mirror(st7796_spi_get_panel_handle(), true, false)
Colour inversionOn (DISPLAY_INVERT_FLAG = 1)
Backlight controlGPIO 5, active-low, PWM (BACKLIGHT_PWM_ENABLED_FLAG = 1, 5 kHz, 8 bits, LEDC timer 0 / channel 0)

The shared ST7796 driver’s orientation enum has four combinations and cannot express mirror-X alone, so DISPLAY_ORIENTATION stays at plain portrait and the board applies the mirror itself, like esp32_3248s035c. The UI uses the res_480_320 resource set, whose layout is drawn for portrait (see display_drivers.md).

Black screen on a new SPI board: with the backlight on and no error, try a low SPI clock (10 MHz) first; on this board the symptom looked like a wrong controller or wrong wiring, and was the SPI frequency.

partial render

(8 lines x 320 px = 5120 B)

SPI DMA

on_color_trans_done

ISR

LVGL

(Core 1)

DMA buffer

(MALLOC_CAP_DMA)

ST7796

panel

notify_lvgl_flush_ready()

→ lv_display_flush_ready()

The board uses a single partial-render buffer (8 scan lines of 320 px, 5 120 bytes). Double buffering is explicitly disabled (DISPLAY_USE_DOUBLE_BUFFER_FLAG = 0) to conserve heap on the no-PSRAM ESP32. The notify_lvgl_flush_ready callback fires from the SPI DMA completion ISR and releases LVGL to start the next render cycle.

When ESP3D_SNAPSHOT_FEATURE is enabled, lvgl_flush_cb intercepts each DMA transfer and streams the raw pixel data to a file before forwarding it to the panel, enabling full-screen captures without a separate frame buffer.


ParameterValue
ControllerFT6336U
InterfaceI2C0
I2C address0x38
I2C speed400 kHz
SDA / SCLGPIO 0 / GPIO 4
INTGPIO 21
RSTGPIO 14
Swap XYNo
Mirror X/YNo
Poll period10 ms

The touch_read_cb implements a touch-consumed-for-wakeup pattern to prevent the first press after screen inactivity from accidentally triggering a UI action:

screen idle / touch released

touch pressed

activity_process_event() returns false\n(display was sleeping)

activity_process_event() returns true\n(display already active)

touch released (ignored)

touch released\n(activity_process_event notified)

touch held

touch held (state=RELEASED sent to LVGL)

IDLE

FIRST_PRESS

WAKEUP_CONSUMED

NORMAL_PRESS

activity_process_event() is called by the Activity Manager component, which drives the screen-timeout and dim/wake lifecycle.


All hardware input events on this board flow through a unified struct:

typedef struct {
lv_indev_t *indev; // LVGL input device handle
uint32_t btn_id; // Button / control index
lv_indev_type_t type; // LV_INDEV_TYPE_POINTER, etc.
control_family_t family_id; // CONTROL_FAMILY_BUTTONS / TOUCH / ...
int32_t steps; // Encoder steps (unused on this board)
uint32_t press_duration; // Press duration in milliseconds
} control_event_t;
ValueMeaningUsed on this board
CONTROL_FAMILY_BUTTONSPhysical buttons✓ (GPIO 35 + 2 virtual)
CONTROL_FAMILY_SWITCH4-position switch✗
CONTROL_FAMILY_TOUCHTouchscreen✓ (primary input)
CONTROL_FAMILY_ENCODERRotary encoder✗
CONTROL_FAMILY_POTENTIOMETERAnalog potentiometer✗

control_events_init() registers three custom event codes after lv_init():

Event codeTrigger
LV_EVENT_SWITCH_PRESSEDPositional switch pressed (not fitted)
LV_EVENT_SWITCH_RELEASEDPositional switch released (not fitted)
LV_EVENT_POTENTIOMETER_CHANGEDAnalog value changed (not fitted)

These slots are declared for BSP interface compatibility. They fire no hardware events on this board but are required so shared UI code compiles without #ifdef guards. See Input System Architecture for how screens consume control events.


esp_timer (any core)

lvgl_tick

20 ms period

Core 1

tftUI (LVGL)

prio 15 / 12 KB

tftStream

prio 0 / 8 KB

esp3d_gcode_host_task

prio 14 / 4 KB

esp3d_rendering_rx_task

prio 5 / 4 KB

Core 0

tftNetwork

prio 0 / 4 KB

esp3d_serial_rx_task

prio 10 / 4 KB

esp3d_socket_*

prio 5 / 4-8 KB

TaskCorePriorityStack
Network (tftNetwork)004 096 B
Serial RX0104 096 B
Socket client RX058 192 B
Socket server RX054 096 B
LVGL UI (tftUI)11512 288 B
GCode host1144 096 B
Rendering RX154 096 B
Stream (tftStream)108 192 B

Important: LVGL runs exclusively on Core 1 at the highest priority. All lv_obj_* calls must originate from the UI task (or under lvgl_api_lock). See the Screens Architecture documentation for safe patterns.

No PSRAM is fitted; STREAM_CHUNK_SIZE falls to 1 024 B (the < 2 MB branch in tasks_def.h).


The partition table is custom, with the table itself relocated to 0xC000 (from the standard 0x8000) to accommodate a larger bootloader area (~44 KB vs. 32 KB standard).

Address Name Type SubType Size Notes
─────────────────────────────────────────────────────────────
0x01000 [bootloader] — — ~44 KB Standard ESP32 bootloader
0x0C000 [pt table] — — — Partition table (custom offset)
0x0D000 nvs data nvs 12 KB NVS key-value store
0x10000 otadata data ota 8 KB OTA selection metadata
0x20000 app0 app ota_0 3.1 MB Active firmware slot
0x340000 app1 app ota_1 3.1 MB OTA update slot
0x660000 ui_resources data 0x42 212 KB LVGL icon/font/theme assets
0x695000 flashfs data spiffs 1.42 MB LittleFS user filesystem

No factory partition — there is no SD card and therefore no recovery mechanism for a bricked firmware. Dual OTA (app0 / app1) is the only recovery path. Flash the initial firmware with the full esptool command; subsequent updates go through the OTA mechanism.

The ui_resources partition is managed by the UI Resources build pipeline. The flashfs partition stores user configuration, macros, and language packs.


validates variant name

from VARIANTS dict

--clean flag

normal build

variant binary + offsets header

build_one.py

main()

common.py

build_variant()

clean_build_dir()

clean installer_dir()

prepare_build_dir()

(IDF_TARGET marker check)

generate_resources()

generate_resources.py

--variant --resolution res_480_320

--partition-csv partitions_8mb.csv

--build-name

run_cmake_build()

idf.py -B build_dir ...cmake_args... build

_show_size_report()

idf.py size

_copy_ui_resources_bin()

installer//

_package_user_resources_kit()

installer//ui_resources_kit/

generate_flash_map()

flash_mgr.py --generate

FunctionPurpose
build_variant(config)Full build pipeline: clean → resources → cmake → size report → installer copy
check_variant(config)CMake reconfigure only — validates flags without compiling
generate_resources(cmake_args, build_dir)Derives --variant string from cmake flags and calls generate_resources.py
resource_variant_string(cmake_args)Maps cmake flags → {mem}mb_{transport}_{fw} string (e.g. 8mb_wifi_fluidnc)
build_config_name(cmake_args)Derives the config name (no log suffix); also passed to generate_resources.py as --build-name, so a resources binary fits the variant with or without logs
installer_config_name(cmake_args, build_dir)Installer directory name as computed by CMake (cmake/build_config_name.cmake, which appends the log level _LOG_<LEVEL>), read from <build_dir>/build_config_name.txt; falls back to build_config_name() before any configure
prepare_build_dir(build_dir)Writes .idf_target marker; clears build dir if target changed
installer_dir_for(config)Returns installer/<installer_config_name>/ path when cwd == REPO_ROOT
Terminal window
# From the board build-scripts directory:
cd boards/dlc32_max_lcd/build_scripts
# Build a single variant
python build_one.py 8mb_wifi_fluidnc
# Build and clean first
python build_one.py 8mb_bt_grblhal --clean
# Validate cmake flags only (no compile)
python build_one.py 8mb_serial_grbl --check
# List available variants (no args)
python build_one.py

For building all variants at once, use the repo-level tools/build_scripts/build_mgr.py which iterates over boards and calls build_one.py per variant.


All variants require MEMORY_8_MB=ON. The board enforces this in board_config.cmake — any other memory setting is a FATAL_ERROR.

WiFi and Bluetooth are mutually exclusive (no PSRAM; both stacks cannot coexist). The cmake/sanity_check.cmake enforces this at configure time.

DLC32_MAX_LCD

MEMORY_8_MB

FluidNC

grblHAL

grbl

Marlin (groundwork)

8mb_wifi_fluidnc

WiFi + Socket client + MDNS + Lua

8mb_bt_fluidnc

BT Serial + BLE

8mb_bt_serial_fluidnc

BT Serial only

8mb_bt_ble_fluidnc

BLE only

8mb_serial_fluidnc

UART2 only

8mb_wifi_grblhal

WiFi + Socket client + MDNS

8mb_bt_grblhal

BT Serial + BLE

8mb_bt_serial_grblhal

BT Serial only

8mb_bt_ble_grblhal

BLE only

8mb_serial_grblhal

UART2 only

8mb_serial_grbl

UART2 only

8mb_serial_marlin

UART2 only

VariantFWRadioUART2Socket clientMDNSLua
8mb_wifi_fluidncFluidNCWiFi✓✓✓✓
8mb_bt_fluidncFluidNCBT+BLE✓✗✗✗
8mb_bt_serial_fluidncFluidNCBT SPP✓✗✗✗
8mb_bt_ble_fluidncFluidNCBLE✓✗✗✗
8mb_serial_fluidncFluidNCNone✓✗✗✗
8mb_wifi_grblhalgrblHALWiFi✓✓✓✗
8mb_bt_grblhalgrblHALBT+BLE✓✗✗✗
8mb_bt_serial_grblhalgrblHALBT SPP✓✗✗✗
8mb_bt_ble_grblhalgrblHALBLE✓✗✗✗
8mb_serial_grblhalgrblHALNone✓✗✗✗
8mb_serial_grblgrblNone✓✗✗✗
8mb_serial_marlinMarlinNone✓✗✗✗

8mb_serial_marlin (TARGET_FW_MARLIN=ON, SERIAL_SERVICE, TFT_UI_SERVICE, TFT_TOUCH_SERVICE, UPDATE_SERVICE) is the build target of the 3D printer / Marlin groundwork added in a14 (see UI_Framework_&_Screens.md and CNC_Firmware_Integration.md).

No factory variants. FACTORY_VARIANTS = {} in variants.py. The board has no SD card, so factory-app recovery is not implemented.


Five sdkconfig defaults files cover the five radio combinations. The build system selects the appropriate one in board_config.cmake based on the active BT_SERVICE / WIFI_SERVICE flags. The file is applied only on the first configure (when no sdkconfig file exists in the build directory); subsequent configures preserve any menuconfig changes.

FileApplied when
sdkconfig.8mb.wifiWIFI_SERVICE=ON
sdkconfig.8mb.btBT_SERVICE=ON with both BT_SERIAL_SERVICE and BT_BLE_SERVICE ON
sdkconfig.8mb.bt_serialBT_SERVICE=ON, BT_SERIAL_SERVICE=ON, BT_BLE_SERVICE=OFF
sdkconfig.8mb.bt_bleBT_SERVICE=ON, BT_BLE_SERVICE=ON, BT_SERIAL_SERVICE=OFF
sdkconfig.8mb.serialNeither WiFi nor BT active

All sdkconfigs share these key settings:

SettingValue
IDF_TARGETesp32
CONFIG_ESPTOOLPY_FLASHSIZE8MB
CONFIG_ESPTOOLPY_FLASHFREQ80m
CONFIG_ESPTOOLPY_FLASHMODEdio
CONFIG_PARTITION_TABLE_CUSTOM_FILENAMEboards/dlc32_max_lcd/partitions_8mb.csv
CONFIG_PARTITION_TABLE_OFFSET0xC000
CONFIG_COMPILER_OPTIMIZATION_SIZEy

FeatureDLC32 MAX LCDPiBot Pendant v1.0ESP32-3248S035C
SoCESP32 (Xtensa)ESP32 (Xtensa)ESP32
Flash8 MB (no PSRAM)4 MB or 8 MB (no PSRAM)4 MB
Display320×480 SPI ST7796, portrait240×320 SPI ILI9341320×480 SPI ST7796, portrait
TouchFT6336U (capacitive)FT6336U (capacitive)GT911 (capacitive)
Physical buttons13✗ (touch only)
Encoder✗✓✗
Potentiometer✗✓✗
SD card✗✓✓
Factory app✗✓✓
Buzzer✗ (footprint only)✓✗
BT + WiFi coexist✗✗ (no PSRAM)✗
WiFi fallback modeSTA only (no AP)STA with AP fallbackSTA with AP fallback
HostnameDLC32-MAX-LCDESP3D-TFTboard-specific
LVGL bufferSingle / 8 linesSingle / configurableSingle / configurable

This board sets ESP3D_FALLBACK_MODE="1" (stay in wifi_sta on connection failure) rather than the default AP-fallback. This means a failed WiFi connection keeps the pendant in STA mode, and the user reconnects manually via the Connection Status Screen.