Skip to content

Physical Inputs Module

The physical_inputs module provides low-level hardware drivers for the discrete, non-touch input devices used on CNC pendant boards. It is the only sub-module in the BSP layer that exclusively handles human-operated mechanical controls: pushbuttons, a quadrature rotary encoder, a multi-position selector switch, and an analog potentiometer.

Each driver is self-contained: it exposes a configure / read / deinit lifecycle and remains board-agnostic. Board-specific wiring (GPIO numbers, ADC channels, pull-up policy, debounce timing, etc.) is injected at runtime through typed configuration structures, keeping the driver code shared across every supported board variant.

The module lives at:

hardware/common/drivers/
├── phy_buttons/
│ ├── phy_buttons_config.h ← configuration type
│ └── phy_buttons.h / .c ← implementation
├── phy_encoder/
│ ├── phy_encoder_config.h ← configuration type
│ └── phy_encoder.h / .c ← implementation (PCNT-based)
├── phy_potentiometer/
│ ├── phy_potentiometer_config.h ← configuration type
│ └── phy_potentiometer.h / .c ← implementation (ADC oneshot)
└── phy_switch/
├── phy_switch_config.h ← configuration type
└── phy_switch.h / .c ← implementation

Control Event System

LVGL Input Device Layer

BSP Layer - boards/pibot_pendant_v1_0/components/bsp

physical_inputs - hardware/common/drivers

phy_buttons_read()

phy_encoder_read()

phy_potentiometer_read()

phy_switch_read()

LV_EVENT_PRESSED / RELEASED

+ control_event_t

LV_EVENT_KEY

+ control_event_t

LV_EVENT_POTENTIOMETER_CHANGED

+ control_event_t

LV_EVENT_SWITCH_PRESSED / RELEASED

+ control_event_t

phy_buttons_configure()

phy_encoder_configure()

phy_potentiometer_configure()

phy_switch_configure()

phy_buttons

GPIO + debounce

3 push-buttons

phy_encoder

ESP-IDF PCNT

quadrature decoding

phy_potentiometer

ADC oneshot

wiper position

phy_switch

GPIO + debounce

4-position selector

board_init()

init_lvgl()

button_read_cb()

encoder_read_cb()

potentiometer_read_cb()

switch_read_cb()

lv_indev

LV_INDEV_TYPE_BUTTON

(buttons)

lv_indev

LV_INDEV_TYPE_ENCODER

lv_indev

LV_INDEV_TYPE_POINTER

(potentiometer)

lv_indev

LV_INDEV_TYPE_BUTTON

(switch)

control_event_t

Unified event descriptor

control_events_init()

Custom LVGL event codes

Active Screen

lv_screen_active()


Files: phy_buttons_config.h, phy_buttons.h, phy_buttons.c

Manages up to 3 independent push-buttons connected to GPIO pins. The driver handles debouncing and active-level polarity internally, exposing a simple array-of-booleans read interface.

typedef struct {
gpio_num_t button_pins[3]; // GPIO pins for the 3 buttons
bool pullups_enabled; // Enable internal pull-up resistors
uint32_t debounce_ms; // Debounce time in milliseconds
bool active_low; // true = pressed when GPIO reads 0
} phy_buttons_config_t;
FieldTypeDescription
button_pins[3]gpio_num_tGPIO numbers, one per button (index 0–2)
pullups_enabledboolEnables ESP-IDF internal pull-ups
debounce_msuint32_tContact bounce filter, typically 20–50 ms
active_lowbooltrue for buttons wired to GND; false for VCC side
FunctionDescription
phy_buttons_configure(const phy_buttons_config_t *)Initialize GPIO pins and debounce state
phy_buttons_read(bool states[3])Fill states[] with current pressed/released status
phy_buttons_deinit(void)Release GPIO resources

2. phy_encoder — Quadrature Rotary Encoder

Section titled “2. phy_encoder — Quadrature Rotary Encoder”

Files: phy_encoder_config.h, phy_encoder.h, phy_encoder.c

Implements hardware-accelerated quadrature decoding using the ESP-IDF PCNT (Pulse Counter) peripheral. Two PCNT channels decode both edges of both phase signals (4× resolution). Since a14 the counter is read as an absolute position by a 10 ms sampling timer (see below), so fast rotation is never dropped.

typedef struct {
gpio_num_t pin_a; // Phase A GPIO
gpio_num_t pin_b; // Phase B GPIO
bool pullups_enabled; // Enable internal pull-ups
uint32_t debounce_us; // Informational; hardware uses pcnt_glitch_ns
uint32_t min_step_interval_us;// Reserved — not used in PCNT mode
uint32_t steps_per_rev; // Mechanical steps per revolution
int32_t pcnt_high_limit; // PCNT upper bound before auto-reset
int32_t pcnt_low_limit; // PCNT lower bound before auto-reset
uint32_t pcnt_glitch_ns; // Hardware glitch filter threshold in nanoseconds
} phy_encoder_config_t;
FieldDescription
pin_a / pin_bPhase A and B GPIO connections (quadrature signal pair)
steps_per_revUsed by higher layers to convert raw clicks → revolutions
pcnt_high_limit / pcnt_low_limitHardware counter limits; at a wrap the driver accumulator (accum_count) adds the limit, so the reported position stays absolute
pcnt_glitch_nsHardware debounce; default 500 ns balances noise rejection vs. sensitivity

The driver was rewritten for the handwheel jog (handwheel_jog.md):

  • Absolute position, never cleared. The PCNT unit is created with accum_count: the driver adds the high / low limit to an internal accumulator when the hardware counter wraps, so pcnt_unit_get_count() returns an absolute position and the counter never has to be cleared (clearing lost the pulses arriving in between). The watch points at the limits are only there so accum_count sees the wraps; the former ISR + FreeRTOS queue is gone.
  • Sampled every 10 ms by an esp_timer (SAMPLE_PERIOD_US), independently of the LVGL loop, which can be 100–300 ms late while a screen redraws. The timer converts pulses to detents (PULSES_PER_DETENT = 4, 4× decoding) with a floor division rounded to the nearest detent, so a ±1 pulse jitter at rest never flips the count.
  • Speed = detent count over a sliding window of 10 samples (100 ms), in detents per second.
  • Values written by the timer are 32-bit aligned (atomic on Xtensa) and read by the other tasks without a lock.
FunctionDescription
phy_encoder_configure(const phy_encoder_config_t *)Initialize the PCNT unit (both channels, accum_count, limit watch points), reset positions and speed window, start the sampling timer
phy_encoder_read(int32_t *steps)Detents since the previous call (current absolute position − position returned by the previous call): nothing is lost between calls. Used by the LVGL input device
phy_encoder_get_total_steps(int32_t *total_steps)Absolute detent position since initialization (never cleared)
phy_encoder_get_read_position(int32_t *position)Absolute position as of the last phy_encoder_read() call (a14)
phy_encoder_get_speed(uint32_t *speed)Rotation speed in detents/s over the 100 ms window (a14)
phy_encoder_reset_steps(void)Discard the detents not yet returned by phy_encoder_read(); the absolute position is kept (other consumers track it)
phy_encoder_get_config(phy_encoder_config_t *)Copy current configuration out
phy_encoder_deinit(void)Stop the sampling timer first, then stop, disable and delete the PCNT unit

4× Quadrature Decoding — Channel Configuration

Section titled “4× Quadrature Decoding — Channel Configuration”

PCNT Channel B (edges on pin_b, level on pin_a)

Rising edge → +1

when pin_a = HIGH

Falling edge → −1

when pin_a = HIGH

INVERSE action

when pin_a = LOW

PCNT Channel A (edges on pin_a, level on pin_b)

Rising edge → +1

when pin_b = LOW

Falling edge → −1

when pin_b = LOW

INVERSE action

when pin_b = HIGH

4 PCNT counts = 1 logical click

detents = pulses_to_detents(absolute count)

Every edge on either channel contributes to the count. The 10 ms sampling timer converts the absolute PCNT count to detents (pulses_to_detents(): 4 pulses per detent, rounded to the nearest detent), and phy_encoder_read() returns the difference with the position it returned last time:

*steps = position - read_position; // detents since the last read
read_position = position;

3. phy_potentiometer — Analog Potentiometer

Section titled “3. phy_potentiometer — Analog Potentiometer”

Files: phy_potentiometer_config.h, phy_potentiometer.h, phy_potentiometer.c

Reads a continuous position signal from a resistive potentiometer via the ESP-IDF ADC oneshot API. The raw 12-bit ADC value is optionally averaged over multiple samples to reduce noise before being returned to the caller.

typedef struct {
gpio_num_t pin; // GPIO pin connected to wiper
adc_channel_t channel; // ADC channel number
adc_bitwidth_t width; // ADC resolution (typically ADC_BITWIDTH_12)
adc_atten_t atten; // Input voltage range (attenuation)
bool filter_enabled; // Enable sample averaging
uint32_t filter_samples; // Number of samples to average
} phy_potentiometer_config_t;
FieldDescription
channelMapped from pin using board-specific ADC channel assignments
widthADC_BITWIDTH_12 → raw range 0–4095
attenADC_ATTEN_DB_11 → full-scale ≈ 3.3 V (most common)
filter_enabled / filter_samplesMulti-sample averaging; reduces ADC noise at cost of latency
FunctionDescription
phy_potentiometer_configure(const phy_potentiometer_config_t *)Initialize ADC oneshot handle and channel calibration
phy_potentiometer_read(uint32_t *value)Read raw ADC value (0–4095); returns ESP_ERR_INVALID_RESPONSE if change is below driver threshold
phy_potentiometer_deinit(void)Delete ADC oneshot handle

4. phy_switch — 4-Position Selector Switch

Section titled “4. phy_switch — 4-Position Selector Switch”

Files: phy_switch_config.h, phy_switch.h, phy_switch.c

Decodes a 3-wire, 4-position rotary selector switch where each switch position activates a unique combination of 3 GPIO inputs. The driver validates that the GPIO bit pattern is a known valid state before reporting a position, and returns ESP_ERR_INVALID_RESPONSE during mechanical transition states.

typedef struct {
gpio_num_t pins[3]; // Three GPIO pins encoding the 4 positions
bool pullups_enabled; // Enable internal pull-up resistors
uint32_t debounce_ms; // Debounce time in milliseconds
} phy_switch_config_t;
FieldDescription
pins[3]Each pin encodes one bit of the 4-position state
pullups_enabledEnables GPIO pull-ups (wiper pulls a pin to GND at each position)
debounce_msPrevents spurious transitions during mechanical contact switching
FunctionDescription
phy_switch_configure(const phy_switch_config_t *)Initialize GPIO pins
phy_switch_read(bool states[4])Fill states[] with decoded 4-position state; returns ESP_ERR_INVALID_RESPONSE for invalid pin patterns
phy_switch_deinit(void)Release GPIO resources

Important: The BSP switch_read_cb treats ESP_ERR_INVALID_RESPONSE as a silent no-op and does not call activity_process_event() for invalid states. This prevents phantom display wake-ups during mechanical contact bouncing between positions.


Active ScreenLVGL indev timer (~10 ms)activity_manager*_read_cb (BSP)phy_* DriverHardware (GPIO / ADC / PCNT)Active ScreenLVGL indev timer (~10 ms)activity_manager*_read_cb (BSP)phy_* DriverHardware (GPIO / ADC / PCNT)alt[Normal operation][Wake-up cycle (screen was sleeping)]alt[Input changed (threshold exceeded)][No change or invalid state]polling tickphy_*_read()read GPIO / PCNT counter / ADCraw valueprocessed state / deltaactivity_process_event()true = normal / false = wake-uplv_obj_send_event(active_screen, EVENT_CODE, &control_event_t)LV_RES_OKconsume event silently, no UI dispatchLV_INDEV_STATE_RELEASED

All four physical input types share a unified event descriptor that is passed as user_data in LVGL event callbacks:

// boards/pibot_pendant_v1_0/components/bsp/control_event.h
typedef struct {
lv_indev_t *indev; // LVGL input device handle (set per callback)
uint32_t btn_id; // Button/switch position index (0-based)
lv_indev_type_t type; // LVGL indev type of the source
control_family_t family_id; // CONTROL_FAMILY_BUTTONS | CONTROL_FAMILY_ENCODER
// | CONTROL_FAMILY_POTENTIOMETER | CONTROL_FAMILY_SWITCH
int32_t steps; // Encoder: ±1 per click; Pot: mapped 0–100 value
uint32_t press_duration; // Buttons: ms held before release; others: 0
} control_event_t;

Static control_event_t instances are declared per callback (not heap-allocated per event), avoiding any dynamic allocation in the polling hot path.

Two additional LVGL event codes are registered at startup by control_events_init() (called at the end of board_init()):

Event CodeSource CallbackMeaning
LV_EVENT_SWITCH_PRESSEDswitch_read_cbSwitch moved to a new valid position
LV_EVENT_SWITCH_RELEASEDswitch_read_cbSwitch leaving a position (transitioning)
LV_EVENT_POTENTIOMETER_CHANGEDpotentiometer_read_cbWiper value changed beyond adaptive threshold

Standard LVGL event codes used for the remaining inputs:

Event CodeSource CallbackMeaning
LV_EVENT_PRESSEDbutton_read_cbButton pressed
LV_EVENT_RELEASEDbutton_read_cbButton released (carries press_duration)
LV_EVENT_KEYencoder_read_cbEncoder rotated: LV_KEY_LEFT or LV_KEY_RIGHT

Each physical input is registered as an LVGL input device (lv_indev_t) inside init_lvgl(), guarded by a compile-time feature flag:

init_lvgl() - conditional indev registration

ESP3D_TOUCH_FEATURE

lv_indev TYPE_POINTER

touch_read_cb (10 ms poll)

ESP3D_HARDWARE_BUTTONS_FEATURE

lv_indev TYPE_BUTTON

button_read_cb (3 virtual points)

ESP3D_HARDWARE_ENCODER_FEATURE

lv_indev TYPE_ENCODER

encoder_read_cb

ESP3D_HARDWARE_SWITCH_FEATURE

lv_indev TYPE_BUTTON

switch_read_cb (4 virtual points)

ESP3D_HARDWARE_POTENTIOMETER_FEATURE

lv_indev TYPE_POINTER

potentiometer_read_cb

⚠️ LVGL thread safety: All *_read_cb functions run on Core 1 within the LVGL timer task. They must never block, must never perform dynamic allocation in the hot path, and must complete quickly. The LVGL task must remain responsive at all times — see screens_architecture.md for global LVGL constraints.

The encoder callback applies speed-adaptive throttling before dispatching LV_EVENT_KEY to prevent LVGL from being overwhelmed during rapid rotation:

Time since last dispatched eventMinimum dispatch interval
≥ ENCODER_SPEED_THRESHOLD_SLOW_MS80 ms
≥ ENCODER_SPEED_THRESHOLD_NORMAL_MS40 ms
≥ ENCODER_SPEED_THRESHOLD_FAST_MS20 ms
< fast threshold10 ms

Additionally, events per polling tick are capped at max_events_per_call = 5. Direction is optionally inverted by the ENCODER_INVERT_ROTATION compile-time flag.

The potentiometer callback uses a hysteresis strategy to distinguish real user input from ADC noise. Raw ADC values (0–4095) are mapped linearly to 0–100 before applying the threshold:

ConditionMapped change threshold
After POT_INACTIVITY_THRESHOLD_MS of silencePOT_WAKE_THRESHOLD_MAPPED (high)
Direction reversal during active use1 unit
Descending value during active use2 units
Ascending value during active use3 units

When a button press wakes the system from display sleep, that specific press-and-release cycle is fully consumed (not forwarded to the active screen):

if (activity_process_event()) {
// System was already awake — dispatch LV_EVENT_PRESSED normally
button_consumed_for_wakeup[i] = false;
} else {
// System just woke up — suppress this press event
button_consumed_for_wakeup[i] = true;
}
// The subsequent LV_EVENT_RELEASED for a consumed press is also suppressed.

Yes

No

Yes

No

Yes

No

Yes

No

Yes

No

board_init()

activity_manager_init()

Display driver init

(ILI9341 SPI + backlight)

ESP3D_TOUCH_FEATURE?

init_touch_controller()

ESP3D_HARDWARE_BUTTONS_FEATURE?

phy_buttons_configure()

ESP3D_HARDWARE_ENCODER_FEATURE?

phy_encoder_configure()

PCNT unit + 10 ms sampling timer

ESP3D_HARDWARE_SWITCH_FEATURE?

phy_switch_configure()

ESP3D_HARDWARE_POTENTIOMETER_FEATURE?

phy_potentiometer_configure()

ADC oneshot handle

init_lvgl()

Create lv_indev per enabled feature

control_events_init()

Register custom LVGL event IDs

Board ready


The physical inputs driver code is shared across all boards via hardware/common/drivers/. Only pibot_pendant_v1_0 currently enables all four physical input types through its feature flags:

BoardButtons (3×)EncoderSwitch (4-pos)Potentiometer
pibot_pendant_v1_0✅✅✅✅
esp32_2432s028r————
esp32_3248s035c————
esp32_3248s035r————
esp32s3_4827s043c————
esp32s3_8048* (touch panels)————
esp32s3_bzm_tft35_gt911————
esp32s3_hmi43v3————
esp32s3_zx3d50ce02s_usrc_4832————
dlc32_max_lcd————
fysetc_wifi_pro————

Boards without physical inputs follow the same board_init pattern; phy_*_configure() calls are absent because the corresponding feature flags are set to OFF in their CMakeLists.txt.


DriverHeap allocationInterrupt usage
phy_buttonsNone — static GPIO state onlyNone
phy_encoderxQueueCreate(10) once at configure timeencoder_pcnt_on_reach runs in ISR; only xQueueSendFromISR used
phy_potentiometerADC oneshot handle once at configure timeNone
phy_switchNone — static GPIO state onlyNone

No dynamic allocation occurs during normal operation (after board_init completes). The encoder ISR exclusively uses xQueueSendFromISR — the only safe FreeRTOS call from ISR context. All BSP read callbacks use only stack-allocated locals and static variables to avoid heap fragmentation. See esp32_memory_constraints.md for the platform-wide allocation policy.


physical_inputs

ESP-IDF

driver/gpio.h

buttons + switch

ESP-IDF

driver/pulse_cnt.h

encoder only

ESP-IDF

esp_adc/adc_oneshot.h

potentiometer only

FreeRTOS Queue

PCNT ISR → read task

esp3d_log

logging macros

BSP board_init

boards/pibot_pendant_v1_0

LVGL lv_indev API

activity_manager

components/esp3d_activity_manager

  • ESP-IDF GPIO driver — used by phy_buttons and phy_switch for digital I/O.
  • ESP-IDF PCNT driver — used exclusively by phy_encoder for hardware quadrature decoding.
  • ESP-IDF ADC oneshot — used exclusively by phy_potentiometer.
  • FreeRTOS Queue — bridges the PCNT ISR to phy_encoder_read() in task context.
  • esp3d_log — see esp3d_log_guide.md for macro usage and log levels.
  • activity_manager — called by BSP callbacks (not by drivers) to maintain display activity and detect wake-up cycles.
  • LVGL — input devices are registered by the BSP layer, not by the drivers themselves. See Input_system.md and screens_architecture.md for how screens consume control_event_t payloads.

DocumentTopic
Input_system.mdHow LVGL input devices are consumed by UI screens
screens_architecture.mdScreen event-handling patterns and LVGL thread rules
screen_transitions_flow.mdHow physical inputs trigger screen transitions
display_drivers.mdSPI/RGB/i80 display drivers co-initialized in board_init
pibot-cnc-pendant-hardware-documentation.mdGPIO pin assignments, schematics, and board layout
esp32_memory_constraints.mdESP32 heap constraints and allocation rules
esp3d_log_guide.mdLogging macro usage, levels, and debug workflow