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]

Download RAW File