Physical Inputs Module
Section titled “Physical Inputs Module”Introduction
Section titled “Introduction”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 ← implementationArchitecture Overview
Section titled “Architecture Overview”Component Descriptions
Section titled “Component Descriptions”1. phy_buttons — Physical Push-Buttons
Section titled “1. phy_buttons — Physical Push-Buttons”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.
Configuration Structure
Section titled “Configuration Structure”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;| Field | Type | Description |
|---|---|---|
button_pins[3] | gpio_num_t | GPIO numbers, one per button (index 0–2) |
pullups_enabled | bool | Enables ESP-IDF internal pull-ups |
debounce_ms | uint32_t | Contact bounce filter, typically 20–50 ms |
active_low | bool | true for buttons wired to GND; false for VCC side |
| Function | Description |
|---|---|
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.
Configuration Structure
Section titled “Configuration Structure”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;| Field | Description |
|---|---|
pin_a / pin_b | Phase A and B GPIO connections (quadrature signal pair) |
steps_per_rev | Used by higher layers to convert raw clicks → revolutions |
pcnt_high_limit / pcnt_low_limit | Hardware counter limits; at a wrap the driver accumulator (accum_count) adds the limit, so the reported position stays absolute |
pcnt_glitch_ns | Hardware debounce; default 500 ns balances noise rejection vs. sensitivity |
Sampling Model (a14)
Section titled “Sampling Model (a14)”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, sopcnt_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 soaccum_countsees 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.
| Function | Description |
|---|---|
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”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 readread_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.
Configuration Structure
Section titled “Configuration Structure”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;| Field | Description |
|---|---|
channel | Mapped from pin using board-specific ADC channel assignments |
width | ADC_BITWIDTH_12 → raw range 0–4095 |
atten | ADC_ATTEN_DB_11 → full-scale ≈ 3.3 V (most common) |
filter_enabled / filter_samples | Multi-sample averaging; reduces ADC noise at cost of latency |
| Function | Description |
|---|---|
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.
Configuration Structure
Section titled “Configuration Structure”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;| Field | Description |
|---|---|
pins[3] | Each pin encodes one bit of the 4-position state |
pullups_enabled | Enables GPIO pull-ups (wiper pulls a pin to GND at each position) |
debounce_ms | Prevents spurious transitions during mechanical contact switching |
| Function | Description |
|---|---|
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_cbtreatsESP_ERR_INVALID_RESPONSEas a silent no-op and does not callactivity_process_event()for invalid states. This prevents phantom display wake-ups during mechanical contact bouncing between positions.
Data Flow
Section titled “Data Flow”Control Event System
Section titled “Control Event System”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.htypedef 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.
Custom LVGL Event Codes
Section titled “Custom LVGL Event Codes”Two additional LVGL event codes are registered at startup by control_events_init() (called at the end of board_init()):
| Event Code | Source Callback | Meaning |
|---|---|---|
LV_EVENT_SWITCH_PRESSED | switch_read_cb | Switch moved to a new valid position |
LV_EVENT_SWITCH_RELEASED | switch_read_cb | Switch leaving a position (transitioning) |
LV_EVENT_POTENTIOMETER_CHANGED | potentiometer_read_cb | Wiper value changed beyond adaptive threshold |
Standard LVGL event codes used for the remaining inputs:
| Event Code | Source Callback | Meaning |
|---|---|---|
LV_EVENT_PRESSED | button_read_cb | Button pressed |
LV_EVENT_RELEASED | button_read_cb | Button released (carries press_duration) |
LV_EVENT_KEY | encoder_read_cb | Encoder rotated: LV_KEY_LEFT or LV_KEY_RIGHT |
LVGL Integration
Section titled “LVGL Integration”Each physical input is registered as an LVGL input device (lv_indev_t) inside init_lvgl(), guarded by a compile-time feature flag:
⚠️ LVGL thread safety: All
*_read_cbfunctions 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.
Encoder: Adaptive Rate Limiting
Section titled “Encoder: Adaptive Rate Limiting”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 event | Minimum dispatch interval |
|---|---|
≥ ENCODER_SPEED_THRESHOLD_SLOW_MS | 80 ms |
≥ ENCODER_SPEED_THRESHOLD_NORMAL_MS | 40 ms |
≥ ENCODER_SPEED_THRESHOLD_FAST_MS | 20 ms |
| < fast threshold | 10 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.
Potentiometer: Adaptive Threshold
Section titled “Potentiometer: Adaptive Threshold”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:
| Condition | Mapped change threshold |
|---|---|
After POT_INACTIVITY_THRESHOLD_MS of silence | POT_WAKE_THRESHOLD_MAPPED (high) |
| Direction reversal during active use | 1 unit |
| Descending value during active use | 2 units |
| Ascending value during active use | 3 units |
Button: Wake-Up Consumption
Section titled “Button: Wake-Up Consumption”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.Initialization Sequence
Section titled “Initialization Sequence”Board Support Matrix
Section titled “Board Support Matrix”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:
| Board | Buttons (3×) | Encoder | Switch (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.
Memory and Real-Time Constraints
Section titled “Memory and Real-Time Constraints”| Driver | Heap allocation | Interrupt usage |
|---|---|---|
phy_buttons | None — static GPIO state only | None |
phy_encoder | xQueueCreate(10) once at configure time | encoder_pcnt_on_reach runs in ISR; only xQueueSendFromISR used |
phy_potentiometer | ADC oneshot handle once at configure time | None |
phy_switch | None — static GPIO state only | None |
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.
Dependency Map
Section titled “Dependency Map”- ESP-IDF GPIO driver — used by
phy_buttonsandphy_switchfor digital I/O. - ESP-IDF PCNT driver — used exclusively by
phy_encoderfor 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_tpayloads.
Related Documentation
Section titled “Related Documentation”| Document | Topic |
|---|---|
| Input_system.md | How LVGL input devices are consumed by UI screens |
| screens_architecture.md | Screen event-handling patterns and LVGL thread rules |
| screen_transitions_flow.md | How physical inputs trigger screen transitions |
| display_drivers.md | SPI/RGB/i80 display drivers co-initialized in board_init |
| pibot-cnc-pendant-hardware-documentation.md | GPIO pin assignments, schematics, and board layout |
| esp32_memory_constraints.md | ESP32 heap constraints and allocation rules |
| esp3d_log_guide.md | Logging macro usage, levels, and debug workflow |