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.
Table of Contents
Section titled “Table of Contents”- Module Files
- Hardware Overview
- Architecture
- Component Details
- Initialisation Sequence
- Input Subsystems
- 6.1 Capacitive Touch (FT6336U)
- 6.2 Physical Buttons
- 6.3 Rotary Encoder
- 6.4 4-Position Switch
- 6.5 Analog Potentiometer
- Display Pipeline
- Activity Manager Integration
- Custom LVGL Events
- Feature Flags
- Dependencies
- Related Modules
1. Module Files
Section titled “1. Module Files”| File | Purpose |
|---|---|
boards/pibot_pendant_v1_0/components/bsp/board_init.c | Master board initialisation; all LVGL callbacks |
boards/pibot_pendant_v1_0/components/bsp/control_event.h | control_event_t — unified payload for all input events |
boards/pibot_pendant_v1_0/components/bsp/control_types.c | Registers custom LVGL event codes at runtime |
boards/pibot_pendant_v1_0/components/bsp/esp3d_snapshot.h | snapshot_state_t — state struct for screen capture |
2. Hardware Overview
Section titled “2. Hardware Overview”| Peripheral | Part / Interface | Feature Flag |
|---|---|---|
| Display | ILI9341 over SPI | ESP3D_DISPLAY_FEATURE |
| Backlight | PWM via disp_backlight | ESP3D_BRIGHTNESS_CONTROL_FEATURE |
| Touch | FT6336U over I2C | ESP3D_TOUCH_FEATURE |
| Physical buttons | 3 GPIO buttons via phy_buttons | ESP3D_HARDWARE_BUTTONS_FEATURE |
| Rotary encoder | Quadrature encoder via phy_encoder (PCNT) | ESP3D_HARDWARE_ENCODER_FEATURE |
| 4-position switch | 4 GPIO inputs via phy_switch | ESP3D_HARDWARE_SWITCH_FEATURE |
| Potentiometer | ADC via phy_potentiometer | ESP3D_HARDWARE_POTENTIOMETER_FEATURE |
| Buzzer | LEDC 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.
3. Architecture
Section titled “3. Architecture”4. Component Details
Section titled “4. Component Details”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 onelv_indev_t *per input device. board_init()— the only function called by the outer firmware (viaboard_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 byESP3DXUiduring UI task startup.
// Public API exposed by board_init.cesp_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_FEATURElv_indev_t *get_button_indev(void); // ESP3D_HARDWARE_BUTTONS_FEATURElv_indev_t *get_encoder_indev(void); // ESP3D_HARDWARE_ENCODER_FEATURElv_indev_t *get_switch_indev(void); // ESP3D_HARDWARE_SWITCH_FEATURElv_indev_t *get_potentiometer_indev(void); // ESP3D_HARDWARE_POTENTIOMETER_FEATURE4.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 variable | Value at start | Assigned by |
|---|---|---|
LV_EVENT_SWITCH_PRESSED | LV_EVENT_ALL (sentinel) | lv_event_register_id() |
LV_EVENT_SWITCH_RELEASED | LV_EVENT_ALL | lv_event_register_id() |
LV_EVENT_POTENTIOMETER_CHANGED | LV_EVENT_ALL | lv_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).
5. Initialisation Sequence
Section titled “5. Initialisation Sequence”board_init() runs once from the application entry point before the LVGL UI task starts. The order is fixed by hardware dependencies.
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.
6. Input Subsystems
Section titled “6. Input Subsystems”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.
6.1 Capacitive Touch (FT6336U)
Section titled “6.1 Capacitive Touch (FT6336U)”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:
- Wake-up cycle: the very first touch after screen timeout is consumed (reported as
RELEASEDto 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.
6.2 Physical Buttons
Section titled “6.2 Physical Buttons”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_twithfamily_id = CONTROL_FAMILY_BUTTONSandbtn_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.
6.3 Rotary Encoder
Section titled “6.3 Rotary Encoder”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:
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_ENCODERsteps = +1(clockwise) or-1(counter-clockwise)type = LV_INDEV_TYPE_ENCODER
6.4 4-Position Switch
Section titled “6.4 4-Position Switch”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.
6.5 Analog Potentiometer
Section titled “6.5 Analog Potentiometer”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:
| Condition | Threshold |
|---|---|
Inactive longer than POT_INACTIVITY_THRESHOLD_MS | POT_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.
7. Display Pipeline
Section titled “7. Display Pipeline”7.1 LVGL Flush & DMA Completion
Section titled “7.1 LVGL Flush & DMA Completion”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.
7.2 Screen Snapshot Feature
Section titled “7.2 Screen Snapshot Feature”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.mutexis attempted with a zero timeout (xSemaphoreTake(..., 0)). If the mutex is unavailable, the flush proceeds without writing, keeping LVGL unblocked. - Error recovery: any
fwritefailure setsg_snapshot.error = trueandg_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.
8. Activity Manager Integration
Section titled “8. Activity Manager Integration”All five input callbacks query the activity manager before processing any event. This creates a unified screen wake-up mechanism across all input types.
Each input type has slightly different activity-check throttling:
| Input | When activity_process_event() is called |
|---|---|
| Touch | On every press transition (not-pressed to pressed) |
| Buttons | On every press transition; on release if press was not a wake-up |
| Encoder | At most once per 100 ms window |
| Switch | On every valid pressed transition; invalid (multi-position) states skip it entirely |
| Potentiometer | Only when a real change above the adaptive threshold is detected |
See activity_manager for activity_manager_init() and activity_process_event() implementation details.
9. Custom LVGL Events
Section titled “9. Custom LVGL Events”This BSP registers three LVGL event codes that are absent from all other board BSPs in the project:
| Event code | Trigger | Payload (user_data) |
|---|---|---|
LV_EVENT_SWITCH_PRESSED | Switch channel transitions to active | control_event_t * with family_id = CONTROL_FAMILY_SWITCH, btn_id = 0..3 |
LV_EVENT_SWITCH_RELEASED | Switch channel transitions to inactive | Same |
LV_EVENT_POTENTIOMETER_CHANGED | Potentiometer crosses adaptive threshold | control_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() functionlv_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 handlervoid 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).
9b. a14 Board Changes
Section titled “9b. a14 Board Changes”| File | Change |
|---|---|
board_config.h | BUZZER_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.h | STREAM_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.h | Regenerated 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.
10. Feature Flags
Section titled “10. Feature Flags”All optional hardware is controlled by compile-time flags defined in board_config.h (or sdkconfig). The BSP compiles cleanly with any combination:
| Flag | Guards |
|---|---|
ESP3D_DISPLAY_FEATURE | Entire display and LVGL subsystem |
ESP3D_BRIGHTNESS_CONTROL_FEATURE | Backlight PWM initialisation |
ESP3D_TOUCH_FEATURE | init_touch_controller, touch_read_cb, touch_indev |
ESP3D_HARDWARE_BUTTONS_FEATURE | button_read_cb, button_indev |
ESP3D_HARDWARE_ENCODER_FEATURE | encoder_read_cb, encoder_indev |
ESP3D_HARDWARE_SWITCH_FEATURE | switch_read_cb, switch_indev, LV_EVENT_SWITCH_* |
ESP3D_HARDWARE_POTENTIOMETER_FEATURE | potentiometer_read_cb, potentiometer_indev, LV_EVENT_POTENTIOMETER_CHANGED |
ESP3D_BUZZER_FEATURE | buzzer_configure |
ESP3D_SNAPSHOT_FEATURE | Snapshot block inside lvgl_flush_cb and snapshot_state_t |
DISPLAY_USE_DOUBLE_BUFFER_FLAG | Allocates lvgl_buf2 (DMA double buffer) |
ENCODER_INVERT_ROTATION | Swaps encoder clockwise/counter-clockwise direction |
Build variant combinations are managed by pibot_pendant_v1_0_build.
11. Dependencies
Section titled “11. Dependencies”| Dependency | Purpose |
|---|---|
display_drivers_spi | ILI9341 SPI panel driver and backlight driver |
touch_drivers | FT6336U capacitive touch driver |
communication_bus_drivers | Shared I2C bus initialisation for the touch controller |
physical_input_drivers | Encoder (PCNT), GPIO buttons, 4-position switch, potentiometer (ADC) |
buzzer_driver | Buzzer tone output |
activity_manager | Screen timeout and wake-up management |
lvgl_system | LVGL 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 |
12. Related Modules
Section titled “12. Related Modules”| Module | Relationship |
|---|---|
pibot_pendant_v1_0_factory_app | Factory test application for the same hardware; uses its own lightweight ILI9341 driver (ili9341.c) without LVGL |
pibot_pendant_v1_0_bootloader | Custom bootloader with button-triggered OTA recovery (is_button_pressed, backup_and_erase_otadata, beep_confirm) |
pibot_pendant_v1_0_build | Build scripts (build_one.py, variants.py, common.py) controlling which feature flags are compiled in |
bsp | All other board BSPs — reference for understanding what is common (touch, display, buttons) vs. unique to this board (encoder, switch, potentiometer) |
cnc_shared_screens | Primary consumer of LV_EVENT_SWITCH_PRESSED, LV_EVENT_POTENTIOMETER_CHANGED, and the control_event_t payload |
lvgl_system | Owns esp3d_snapshot_deinit() and lv_timer_pause_all/resume_all used alongside the snapshot capture feature |
activity_monitoring | UI-side activity monitoring that complements the BSP-side activity_manager integration |