Skip to content

PiBot Pendant v1.0 — Board Support Package (BSP)

PiBot Pendant v1.0 — Board Support Package (BSP)

Section titled “PiBot Pendant v1.0 — Board Support Package (BSP)”

The pibot_pendant_v1_0_bsp module is the hardware abstraction layer for the PiBot CNC Pendant v1.0 board. It initialises every piece of hardware on the pendant — display, touch, physical buttons, rotary encoder, 4-position switch, analog potentiometer, buzzer, and backlight — and wires each peripheral into the LVGL input-device subsystem so the rest of the firmware can operate on pure UI events without knowing anything about the underlying hardware.

Within the family of supported boards this is the most input-rich BSP: it is the only board that simultaneously supports a capacitive touch panel, physical push-buttons, a rotary encoder, a multi-position switch, and an analog potentiometer.


  1. Module Files
  2. Hardware Overview
  3. Architecture
  4. Component Details
  5. Initialisation Sequence
  6. Input Subsystems
  7. Display Pipeline
  8. Activity Manager Integration
  9. Custom LVGL Events
  10. Feature Flags
  11. Dependencies
  12. Related Modules

FilePurpose
boards/pibot_pendant_v1_0/components/bsp/board_init.cMaster board initialisation; all LVGL callbacks
boards/pibot_pendant_v1_0/components/bsp/control_event.hcontrol_event_t — unified payload for all input events
boards/pibot_pendant_v1_0/components/bsp/control_types.cRegisters custom LVGL event codes at runtime
boards/pibot_pendant_v1_0/components/bsp/esp3d_snapshot.hsnapshot_state_t — state struct for screen capture

PeripheralPart / InterfaceFeature Flag
DisplayILI9341 over SPIESP3D_DISPLAY_FEATURE
BacklightPWM via disp_backlightESP3D_BRIGHTNESS_CONTROL_FEATURE
TouchFT6336U over I2CESP3D_TOUCH_FEATURE
Physical buttons3 GPIO buttons via phy_buttonsESP3D_HARDWARE_BUTTONS_FEATURE
Rotary encoderQuadrature encoder via phy_encoder (PCNT)ESP3D_HARDWARE_ENCODER_FEATURE
4-position switch4 GPIO inputs via phy_switchESP3D_HARDWARE_SWITCH_FEATURE
PotentiometerADC via phy_potentiometerESP3D_HARDWARE_POTENTIOMETER_FEATURE
BuzzerLEDC PWM via buzzer (volume = duty cycle, BUZZER_PWM_DUTY_PERCENT)ESP3D_BUZZER_FEATURE

The ILI9341 display uses an SPI bus (disp_ili9341_spi driver). The FT6336U capacitive touch controller shares an I2C bus (bus_i2c) initialised with the pin pair defined by TOUCH_SDA_PIN / TOUCH_SCL_PIN in board_config.h.


Application Layer

LVGL Layer

Hardware Drivers

pibot_pendant_v1_0_bsp

configures

configures

configures

init I2C bus

configures

configures

configures

configures

configures

lv_display_create

lv_indev_create

lv_indev_create

lv_indev_create

lv_indev_create

lv_indev_create

touch_ft6336u_read

phy_buttons_read

phy_encoder_read

phy_switch_read

phy_potentiometer_read

payload type

payload type

payload type

payload type

LV_EVENT_PRESSED

LV_EVENT_PRESSED/RELEASED + control_event_t

LV_EVENT_KEY + control_event_t

LV_EVENT_SWITCH_PRESSED/RELEASED + control_event_t

LV_EVENT_POTENTIOMETER_CHANGED + control_event_t

activity_manager_init

activity_process_event

activity_process_event

activity_process_event

activity_process_event

activity_process_event

g_snapshot

get_lvgl_display

get_lvgl_lock

get_touch_indev

get_button_indev

get_encoder_indev

get_switch_indev

get_potentiometer_indev

registers custom events

board_init.c

control_event.h

control_types.c

esp3d_snapshot.h

disp_ili9341_spi

disp_backlight

touch_ft6336u

bus_i2c

phy_encoder

phy_buttons

phy_switch

phy_potentiometer

buzzer

lv_display

touch indev

button indev

encoder indev

switch indev

potentiometer indev

activity_manager

ESP3DXUi / UIManager

Screens


4.1 board_init.c — Master Initialisation

Section titled “4.1 board_init.c — Master Initialisation”

This single file is the entry point for all hardware on the pendant. It owns:

  • Module-level statics (all guarded by feature flags): lvgl_display, lvgl_buf1/2, lvgl_tick_timer, and one lv_indev_t * per input device.
  • board_init() — the only function called by the outer firmware (via board_init.h). It executes the full hardware initialisation sequence in a strict dependency order (see §5).
  • LVGL flush pipeline — lvgl_flush_cb() + notify_lvgl_flush_ready() (see §7).
  • Five LVGL read callbacks — one per input device (see §6).
  • Accessor functions — get_lvgl_display(), get_lvgl_lock(), get_touch_indev(), etc., called by ESP3DXUi during UI task startup.
// Public API exposed by board_init.c
esp_err_t board_init(void);
const char *board_get_name(void);
const char *board_get_version(void);
lv_display_t *get_lvgl_display(void);
_lock_t *get_lvgl_lock(void);
lv_indev_t *get_touch_indev(void); // ESP3D_TOUCH_FEATURE
lv_indev_t *get_button_indev(void); // ESP3D_HARDWARE_BUTTONS_FEATURE
lv_indev_t *get_encoder_indev(void); // ESP3D_HARDWARE_ENCODER_FEATURE
lv_indev_t *get_switch_indev(void); // ESP3D_HARDWARE_SWITCH_FEATURE
lv_indev_t *get_potentiometer_indev(void); // ESP3D_HARDWARE_POTENTIOMETER_FEATURE

4.2 control_event.h — Unified Event Structure

Section titled “4.2 control_event.h — Unified Event Structure”

All non-touch input devices (buttons, encoder, switch, potentiometer) deliver events to the active LVGL screen using a common payload. The pointer to this struct is passed as the user_data argument of lv_obj_send_event().

typedef struct {
lv_indev_t *indev; // LVGL indev handle that originated this event
uint32_t btn_id; // Button/switch channel index (0-based)
lv_indev_type_t type; // LV_INDEV_TYPE_BUTTON / ENCODER / POINTER
control_family_t family_id; // Which physical input family
int32_t steps; // Encoder clicks (+-1) or potentiometer mapped value (0-100)
uint32_t press_duration; // Milliseconds since the corresponding PRESSED event
} control_event_t;

control_family_t is declared in control_types.h and enumerates CONTROL_FAMILY_BUTTONS, CONTROL_FAMILY_ENCODER, CONTROL_FAMILY_SWITCH, CONTROL_FAMILY_POTENTIOMETER. Screens use family_id to distinguish which physical device sent the event when multiple indevs share the same LVGL event code (e.g. both button and switch indevs raise LV_EVENT_PRESSED).

4.3 control_types.c — Custom LVGL Event Registration

Section titled “4.3 control_types.c — Custom LVGL Event Registration”

LVGL only defines a fixed set of built-in event codes. To express switch and potentiometer state changes cleanly, three additional codes are registered at runtime after lv_init():

Global variableValue at startAssigned by
LV_EVENT_SWITCH_PRESSEDLV_EVENT_ALL (sentinel)lv_event_register_id()
LV_EVENT_SWITCH_RELEASEDLV_EVENT_ALLlv_event_register_id()
LV_EVENT_POTENTIOMETER_CHANGEDLV_EVENT_ALLlv_event_register_id()

control_events_init() is called at the end of init_lvgl() (inside board_init()). After the call, all three globals hold unique lv_event_code_t values that can be used with lv_obj_add_event_cb() and lv_obj_send_event() anywhere in the application. See §9 for usage examples.

4.4 esp3d_snapshot.h — Screen Capture State

Section titled “4.4 esp3d_snapshot.h — Screen Capture State”

When ESP3D_SNAPSHOT_FEATURE is enabled, the flush callback intercepts the raw pixel data that LVGL pushes to the display and accumulates it into a file. The entire snapshot subsystem is coordinated through a single global struct:

typedef struct {
FILE *file; // Open file receiving pixel data
volatile bool error; // Set on any write failure
uint32_t expected_pixels; // Total pixels in one full frame
volatile uint32_t captured_pixels; // Running count of pixels written
SemaphoreHandle_t mutex; // Guards concurrent flush access
volatile bool initialized; // Snapshot system ready
volatile bool ongoing; // A capture is in progress
} snapshot_state_t;
extern snapshot_state_t g_snapshot;

The ongoing flag is the sole gate: the flush callback checks it atomically before attempting to take the mutex. Pixel data is written in chunks of up to 120 bytes to stay within the safe stack-buffer limit for embedded targets (see memory constraints in CLAUDE.md). When captured_pixels >= expected_pixels, ongoing is cleared, stopping the capture.

Refer to the lvgl_system module for the higher-level snapshot API (esp3d_snapshot_deinit).


board_init() runs once from the application entry point before the LVGL UI task starts. The order is fixed by hardware dependencies.

init_lvgl()

lv_init

lv_display_create

heap_caps_malloc buf1

+/- buf2 with MALLOC_CAP_DMA

lv_display_set_buffers

LV_DISPLAY_RENDER_MODE_PARTIAL

lv_display_set_flush_cb --> lvgl_flush_cb

esp_timer_create

increase_lvgl_tick at LVGL_TICK_PERIOD_MS

esp_lcd_panel_io_register_event_callbacks

-> notify_lvgl_flush_ready

lv_indev_create x5

touch / button / encoder / switch / potentiometer

control_events_init

board_init

activity_manager_init

backlight_configure

+ backlight_set(0)

ili9341_spi_configure

init_touch_controller

bus_i2c_init -> touch_ft6336u_configure

phy_buttons_configure

phy_encoder_configure

phy_switch_configure

phy_potentiometer_configure

buzzer_configure

init_lvgl

Initialisation complete

Failure handling: every step checks ret != ESP_OK and returns immediately with an error log. The caller (ESP3DX::begin()) is responsible for halting startup if board initialisation fails.

Backlight is set to 0 before display initialisation so the user never sees garbage on the panel during the init sequence. It is raised to the configured brightness level later by the UI layer once the first screen is rendered.


All input devices are polled by LVGL’s internal timer via their registered read callbacks. The polling period for each indev uses LVGL’s default refresh period except the touch indev, whose timer is explicitly shortened to 10 ms to reduce the chance of missing rapid press/release cycles.

Callback: touch_read_cb()
LVGL type: LV_INDEV_TYPE_POINTER
Polling period: 10 ms (overridden from default)

The callback implements edge-detection to avoid flooding LVGL with repeated pressed/released transitions:

pressed AND activity_process_event() == false

pressed AND activity_process_event() == true

still pressed (report RELEASED to LVGL)

released - ignore release

still pressed (report position each poll)

released - activity_process_event()

Released

WakeUp

Pressed

ConsumedHold

  • Wake-up cycle: the very first touch after screen timeout is consumed (reported as RELEASED to LVGL) so it only wakes the display and is never forwarded to the application as a UI action.
  • Position reporting: while held, coordinates are forwarded to LVGL on every polling cycle.

Callback: button_read_cb()
LVGL type: LV_INDEV_TYPE_BUTTON
Channels: 3 (indices 0, 1, 2)

Processing order within one callback invocation: all released transitions are handled before pressed transitions. This prevents the corner case where a button that transitions released to pressed in the same poll cycle fires its press before its release.

Each button:

  • Gets its own control_event_t with family_id = CONTROL_FAMILY_BUTTONS and btn_id = i.
  • Accumulates press_duration (ms) between the start of the press and the release.
  • Routes events directly to the active screen via lv_obj_send_event(active_screen, LV_EVENT_PRESSED/RELEASED, &button_events[i]).
  • Applies the same wake-up consumption logic as touch: the first press after inactivity is consumed.

The active screen pointer is validated before each event dispatch; if the screen is destroyed mid-callback, the event is silently dropped with a log message.

Callback: encoder_read_cb()
LVGL type: LV_INDEV_TYPE_ENCODER
Direction: software-invertible via ENCODER_INVERT_ROTATION

The encoder uses an adaptive speed algorithm to balance responsiveness against LVGL overload:

wake-up

normal

< 10 ms

>= SLOW_MS

>= NORMAL_MS

>= FAST_MS

< FAST_MS

phy_encoder_read

clicks != 0

activity_process_event

every 100 ms?

ignore

data.state = RELEASED

measure

time_since_last (ms)

compare to

speed thresholds

skip - too fast

min_interval = 80 ms

min_interval = 40 ms

min_interval = 20 ms

min_interval = 10 ms

Send up to 5 LV_EVENT_KEY events

per callback

steps = +-1

Activity notification is throttled to once per 100 ms to prevent blocking the callback on frequent encoder spins.

Each event sent to the active screen carries a control_event_t with:

  • family_id = CONTROL_FAMILY_ENCODER
  • steps = +1 (clockwise) or -1 (counter-clockwise)
  • type = LV_INDEV_TYPE_ENCODER

Callback: switch_read_cb()
LVGL type: LV_INDEV_TYPE_BUTTON (4 virtual channels)
Custom events: LV_EVENT_SWITCH_PRESSED, LV_EVENT_SWITCH_RELEASED

The switch models an exclusive rotary selector: only one position can be active at a time. The driver (phy_switch_read) returns ESP_ERR_INVALID_RESPONSE for physically impossible multi-position states (hardware wiring error or contact bounce). Those states are silently dropped and do not trigger the activity manager, preventing spurious wake-ups.

Processing: released transitions fire before pressed transitions (same pattern as buttons). Events are dispatched directly to the active screen:

lv_obj_send_event(active_screen, LV_EVENT_SWITCH_PRESSED, &switch_events[i]);
lv_obj_send_event(active_screen, LV_EVENT_SWITCH_RELEASED, &switch_events[i]);

The control_event_t payload carries btn_id = i (0–3) and family_id = CONTROL_FAMILY_SWITCH.

Callback: potentiometer_read_cb()
LVGL type: LV_INDEV_TYPE_POINTER (repurposed for analog value)
Custom event: LV_EVENT_POTENTIOMETER_CHANGED
Mapped range: 0–100 (from ADC raw 0–4095)

The potentiometer ADC is noisy, so the callback applies an adaptive threshold scheme:

ConditionThreshold
Inactive longer than POT_INACTIVITY_THRESHOLD_MSPOT_WAKE_THRESHOLD_MAPPED (high — suppresses ADC noise)
Direction change detected (active use)1 (ultra-sensitive)
Descending value (active use)2
Ascending value (active use)3

A last activity timestamp tracks when the potentiometer last produced a real change. If no real change occurs within POT_INACTIVITY_THRESHOLD_MS, the threshold reverts to the high noise-suppression value so idle ADC dithering does not repeatedly wake the screen.

On first call, initialization_done latches the current value so there is no startup jump.

The control_event_t.steps field carries the mapped value (0–100), not a delta. Screens receiving LV_EVENT_POTENTIOMETER_CHANGED read .steps as the absolute potentiometer position.


notify_lvgl_flush_readySPI DMAesp_lcd_panel_draw_bitmapSnapshot Logiclvgl_flush_cbLVGL Corenotify_lvgl_flush_readySPI DMAesp_lcd_panel_draw_bitmapSnapshot Logiclvgl_flush_cbLVGL Corelvgl_flush_cb(disp, area, px_map)if g_snapshot.ongoing -> write pixels to file (chunks <= 120 B)lv_draw_sw_rgb565_swap (if DISPLAY_SWAP_COLOR_FLAG)esp_lcd_panel_draw_bitmap(x1, y1, x2+1, y2+1, px_map)SPI DMA transferon_color_trans_done IRQlv_display_flush_ready(disp)

lv_display_flush_ready() is called only from notify_lvgl_flush_ready() (the SPI DMA completion callback registered via esp_lcd_panel_io_register_event_callbacks()), never from lvgl_flush_cb() directly. This ensures LVGL waits for hardware completion before starting the next render cycle.

DISPLAY_SWAP_COLOR_FLAG is a compile-time constant in board_config.h. When set, the RGB565 byte order is swapped before the DMA transfer to match the ILI9341’s native byte order.

When ESP3D_SNAPSHOT_FEATURE is enabled, lvgl_flush_cb() intercepts pixel data before it reaches the SPI bus. The snapshot is accumulated across multiple flush calls (LVGL flushes partial areas in LV_DISPLAY_RENDER_MODE_PARTIAL mode) until captured_pixels >= expected_pixels.

Key constraints:

  • Chunk size: pixel data is written in at most 120-byte chunks to stay within the safe stack-buffer limit for embedded targets.
  • Mutex: g_snapshot.mutex is attempted with a zero timeout (xSemaphoreTake(..., 0)). If the mutex is unavailable, the flush proceeds without writing, keeping LVGL unblocked.
  • Error recovery: any fwrite failure sets g_snapshot.error = true and g_snapshot.ongoing = false, cleanly terminating the capture without affecting the display pipeline.
  • Pixel format: inferred from sizeof(lv_color_t) at runtime — 2 bytes for RGB565, 4 bytes for ARGB8888.

All five input callbacks query the activity manager before processing any event. This creates a unified screen wake-up mechanism across all input types.

returns true

(system already active)

returns false

(system was sleeping / just woke up)

Input event detected

(touch / button / encoder / switch / potentiometer)

activity_process_event()

Forward event to LVGL

as normal UI event

Consume event

(report RELEASED to LVGL)

do NOT forward to UI

Screen wakes

first input silently discarded

Active screen receives event

Each input type has slightly different activity-check throttling:

InputWhen activity_process_event() is called
TouchOn every press transition (not-pressed to pressed)
ButtonsOn every press transition; on release if press was not a wake-up
EncoderAt most once per 100 ms window
SwitchOn every valid pressed transition; invalid (multi-position) states skip it entirely
PotentiometerOnly when a real change above the adaptive threshold is detected

See activity_manager for activity_manager_init() and activity_process_event() implementation details.


This BSP registers three LVGL event codes that are absent from all other board BSPs in the project:

Event codeTriggerPayload (user_data)
LV_EVENT_SWITCH_PRESSEDSwitch channel transitions to activecontrol_event_t * with family_id = CONTROL_FAMILY_SWITCH, btn_id = 0..3
LV_EVENT_SWITCH_RELEASEDSwitch channel transitions to inactiveSame
LV_EVENT_POTENTIOMETER_CHANGEDPotentiometer crosses adaptive thresholdcontrol_event_t * with steps = 0..100

These codes are registered by control_events_init() using lv_event_register_id(), which returns unique integers beyond LV_EVENT_LAST. The globals are declared extern in control_types.h and visible to all screens.

Usage in screens:

// Register in a screen's create() function
lv_obj_add_event_cb(screen_obj, my_handler, LV_EVENT_SWITCH_PRESSED, NULL);
lv_obj_add_event_cb(screen_obj, my_handler, LV_EVENT_POTENTIOMETER_CHANGED, NULL);
// In the handler
void my_handler(lv_event_t *e) {
control_event_t *ctrl = (control_event_t *)lv_event_get_user_data(e);
if (ctrl->family_id == CONTROL_FAMILY_SWITCH) {
uint32_t position = ctrl->btn_id; // 0-3
}
if (ctrl->family_id == CONTROL_FAMILY_POTENTIOMETER) {
int32_t value = ctrl->steps; // 0-100
}
}

Primary consumers of these events: cnc_shared_screens (jog_screen, status_screen, macros_screen).


FileChange
board_config.hBUZZER_PWM_DUTY_PERCENT = 15 (tone duty cycle = volume, 1–50 %, 50 = loudest). Lowered from 50: the SC8002B amplifier’s current peaks on fast repeated key beeps are suspected of causing BROWNOUT resets (2026-11-01, not confirmed on hardware).
buzzer_def.h.duty_percent = BUZZER_PWM_DUTY_PERCENT passed to buzzer_configure() (see bsp_buzzer.md).
tasks_def.hSTREAM_TASK_PRIORITY 0 → 13. At priority 0 the TX drain (last hop of every jog and command) only ran in the time left by LVGL (15) and the gcode host (14) on core 1: 10–40 ms of scheduling latency per jog were measured, and up to 500 ms more when the UART retry loop kicked in. 13 keeps it below the host and LVGL but schedulable right after them; the task still sleeps 10 ms per loop, so it cannot starve anyone (latency audit 2026-10-17, risk B2).
lv_conf.hRegenerated for LVGL 9.6 (with CONFIG_LV_CONF_SKIP=n in the sdkconfig files, see Board_Support_Packages.md).

The PiBot 8 MB partition layout (3 MB app slots, ui_resources at 0x620000, factory at 0x780000) is described in Board_Support_Packages.md.


All optional hardware is controlled by compile-time flags defined in board_config.h (or sdkconfig). The BSP compiles cleanly with any combination:

FlagGuards
ESP3D_DISPLAY_FEATUREEntire display and LVGL subsystem
ESP3D_BRIGHTNESS_CONTROL_FEATUREBacklight PWM initialisation
ESP3D_TOUCH_FEATUREinit_touch_controller, touch_read_cb, touch_indev
ESP3D_HARDWARE_BUTTONS_FEATUREbutton_read_cb, button_indev
ESP3D_HARDWARE_ENCODER_FEATUREencoder_read_cb, encoder_indev
ESP3D_HARDWARE_SWITCH_FEATUREswitch_read_cb, switch_indev, LV_EVENT_SWITCH_*
ESP3D_HARDWARE_POTENTIOMETER_FEATUREpotentiometer_read_cb, potentiometer_indev, LV_EVENT_POTENTIOMETER_CHANGED
ESP3D_BUZZER_FEATUREbuzzer_configure
ESP3D_SNAPSHOT_FEATURESnapshot block inside lvgl_flush_cb and snapshot_state_t
DISPLAY_USE_DOUBLE_BUFFER_FLAGAllocates lvgl_buf2 (DMA double buffer)
ENCODER_INVERT_ROTATIONSwaps encoder clockwise/counter-clockwise direction

Build variant combinations are managed by pibot_pendant_v1_0_build.


pibot_pendant_v1_0_bsp

disp_ili9341_spi

display_drivers_spi

disp_backlight

display_drivers_spi

touch_ft6336u

touch_drivers

bus_i2c

communication_bus_drivers

phy_encoder

physical_input_drivers

phy_buttons

physical_input_drivers

phy_switch

physical_input_drivers

phy_potentiometer

physical_input_drivers

buzzer

buzzer_driver

activity_manager

lvgl

lvgl_system

esp_lcd

ESP-IDF

esp_timer

ESP-IDF

FreeRTOS

ESP-IDF

DependencyPurpose
display_drivers_spiILI9341 SPI panel driver and backlight driver
touch_driversFT6336U capacitive touch driver
communication_bus_driversShared I2C bus initialisation for the touch controller
physical_input_driversEncoder (PCNT), GPIO buttons, 4-position switch, potentiometer (ADC)
buzzer_driverBuzzer tone output
activity_managerScreen timeout and wake-up management
lvgl_systemLVGL core, display, indev, event, and timer subsystems
esp_lcd (ESP-IDF)LCD panel IO abstraction and DMA completion callbacks
esp_timer (ESP-IDF)High-resolution tick timer driving lv_tick_inc
FreeRTOS (ESP-IDF)SemaphoreHandle_t mutex used in snapshot state

ModuleRelationship
pibot_pendant_v1_0_factory_appFactory test application for the same hardware; uses its own lightweight ILI9341 driver (ili9341.c) without LVGL
pibot_pendant_v1_0_bootloaderCustom bootloader with button-triggered OTA recovery (is_button_pressed, backup_and_erase_otadata, beep_confirm)
pibot_pendant_v1_0_buildBuild scripts (build_one.py, variants.py, common.py) controlling which feature flags are compiled in
bspAll other board BSPs — reference for understanding what is common (touch, display, buttons) vs. unique to this board (encoder, switch, potentiometer)
cnc_shared_screensPrimary consumer of LV_EVENT_SWITCH_PRESSED, LV_EVENT_POTENTIOMETER_CHANGED, and the control_event_t payload
lvgl_systemOwns esp3d_snapshot_deinit() and lv_timer_pause_all/resume_all used alongside the snapshot capture feature
activity_monitoringUI-side activity monitoring that complements the BSP-side activity_manager integration