Building a reliable ESP32 BLE keyboard requires more than just copying a sketch. You need the ESP32-BLE-Keyboard library, an ESP32-WROOM-32 module, and strict GPIO selection to avoid strapping pin conflicts that brick your boot sequence. In this guide, we will build a 4-key wireless macro pad, write non-blocking debounced firmware, and troubleshoot the exact FreeRTOS watchdog panics that plague most first-time BLE builds.

Project Spec Sheet & Difficulty Rating

Parameter Specification
Difficulty Intermediate (Requires basic C++ and FreeRTOS timing concepts)
Build Time 45 minutes (hardware) + 15 minutes (software)
Estimated Cost $12 - $18 USD (depending on switch and enclosure choices)
Target Board DOIT ESP32 DevKit V1 (38-pin, ESP32-WROOM-32E)
Core Version ESP32 Arduino Core v2.0.14 or v3.x (NimBLE compatible)

Hardware BOM and Pin Mapping

The most common mistake in ESP32 keyboard builds is wiring switches to boot-strapping pins. If you pull GPIO 12 high during boot, the ESP32 will fail to start. If you pull GPIO 0 or GPIO 2 low, it will enter UART download mode instead of running your code. We strictly use safe input pins for this build.

Bill of Materials

  • MCU: DOIT ESP32 DevKit V1 (38-pin variant)
  • Switches: 4x Cherry MX Blue (or any 2-pin tactile switch)
  • Resistors: 4x 10kΩ (for pull-down configuration)
  • Capacitor: 1x 100µF electrolytic (crucial for BLE TX power spikes)
  • Wiring: 22 AWG solid core jumper wires

GPIO Pin Mapping Table

Component ESP32 GPIO Wiring Notes
Switch 1 (Macro A) GPIO 25 Switch to 3V3, 10kΩ pulldown to GND
Switch 2 (Macro B) GPIO 26 Switch to 3V3, 10kΩ pulldown to GND
Switch 3 (Macro C) GPIO 27 Switch to 3V3, 10kΩ pulldown to GND
Switch 4 (Macro D) GPIO 14 Switch to 3V3, 10kΩ pulldown to GND
Power Decoupling 3V3 & GND 100µF Cap across 3V3 and GND rails
Bench Tip: Always place the 100µF capacitor as close to the ESP32 3V3 and GND pins as physically possible. The BLE radio draws up to 130mA in short bursts during transmission. Without local energy storage, the voltage rail will sag below 3.0V, triggering the ESP32's internal Brownout Detector (BOD) and resetting the board.

Complete Compilable BLE Keyboard Firmware

This firmware targets the DOIT ESP32 DevKit V1 (38-pin). It uses the widely maintained ESP32-BLE-Keyboard library by T-vK. Install it via the Arduino Library Manager by searching for 'ESP32 BLE Keyboard'.

Unlike beginner tutorials that use delay() for debouncing, this code uses a non-blocking millis() state machine. Blocking the main loop with delay() starves the FreeRTOS background tasks that manage the BLE stack, leading to connection drops and watchdog panics.

#include <BleKeyboard.h>

// --- PIN DEFINITIONS ---
const int PIN_BTN1 = 25;
const int PIN_BTN2 = 26;
const int PIN_BTN3 = 27;
const int PIN_BTN4 = 14;

// --- DEBOUNCE CONFIG ---
const unsigned long DEBOUNCE_TIME = 50; // milliseconds

struct ButtonState {
  int pin;
  bool lastReading;
  bool currentState;
  unsigned long lastDebounceTime;
};

ButtonState buttons[4];

// Initialize BLE Keyboard with Device Name, Manufacturer, and Initial Battery Level
BleKeyboard bleKeyboard("FluxMacroPad", "ElectricalFlux", 100);

void setup() {
  Serial.begin(115200);
  Serial.println("Initializing ESP32 BLE Keyboard...");

  // Configure pins and initial states
  int pins[] = {PIN_BTN1, PIN_BTN2, PIN_BTN3, PIN_BTN4};
  for (int i = 0; i < 4; i++) {
    pinMode(pins[i], INPUT); // Using external 10k pulldowns
    buttons[i].pin = pins[i];
    buttons[i].lastReading = LOW;
    buttons[i].currentState = LOW;
    buttons[i].lastDebounceTime = 0;
  }

  bleKeyboard.begin();
  Serial.println("BLE Stack started. Waiting for host pairing...");
}

void loop() {
  // Non-blocking button polling
  for (int i = 0; i < 4; i++) {
    bool reading = digitalRead(buttons[i].pin);

    if (reading != buttons[i].lastReading) {
      buttons[i].lastDebounceTime = millis();
    }

    if ((millis() - buttons[i].lastDebounceTime) > DEBOUNCE_TIME) {
      if (reading != buttons[i].currentState) {
        buttons[i].currentState = reading;
        
        // Only send HID reports if BLE is actively connected to a host
        if (bleKeyboard.isConnected()) {
          if (buttons[i].currentState == HIGH) {
            triggerMacro(i);
          }
        }
      }
    }
    buttons[i].lastReading = reading;
  }
  
  // Yield to FreeRTOS BLE background tasks
  vTaskDelay(10 / portTICK_PERIOD_MS);
}

void triggerMacro(int btnIndex) {
  switch (btnIndex) {
    case 0: // Macro A: Ctrl+C (Copy)
      bleKeyboard.press(KEY_LEFT_CTRL);
      bleKeyboard.press('c');
      bleKeyboard.releaseAll();
      break;
    case 1: // Macro B: Ctrl+V (Paste)
      bleKeyboard.press(KEY_LEFT_CTRL);
      bleKeyboard.press('v');
      bleKeyboard.releaseAll();
      break;
    case 2: // Macro C: Media Play/Pause
      bleKeyboard.write(KEY_MEDIA_PLAY_PAUSE);
      break;
    case 3: // Macro D: Custom String
      bleKeyboard.print("electricalflux.com");
      break;
  }
}

Debugging: First 3 Checks & Common Error Strings

When your ESP32 BLE keyboard fails to pair or crashes during operation, do not immediately rewrite your code. Run through these three hardware and environment checks first.

The First 3 Things to Check

  1. Power Delivery & Brownouts: Open the Serial Monitor. If you see rst:0xc (SW_CPU_RESET) or brownout detector was triggered exactly when you press a button or when the device attempts to pair, your USB cable or hub cannot supply the 130mA TX spike. Swap to a high-quality 20AWG data cable and ensure the 100µF capacitor is installed.
  2. ESP32 Core Version Mismatch: The ESP32 Arduino Core transitioned from the Bluedroid BLE stack to NimBLE in version 3.x. If you are using Core v3.x, the original T-vK library may fail to compile. You must either downgrade to Core v2.0.14 via the Boards Manager, or switch to a NimBLE-compatible fork like ESP32-BLE-Combo.
  3. Strapping Pin Conflicts: If the board boots into a continuous reset loop or fails to execute setup(), verify you haven't accidentally wired a switch to GPIO 0, 2, 12, or 15, and that external pull-ups/pull-downs aren't fighting the internal boot strapping resistors.

Exact Error String: The WDT Panic

If your Serial Monitor outputs the following exact error string, your code is blocking the FreeRTOS scheduler:

Guru Meditation Error: Core 1 panic'ed (Interrupt wdt timeout on CPU1)
Core 1 register dump:
PC : 0x4008b6b1 PS : 0x00060034 A0 : 0x8008a833 A1 : 0x3ffbfe00

Ranked Causes & Fixes:

  1. Using delay() in the main loop (Most Likely): The BLE stack runs as a high-priority FreeRTOS task. If your loop() uses delay(100) for debouncing, the idle task never runs to feed the hardware watchdog. Fix: Use the millis() non-blocking logic provided in the firmware above.
  2. Heavy I2C/SPI Polling: If you added an OLED display and are updating it every 5ms using blocking Wire commands, you are starving the BLE stack. Fix: Move display updates to a separate FreeRTOS task pinned to Core 0, or throttle updates to 30fps.
  3. Missing vTaskDelay(): A tight while(1) loop without yielding will trigger the Interrupt WDT. Fix: Always include vTaskDelay(10 / portTICK_PERIOD_MS); at the end of your loop.

Extending and Simplifying the Build

To Simplify: If you only need a single-button presentation clicker, strip the buttons[] array down to a single struct instance. You can also eliminate the external 10kΩ pulldown resistors by changing pinMode(pin, INPUT) to pinMode(pin, INPUT_PULLDOWN), utilizing the ESP32's internal 45kΩ pulldown resistors. This works fine for short wire runs under 6 inches.

To Extend (Switch Matrix): For a full 60% keyboard layout, direct GPIO wiring is impossible. You must implement a diode-isolated switch matrix (e.g., 8 columns x 8 rows). Use the Keypad library to scan the matrix, but ensure the scanning frequency does not exceed 1kHz to prevent GPIO toggle noise from desensitizing the ESP32's 2.4GHz BLE antenna trace.

ESP32 BLE Keyboard FAQ

Why does my ESP32 BLE keyboard disconnect when I press a button?

This is almost always a power brownout. When the ESP32 transmits a BLE HID report, the radio draws a sudden spike of 120mA-130mA. If your power source (like a cheap PC USB hub or a long, thin micro-USB cable) has high internal resistance, the voltage at the ESP32's 3V3 rail will momentarily drop below the 2.4V brownout threshold. The chip resets, dropping the BLE connection. Soldering a 100µF to 470µF electrolytic capacitor directly across the 3V3 and GND pins on the DevKit provides the instantaneous current needed to ride through the TX spike.

Can I use an ESP32-C3 or ESP32-S3 for a BLE keyboard?

Yes, but the software stack changes. The ESP32-C3 (RISC-V) and ESP32-S3 (Xtensa LX7) use the NimBLE stack natively in modern ESP-IDF and Arduino Core v3.x environments. The original T-vK Bluedroid-based library will fail to compile on these chips. You must use a NimBLE-compatible fork, such as the ESP32-BLE-Combo library, which abstracts the NimBLE API to match the standard Keyboard/Mouse HID profiles. Hardware-wise, the C3 is excellent for keyboards due to its lower deep-sleep current (~5µA compared to the original ESP32's ~150µA).

How do I wake the ESP32 BLE keyboard from deep sleep?

You can wake the ESP32 from deep sleep using an external GPIO interrupt via esp_sleep_enable_ext0_wakeup(GPIO_NUM_25, 1). However, understand that deep sleep wipes the RAM. The BLE connection state, pairing keys, and variables are lost. When the chip wakes, it undergoes a full cold boot and must re-establish the BLE link, which takes 1-3 seconds. For a seamless keyboard experience where the host doesn't drop the connection, use Light Sleep (esp_light_sleep_start()) instead. Light sleep retains RAM and BLE link-layer state, allowing the keyboard to wake and send a keystroke in under 5 milliseconds.