Files
pyxis/lib/tdeck_ui/Hardware/TDeck/Trackball.cpp
torlando-tech ac6ceca9f8 Initial commit: standalone Pyxis T-Deck firmware
Split T-Deck firmware from microReticulum examples/lxmf_tdeck/ into its
own repo. microReticulum is consumed as a git submodule dependency pinned
to feat/t-deck. All include paths updated from relative symlinks to bare
includes resolved via library build flags.

Both tdeck (NimBLE) and tdeck-bluedroid environments compile successfully.
Licensed under AGPLv3.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-06 19:48:33 -05:00

349 lines
10 KiB
C++

// Copyright (c) 2024 microReticulum contributors
// SPDX-License-Identifier: MIT
#include "Trackball.h"
#ifdef ARDUINO
#include "Log.h"
#include <driver/gpio.h>
using namespace RNS;
namespace Hardware {
namespace TDeck {
// Static member initialization
lv_indev_t* Trackball::_indev = nullptr;
volatile int16_t Trackball::_pulse_up = 0;
volatile int16_t Trackball::_pulse_down = 0;
volatile int16_t Trackball::_pulse_left = 0;
volatile int16_t Trackball::_pulse_right = 0;
volatile uint32_t Trackball::_last_pulse_time = 0;
bool Trackball::_button_pressed = false;
uint32_t Trackball::_last_button_time = 0;
Trackball::State Trackball::_state;
bool Trackball::_initialized = false;
bool Trackball::init() {
if (_initialized) {
return true;
}
INFO("Initializing T-Deck trackball");
// Initialize hardware first
if (!init_hardware_only()) {
return false;
}
// Register LVGL input device as KEYPAD for focus navigation
static lv_indev_drv_t indev_drv;
lv_indev_drv_init(&indev_drv);
indev_drv.type = LV_INDEV_TYPE_KEYPAD; // KEYPAD for 2D focus navigation
indev_drv.read_cb = lvgl_read_cb;
_indev = lv_indev_drv_register(&indev_drv);
if (!_indev) {
ERROR("Failed to register trackball with LVGL");
return false;
}
INFO("Trackball initialized successfully");
return true;
}
bool Trackball::init_hardware_only() {
if (_initialized) {
return true;
}
INFO("Initializing trackball hardware");
// Use ESP-IDF gpio driver for reliable interrupt handling on strapping pins
gpio_config_t io_conf = {};
io_conf.intr_type = GPIO_INTR_NEGEDGE; // Falling edge trigger
io_conf.mode = GPIO_MODE_INPUT;
io_conf.pull_up_en = GPIO_PULLUP_ENABLE;
io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
// Configure all trackball directional pins
io_conf.pin_bit_mask = (1ULL << Pin::TRACKBALL_UP) |
(1ULL << Pin::TRACKBALL_DOWN) |
(1ULL << Pin::TRACKBALL_LEFT) |
(1ULL << Pin::TRACKBALL_RIGHT);
gpio_config(&io_conf);
// Configure button separately (just input with pullup, no interrupt)
gpio_config_t btn_conf = {};
btn_conf.intr_type = GPIO_INTR_DISABLE;
btn_conf.mode = GPIO_MODE_INPUT;
btn_conf.pull_up_en = GPIO_PULLUP_ENABLE;
btn_conf.pull_down_en = GPIO_PULLDOWN_DISABLE;
btn_conf.pin_bit_mask = (1ULL << Pin::TRACKBALL_BUTTON);
gpio_config(&btn_conf);
// Install GPIO ISR service
gpio_install_isr_service(0);
// Attach ISR handlers
gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_UP, isr_up, nullptr);
gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_DOWN, isr_down, nullptr);
gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_LEFT, isr_left, nullptr);
gpio_isr_handler_add((gpio_num_t)Pin::TRACKBALL_RIGHT, isr_right, nullptr);
// Initialize state
_state.delta_x = 0;
_state.delta_y = 0;
_state.button_pressed = false;
_state.timestamp = millis();
_initialized = true;
INFO(" Trackball hardware ready");
return true;
}
bool Trackball::poll() {
if (!_initialized) {
return false;
}
bool state_changed = false;
uint32_t now = millis();
// Read pulse counters (critical section to avoid race with ISRs)
noInterrupts();
int16_t up = _pulse_up;
int16_t down = _pulse_down;
int16_t left = _pulse_left;
int16_t right = _pulse_right;
uint32_t last_pulse = _last_pulse_time;
interrupts();
// Calculate net movement
int16_t delta_y = down - up; // Positive = down, negative = up
int16_t delta_x = right - left; // Positive = right, negative = left
// Apply sensitivity multiplier
delta_x *= Trk::PIXELS_PER_PULSE;
delta_y *= Trk::PIXELS_PER_PULSE;
// Update state if movement detected
if (delta_x != 0 || delta_y != 0) {
_state.delta_x = delta_x;
_state.delta_y = delta_y;
_state.timestamp = now;
state_changed = true;
// Reset pulse counters after reading
noInterrupts();
_pulse_up = 0;
_pulse_down = 0;
_pulse_left = 0;
_pulse_right = 0;
interrupts();
} else {
// Reset deltas if no recent pulses (timeout)
if (now - last_pulse > Trk::PULSE_RESET_MS) {
if (_state.delta_x != 0 || _state.delta_y != 0) {
_state.delta_x = 0;
_state.delta_y = 0;
state_changed = true;
}
}
}
// Read button state with debouncing
bool button = read_button_debounced();
if (button != _state.button_pressed) {
_state.button_pressed = button;
state_changed = true;
}
return state_changed;
}
void Trackball::get_state(State& state) {
state = _state;
}
void Trackball::reset_deltas() {
_state.delta_x = 0;
_state.delta_y = 0;
}
bool Trackball::is_button_pressed() {
return _state.button_pressed;
}
lv_indev_t* Trackball::get_indev() {
return _indev;
}
void Trackball::lvgl_read_cb(lv_indev_drv_t* drv, lv_indev_data_t* data) {
// Static accumulators for threshold-based navigation
static int16_t accum_x = 0;
static int16_t accum_y = 0;
static uint32_t last_key_time = 0;
// Key press/release state machine
static uint32_t pending_key = 0; // Key waiting to be pressed
static uint32_t pressed_key = 0; // Key currently pressed (needs release)
static bool button_was_pressed = false; // Track physical button state for release
// Poll for new trackball data
poll();
// Get current state
State state;
get_state(state);
// Accumulate movement (convert back from pixels to pulses)
accum_x += state.delta_x / Trk::PIXELS_PER_PULSE;
accum_y += state.delta_y / Trk::PIXELS_PER_PULSE;
// Button handling - trigger on release only to avoid double-activation
// When screen changes on press, release would hit new screen's focused element
if (state.button_pressed) {
button_was_pressed = true;
// Don't send anything yet, wait for release
} else if (button_was_pressed) {
// Button just released - queue ENTER key for press/release cycle
button_was_pressed = false;
pending_key = LV_KEY_ENTER;
// Fall through to let pending_key logic handle it
}
// Check if we need to release a previously pressed navigation key
if (pressed_key != 0) {
data->key = pressed_key;
data->state = LV_INDEV_STATE_RELEASED;
pressed_key = 0;
reset_deltas();
return;
}
// Check if we have a pending key to press
if (pending_key != 0) {
data->key = pending_key;
data->state = LV_INDEV_STATE_PRESSED;
pressed_key = pending_key; // Mark for release on next callback
pending_key = 0;
reset_deltas();
return;
}
uint32_t now = millis();
// Check thresholds - use NEXT/PREV for group focus navigation
// LVGL groups only support linear navigation with NEXT/PREV
if (abs(accum_y) >= Trk::NAV_THRESHOLD && abs(accum_y) >= abs(accum_x)) {
if (now - last_key_time >= Trk::KEY_REPEAT_MS) {
pending_key = (accum_y > 0) ? LV_KEY_NEXT : LV_KEY_PREV;
last_key_time = now;
}
accum_y = 0;
} else if (abs(accum_x) >= Trk::NAV_THRESHOLD) {
if (now - last_key_time >= Trk::KEY_REPEAT_MS) {
pending_key = (accum_x > 0) ? LV_KEY_NEXT : LV_KEY_PREV;
last_key_time = now;
}
accum_x = 0;
}
// If we have a pending key, check if anything visible is focused
// If nothing is focused or focused object is hidden, find a visible object
if (pending_key != 0) {
lv_group_t* group = lv_group_get_default();
if (group) {
lv_obj_t* focused = lv_group_get_focused(group);
bool need_refocus = !focused;
// Check if focused object or any parent is hidden
if (focused && !need_refocus) {
lv_obj_t* obj = focused;
while (obj) {
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) {
need_refocus = true;
break;
}
obj = lv_obj_get_parent(obj);
}
}
if (need_refocus) {
// Find first visible object in group
uint32_t obj_cnt = lv_group_get_obj_count(group);
for (uint32_t i = 0; i < obj_cnt; i++) {
lv_group_focus_next(group);
lv_obj_t* candidate = lv_group_get_focused(group);
if (candidate) {
bool visible = true;
lv_obj_t* obj = candidate;
while (obj) {
if (lv_obj_has_flag(obj, LV_OBJ_FLAG_HIDDEN)) {
visible = false;
break;
}
obj = lv_obj_get_parent(obj);
}
if (visible) break; // Found a visible object
}
}
pending_key = 0; // Don't send the key, just focus
accum_x = 0;
accum_y = 0;
}
}
}
// Default: no key activity
data->key = 0;
data->state = LV_INDEV_STATE_RELEASED;
reset_deltas();
}
bool Trackball::read_button_debounced() {
bool current = (digitalRead(Pin::TRACKBALL_BUTTON) == LOW); // Active low
uint32_t now = millis();
// Debounce: only accept change if stable for debounce period
if (current != _button_pressed) {
if (now - _last_button_time > Trk::DEBOUNCE_MS) {
_last_button_time = now;
return current;
}
} else {
_last_button_time = now;
}
return _button_pressed;
}
// ISR handlers - MUST be in IRAM for ESP32
// ESP-IDF gpio_isr_handler signature requires void* arg
void IRAM_ATTR Trackball::isr_up(void* arg) {
_pulse_up++;
_last_pulse_time = millis();
}
void IRAM_ATTR Trackball::isr_down(void* arg) {
_pulse_down++;
_last_pulse_time = millis();
}
void IRAM_ATTR Trackball::isr_left(void* arg) {
_pulse_left++;
_last_pulse_time = millis();
}
void IRAM_ATTR Trackball::isr_right(void* arg) {
_pulse_right++;
_last_pulse_time = millis();
}
} // namespace TDeck
} // namespace Hardware
#endif // ARDUINO