1
/*
* =============================================================================
* ESP32-S3 HID Gamepad - Final Optimized Version for a Single EC-11
* =============================================================================
*
* main.c
*
* Author: Gemini
* Date: July 6, 2025
*
* Description:
* This is the definitive, optimized implementation for a single EC-11 encoder.
* It solves the "double-counting" and "twitching" issues by using a
* professional-grade, event-driven input handling strategy.
*
* --- Reliability Strategy ---
* 1. **Smarter Interrupts:** The ISR triggers only on the falling edge of
* Pin A, ensuring one physical "click" generates exactly one interrupt.
* 2. **Event Queue Logic:** The main task treats the encoder counter as a
* queue. It processes AT MOST one event per cycle, ensuring fast turns
* are buffered and handled with perfect timing.
* 3. **Guaranteed Press Cycle:** Each processed event generates a perfectly
* timed "press -> hold -> release" USB report, which is easy for any
* game engine to interpret correctly.
*
*/
#include <stdio.h>
#include <string.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "driver/gpio.h"
#include "esp_log.h"
#include "tinyusb.h"
#include "class/hid/hid_device.h"
#include "freertos/portmacro.h"
static const char *TAG = "EC11_HID_FINAL";
//==============================================================================
// 1. HARDWARE CONFIGURATION
//==============================================================================
#define ENCODER_A_PIN GPIO_NUM_5 // Interrupt is triggered by this pin
#define ENCODER_B_PIN GPIO_NUM_4 // This pin is only read to determine direction
#define ENCODER_SW_PIN GPIO_NUM_3 // Encoder Switch Pin
//==============================================================================
// 2. ROTARY ENCODER LOGIC
//==============================================================================
static volatile int32_t encoder_count = 0;
static portMUX_TYPE encoder_spinlock = portMUX_INITIALIZER_UNLOCKED;
/**
* @brief Interrupt Service Routine for encoder rotation.
*
* This ISR triggers ONLY on the falling edge of Pin A. This ensures one
* physical click results in one interrupt. It then reads Pin B to determine
* the direction of rotation.
*/
static void IRAM_ATTR encoder_isr_handler(void *arg) {
taskENTER_CRITICAL_ISR(&encoder_spinlock);
// If Pin B is high when Pin A falls, we are turning clockwise.
// If Pin B is low when Pin A falls, we are turning counter-clockwise.
if (gpio_get_level(ENCODER_B_PIN)) {
encoder_count++;
} else {
encoder_count--;
}
taskEXIT_CRITICAL_ISR(&encoder_spinlock);
}
//==============================================================================
// 3. USB HID DESCRIPTORS
//==============================================================================
enum {
REPORT_ID_GAMEPAD = 1
};
#define TUD_HID_REPORT_DESC_GAMEPAD_MANUAL(...) \
HID_USAGE_PAGE ( HID_USAGE_PAGE_DESKTOP ) , \
HID_USAGE ( HID_USAGE_DESKTOP_GAMEPAD ) , \
HID_COLLECTION ( HID_COLLECTION_APPLICATION ) , \
HID_REPORT_ID(REPORT_ID_GAMEPAD) \
HID_USAGE_PAGE ( HID_USAGE_PAGE_BUTTON ) , \
HID_USAGE_MIN ( 1 ) , \
HID_USAGE_MAX ( 16 ) , \
HID_LOGICAL_MIN ( 0 ) , \
HID_LOGICAL_MAX ( 1 ) , \
HID_REPORT_COUNT ( 16 ) , \
HID_REPORT_SIZE ( 1 ) , \
HID_INPUT ( HID_DATA | HID_VARIABLE | HID_ABSOLUTE ) , \
HID_USAGE_PAGE ( HID_USAGE_PAGE_DESKTOP ) , \
HID_USAGE ( HID_USAGE_DESKTOP_X ) , \
HID_USAGE ( HID_USAGE_DESKTOP_Y ) , \
HID_USAGE ( HID_USAGE_DESKTOP_Z ) , \
HID_LOGICAL_MIN ( 0x00 ) , \
HID_LOGICAL_MAX ( 0xFF ) , \
HID_REPORT_SIZE ( 8 ) , \
HID_REPORT_COUNT ( 3 ) , \
HID_INPUT ( HID_DATA | HID_VARIABLE | HID_ABSOLUTE ) , \
HID_COLLECTION_END
const uint8_t tud_hid_report_desc[] = {
TUD_HID_REPORT_DESC_GAMEPAD_MANUAL()
};
const tusb_desc_device_t device_descriptor = {
.bLength = sizeof(device_descriptor),
.bDescriptorType = TUSB_DESC_DEVICE,
.bcdUSB = 0x0200,
.bDeviceClass = 0x00,
.bDeviceSubClass = 0x00,
.bDeviceProtocol = 0x00,
.bMaxPacketSize0 = CFG_TUD_ENDPOINT0_SIZE,
.idVendor = 0x303A, // Espressif VID
.idProduct = 0x4004, // New PID for this final version
.bcdDevice = 0x0200, // Device version 2.0
.iManufacturer = 1,
.iProduct = 2,
.iSerialNumber = 3,
.bNumConfigurations = 1
};
const char* string_desc_arr[] = {
(const char[]){0x09, 0x04}, // 0: Language (English)
"Im exhausted", // 1: Manufacturer
"EC-11 Controller Pro", // 2: Product
"CT-2025-PRO", // 3: Serial
};
#define CONFIG_TOTAL_LEN (TUD_CONFIG_DESC_LEN + TUD_HID_DESC_LEN)
const uint8_t configuration_descriptor[] = {
TUD_CONFIG_DESCRIPTOR(1, 1, 0, CONFIG_TOTAL_LEN, TUSB_DESC_CONFIG_ATT_REMOTE_WAKEUP, 100),
TUD_HID_DESCRIPTOR(0, 0, HID_ITF_PROTOCOL_NONE, sizeof(tud_hid_report_desc), 0x81, CFG_TUD_HID_EP_BUFSIZE, 10)
};
typedef struct TU_ATTR_PACKED {
uint16_t buttons;
uint8_t x;
uint8_t y;
uint8_t z;
} hid_report_t;
//==============================================================================
// 4. MAIN HID TASK
//==============================================================================
void hid_task(void *pvParameters) {
const uint32_t button_hold_ms = 40; // Hold virtual buttons for 40ms
const uint32_t loop_delay_ms = 10; // Loop polls every 10ms
static hid_report_t report;
memset(&report, 0, sizeof(report));
ESP_LOGI(TAG, "HID Task Started");
while (1) {
if (!tud_hid_ready()) {
vTaskDelay(pdMS_TO_TICKS(loop_delay_ms));
continue;
}
// --- Read Encoder Rotation Count ---
int32_t count_to_process;
taskENTER_CRITICAL(&encoder_spinlock);
count_to_process = encoder_count;
taskEXIT_CRITICAL(&encoder_spinlock);
// --- Read Physical Button State ---
if (gpio_get_level(ENCODER_SW_PIN) == 0) {
report.buttons = (1 << 2); // Button 3 is pressed
} else {
report.buttons = 0; // Button 3 is released
}
// Set static axes
report.x = 128;
report.y = 128;
report.z = 128;
// --- Process ONE Encoder Click from the "Queue" (if any) ---
if (count_to_process != 0) {
uint16_t virtual_button_to_press = 0;
// Atomically decrement/increment the shared counter
taskENTER_CRITICAL(&encoder_spinlock);
if (encoder_count > 0) {
virtual_button_to_press = (1 << 0); // Button 1 for CW
encoder_count--;
ESP_LOGI(TAG, "Processing CW Pulse -> Button 1");
} else if (encoder_count < 0) {
virtual_button_to_press = (1 << 1); // Button 2 for CCW
encoder_count++;
ESP_LOGI(TAG, "Processing CCW Pulse -> Button 2");
}
taskEXIT_CRITICAL(&encoder_spinlock);
// Perform the guaranteed "press -> hold -> release" cycle
report.buttons |= virtual_button_to_press;
tud_hid_report(REPORT_ID_GAMEPAD, &report, sizeof(report));
vTaskDelay(pdMS_TO_TICKS(button_hold_ms));
report.buttons &= ~virtual_button_to_press;
tud_hid_report(REPORT_ID_GAMEPAD, &report, sizeof(report));
} else {
// If no rotation, just send the current physical button state
tud_hid_report(REPORT_ID_GAMEPAD, &report, sizeof(report));
}
// Wait before the next loop iteration
vTaskDelay(pdMS_TO_TICKS(loop_delay_ms));
}
}
//==============================================================================
// 5. APPLICATION ENTRY POINT
//==============================================================================
void app_main(void) {
ESP_LOGI(TAG, "Initializing GPIO...");
// Configure Pin A to trigger the interrupt on falling edge
gpio_config_t rot_a_conf = {
.pin_bit_mask = (1ULL << ENCODER_A_PIN),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
.intr_type = GPIO_INTR_NEGEDGE, // Trigger only on falling edge
};
gpio_config(&rot_a_conf);
// Configure Pin B as a simple input, no interrupt
gpio_config_t rot_b_conf = {
.pin_bit_mask = (1ULL << ENCODER_B_PIN),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&rot_b_conf);
// Configure the switch pin as a simple input
gpio_config_t sw_conf = {
.pin_bit_mask = (1ULL << ENCODER_SW_PIN),
.mode = GPIO_MODE_INPUT,
.pull_up_en = GPIO_PULLUP_ENABLE,
};
gpio_config(&sw_conf);
// Install the ISR service and add a handler ONLY for Pin A
gpio_install_isr_service(0);
gpio_isr_handler_add(ENCODER_A_PIN, encoder_isr_handler, NULL);
ESP_LOGI(TAG, "Initializing TinyUSB...");
const tinyusb_config_t tusb_cfg = {
.device_descriptor = &device_descriptor,
.string_descriptor = string_desc_arr,
.string_descriptor_count = sizeof(string_desc_arr) / sizeof(string_desc_arr[0]),
.external_phy = false,
.configuration_descriptor = configuration_descriptor,
};
ESP_ERROR_CHECK(tinyusb_driver_install(&tusb_cfg));
ESP_LOGI(TAG, "TinyUSB driver installed.");
xTaskCreate(hid_task, "hid_task", 2048, NULL, 5, NULL);
ESP_LOGI(TAG, "Initialization complete.");
}
//==============================================================================
// 6. TINYUSB CALLBACKS (Required by stack)
//==============================================================================
const uint8_t *tud_hid_descriptor_report_cb(uint8_t instance) {
(void)instance;
return tud_hid_report_desc;
}
uint16_t tud_hid_get_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t *buffer, uint16_t reqlen) {
(void)instance; (void)report_id; (void)report_type; (void)buffer; (void)reqlen;
return 0;
}
void tud_hid_set_report_cb(uint8_t instance, uint8_t report_id, hid_report_type_t report_type, uint8_t const *buffer, uint16_t bufsize) {
(void)instance; (void)report_id; (void)report_type; (void)buffer; (void)bufsize;
}
For immediate assistance, please email our customer support: [email protected]