The ESP32-S3 datasheet is a dense, 700+ page technical reference that hides the most critical hardware quirks of Espressif’s Xtensa LX7 dual-core MCU. While most tutorials gloss over it, reading the datasheet is the only way to reliably design around the S3’s native USB OTG controller and avoid the infamous strapping pin boot-loops. In this guide, we will decode the relevant datasheet chapters to build a Native USB HID Macro Pad, and then debug the most common flash error that plagues S3 developers.
Parts List & Board Variant Specifics
Before writing a single line of code, you must select the correct hardware. The ESP32-S3 comes in dozens of module configurations. For native USB and HID applications, memory and flash speed dictate your success.
- Microcontroller: ESP32-S3-DevKitC-1 (Specifically the N8R2 variant: 8MB Quad SPI Flash, 2MB PSRAM). Do not use the N4 (4MB) variant if you plan to add audio or large HID descriptor arrays later.
- Switches: 4x Cherry MX Brown mechanical switches (or compatible clones).
- Diodes: 4x 1N4148 signal diodes (mandatory for matrix ghosting prevention).
- Wiring: 24 AWG silicone stranded wire.
- Cable: A verified data-capable USB-C cable (not a charge-only cable).
Navigating the ESP32-S3 Datasheet for Native USB & Strapping Pins
When you open the official ESP32-S3 datasheet, skip the functional overview and go straight to Chapter 2: Pin Descriptions and Chapter 33: USB Serial/JTAG Controller. The S3’s native USB D- and D+ lines are hardcoded to GPIO19 and GPIO20. You cannot remap these.
However, the most dangerous section of the datasheet is Section 2.4: Strapping Pins. The S3 samples these pins during reset to determine boot modes and flash voltages. If your external wiring interferes with these pins, the chip will brick itself on boot.
| GPIO Pin | Function if LOW (0) | Function if HIGH (1) | Maker Hazard Level |
|---|---|---|---|
| GPIO0 | Download boot from SPI | SPI boot mode (Normal) | Low (Used for BOOT button) |
| GPIO3 | JTAG signal source from USB | JTAG signal source from GPIO | Medium (Keep LOW for native USB JTAG) |
| GPIO45 | VDD_SPI outputs 3.3V | VDD_SPI outputs 1.8V | Critical (Pulling HIGH kills SPI flash) |
| GPIO46 | Boot log prints at 115200 | Boot log prints at ROM default | Low |
Never route external matrix switches or pull-up resistors to GPIO45. If GPIO45 is pulled high during reset, the internal flash voltage drops to 1.8V, causing immediate boot failures.
Step-by-Step Matrix Wiring
We are building a 2x2 macro matrix. The datasheet recommends avoiding pins that are tied to the onboard RGB LED (GPIO48) or the Octal SPI flash (GPIO33-37 on Octal modules, though our N8R2 is Quad SPI).
- Prepare the Switches: Solder a 1N4148 diode to the cathode (stripe side) of Pin 1 on each Cherry MX switch. Bend the anode side to form your row lines.
- Wire the Columns: Connect Pin 2 of all switches in the same column together using 24 AWG wire.
- Map to S3 GPIOs: Refer to the pin mapping table below and solder your row and column wires to the DevKit headers.
- Verify Continuity: Use a multimeter in diode-test mode. Place the red probe on the row wire and black on the column wire. You should read ~0.6V. Reverse probes should read OL (Open Loop).
Pin Mapping Table
| Matrix Role | ESP32-S3 GPIO | Datasheet Notes / Constraints |
|---|---|---|
| Row 0 | GPIO4 | Safe GPIO, no boot strapping. |
| Row 1 | GPIO5 | Safe GPIO, supports internal pull-down. |
| Col 0 | GPIO6 | Safe GPIO, will use internal pull-up. |
| Col 1 | GPIO7 | Safe GPIO, will use internal pull-up. |
| Native USB D- | GPIO19 | Hardcoded USB OTG. Do not wire externally. |
| Native USB D+ | GPIO20 | Hardcoded USB OTG. Do not wire externally. |
Complete Compilable Firmware (Arduino IDE 2.x / ESP32 Core 3.x)
This code targets the ESP32-S3-DevKitC-1 (N8R2) using the Arduino ESP32 Core v3.x. It utilizes the native USBHIDKeyboard library. Unlike standard serial, native USB requires explicit mounting checks to prevent the firmware from hanging if the host PC is disconnected.
#include "Arduino.h"
#include "USB.h"
#include "USBHIDKeyboard.h"
// --- PIN DEFINITIONS ---
const int ROW_PINS[] = {4, 5};
const int COL_PINS[] = {6, 7};
const int NUM_ROWS = 2;
const int NUM_COLS = 2;
// --- HID KEY MAPPINGS (Row x Col) ---
// Maps to standard HID keycodes for 'A', 'B', 'C', 'D'
const char KEY_MAP[2][2] = {'a', 'b', 'c', 'd'};
USBHIDKeyboard Keyboard;
bool previousState[2][2];
void setup() {
// Initialize Serial for debug (routes to USB CDC or UART depending on menu)
Serial.begin(115200);
// Configure Rows as OUTPUT (Drive LOW to scan)
for (int i = 0; i < NUM_ROWS; i++) {
pinMode(ROW_PINS[i], OUTPUT);
digitalWrite(ROW_PINS[i], HIGH);
}
// Configure Cols as INPUT_PULLUP
for (int j = 0; j < NUM_COLS; j++) {
pinMode(COL_PINS[j], INPUT_PULLUP);
for (int i = 0; i < NUM_ROWS; i++) {
previousState[i][j] = HIGH;
}
}
// Initialize Native USB HID
Keyboard.begin();
USB.begin();
// Error Handling: Wait for USB mount with a timeout to prevent infinite hang
unsigned long startMillis = millis();
while (!USB.connected() && (millis() - startMillis < 3000)) {
delay(10);
}
if (USB.connected()) {
Serial.println("USB HID Mounted Successfully.");
} else {
Serial.println("Warning: USB Host not detected. Running in offline mode.");
}
}
void loop() {
for (int i = 0; i < NUM_ROWS; i++) {
// Drive current row LOW
digitalWrite(ROW_PINS[i], LOW);
delayMicroseconds(5); // Allow voltage to settle
for (int j = 0; j < NUM_COLS; j++) {
bool currentState = digitalRead(COL_PINS[j]);
// Detect state change (Switch pressed = LOW due to pull-up)
if (currentState != previousState[i][j]) {
if (currentState == LOW) {
// Key Pressed
if (USB.connected()) {
Keyboard.press(KEY_MAP[i][j]);
Serial.printf("Pressed: %c\n", KEY_MAP[i][j]);
}
} else {
// Key Released
if (USB.connected()) {
Keyboard.release(KEY_MAP[i][j]);
}
}
previousState[i][j] = currentState;
}
}
// Return row to HIGH
digitalWrite(ROW_PINS[i], HIGH);
}
delay(2); // ~500Hz scan rate, basic debounce
}
Debugging: "Failed to connect to ESP32-S3: Timed out waiting for packet header"
When flashing the S3 via the Arduino IDE or PlatformIO, you will inevitably encounter this exact error string in the console:
A fatal error occurred: Failed to connect to ESP32-S3: Timed out waiting for packet header
This is not a broken chip; it is a boot-mode strapping failure. The host PC is sending serial handshake packets, but the S3 is not in UART download mode to receive them.
The First Three Things to Check
- Manual Boot Mode Entry: The S3’s auto-reset circuit on the DevKitC-1 sometimes fails to pulse GPIO0 low during the DTR/RTS serial handshake. Fix: Press and hold the BOOT button (GPIO0), tap the RST button, then release the BOOT button. Click "Upload" again in the IDE.
- Native USB CDC/JTAG Conflict: If your previous sketch utilized the native USB port for Serial (CDC) and crashed or disabled the USB peripheral, the UART bridge cannot take over. Fix: Change the IDE Tools menu to USB CDC On Boot: Disabled and USB Mode: Hardware CDC and JTAG, then force manual boot mode (Step 1).
- Cable and Port Verification: Ensure you are plugged into the UART USB-C port on the DevKit, not the native USB port, when using the standard serial uploader. Verify the cable has data lines by checking if the PC makes a USB connection sound.
Extending and Simplifying the Build
To Simplify: If you only need a single macro button (e.g., a stream deck mute toggle), strip the matrix logic. Wire a single switch between GPIO4 and GND. Enable INPUT_PULLUP on GPIO4 and remove the row-scanning for loops entirely. This reduces the code footprint and eliminates the need for diodes.
To Extend: To add a rotary encoder for volume control, consult Chapter 29: Pulse Counter (PCNT) in the ESP32-S3 datasheet. The S3’s PCNT peripheral can decode quadrature encoder signals in hardware without triggering GPIO interrupts, freeing up the CPU. Wire the encoder A/B pins to GPIO8 and GPIO9, and use the ESP32 pcnt_encoder API to map pulses to Keyboard.press(KEY_VOLUME_UP).
ESP32-S3 Datasheet FAQ
What is the maximum current draw per GPIO on the ESP32-S3?
According to Section 2.2 of the datasheet, the absolute maximum current for any single GPIO pin is 40mA. However, the recommended continuous operating current is 20mA to prevent voltage sag and thermal throttling of the internal IO ring. The total combined current for all GPIOs must not exceed 200mA. If you are driving LEDs directly, always use a logic-level MOSFET or a dedicated LED driver IC.
Does the ESP32-S3 support USB PD (Power Delivery) natively?
No. The native USB OTG controller detailed in Chapter 33 supports USB 2.0 Full-Speed (12 Mbps) and acts as a Device or Host, but it does not include the physical layer (PHY) or CC (Configuration Channel) logic required for USB-C Power Delivery negotiation. The S3 will draw standard 5V at up to 500mA from a host. To implement USB PD sink/source capabilities, you must add an external PD controller IC like the STUSB4500 or IP2721 communicating via I2C.
How do I enable the Octal SPI flash mode listed in the datasheet?
Octal SPI (OPI) flash and PSRAM are only available on specific module variants (e.g., N16R8). If you have an Octal module, the datasheet notes that GPIO33 through GPIO37 are permanently consumed by the internal flash/PSRAM bus and are not broken out to the castellated pads. You cannot enable Octal mode via software on a Quad SPI (N8R2) module; it is a hardware physical connection. In the Arduino IDE, you must select the correct OPI PSRAM option under the Tools menu to enable the memory controller to use the 8-bit bus.
Why does the datasheet list GPIO35-42 as unavailable on some modules?
On ESP32-S3 modules that include integrated Octal SPI Flash or PSRAM, pins GPIO35, GPIO36, GPIO37, GPIO38, GPIO39, GPIO40, GPIO41, and GPIO42 are used internally to communicate with the memory chips. The Espressif Hardware Reference explicitly states these pins are unavailable for user IO. If you are designing a custom PCB using a bare S3-WROOM-1 module (which lacks internal PSRAM), these pins become available as standard GPIOs.






