An ESP32 USB HID Passthrough for Keyboard and Mouse. Allowing macro injection.
- ⏰: With a focus on low latency. You can expect sub-millisecond latency added by the passthrough.
- ⌨️ / 🖱️: Compatible for peripheral up to 1000Hz of pooling rate.
- 🕹️: Expected to works fine for gaming.
- 🔌: You can connect a USB hub, allowing you to connect and intercept both the keyboard and mouse at the same time.
- đź’ż: The goal is to have a hardware equivalent of macro software like
AutoHotKey.
macroPassthrough is a dual-ESP32-S3 project that enables USB keyboard and mouse passthrough and macro injection. It acts as a bridge between a USB device and a PC, allowing for the interception and injection of custom keyboard macros. The project is designed for advanced keyboard automation, security research, and prototyping custom HID devices.
Overview of connections between the two ESP32-S3s:
This project is inspired by the following examples:
-
usb-input/
ESP32 firmware that acts as a USB host, receives HID reports from a keyboard, and forwards them over SPI. -
usb-output/
ESP32 firmware that acts as a USB device, receives HID/macro reports over SPI, aggregates them, and presents as a USB HID keyboard to the PC. Supports macro injection. -
benchmark/
Contains performance and latency measurement data for different USB and SPI configurations.
flowchart LR
KBD[USB Keyboard]
ESPIN[ESP32-S3-Input]
ESPOUT[ESP32-S3-Output]
PC[PC]
KBD -- USB --> ESPIN
ESPIN -- SPI1: HID Data --> ESPOUT
ESPOUT -- SPI2: PC LED --> ESPIN
ESPOUT -- USB --> PC
USBPWR1[USB Power / Debug UART] -.-> ESPIN
USBPWR2[USB Power / Debug UART] -.-> ESPOUT
%% Node classes
classDef keyboard fill:#f9f,stroke:#333,stroke-width:2px;
classDef input fill:#c2e7ff,stroke:#333,stroke-width:2px;
classDef output fill:#c2e7ff,stroke:#333,stroke-width:2px;
classDef pc fill:#d2ffd2,stroke:#333,stroke-width:2px;
classDef power fill:#fff3b0,stroke:#333,stroke-width:2px,stroke-dasharray: 5 5;
class KBD keyboard;
class ESPIN input;
class ESPOUT output;
class PC pc;
class USBPWR1 power;
class USBPWR2 power;
Legend:
- USB Keyboard: The physical keyboard connected to the input ESP32-S3.
- ESP32-S3-Input: Runs usb-input or usb-input-tinyusb firmware, acts as USB host and SPI master.
- ESP32-S3-Output: Runs usb-output firmware, acts as SPI slave and USB device.
- SPI1: HID Data: HID reports from input to output.
- SPI2: PC LED: Ouput reports coming the PC (Keyboard LED).
- PC: Receives HID input as if from a real keyboard.
- USB Power: Both ESP32-S3 boards are powered via USB.
- 2 Ă— ESP32-S3 (one for input, one for output): Only tested with ESP32-S3, but should works fine with ESP32-S2. And with a little works with ESP32-H4.
- USB keyboard (for passthrough)
- PC (for HID output)
- SPI wiring between the two ESP32 boards
- USB cables for programming and connecting devices
- A key activation allows you to launch a key sequence. Here, right-clicking the mouse launches a sequence that presses all the keys of the alphabet.
- The sequence can also be used in video games (here CS2) to perform automatic aim control. (Inspired by NoRecoil-CS2)
-
Set up ESP-IDF
Follow the ESP-IDF Getting Started Guide to install the toolchain and export environment variables. -
Clone this repository
git clone https://github.com/arfevrier/macroPassthrough.git cd macroPassthrough -
Build the firmware
For each component (e.g., usb-input, usb-output), run:cd usb-input idf.py build cd ../usb-output idf.py build
For TinyUSB variant:
cd usb-input-tinyusb git clone https://github.com/hathach/tinyusb.git components/tinyusb idf.py build -
Flashing
Flash the firmware using the IDF environment. The project have been made using VSCode with ESP-IDF extension.
This project uses GitHub Actions for continuous integration. Every push and pull request automatically triggers builds for both the usb-input and usb-output ESP-IDF firmware projects. The workflows check out the code, set up the ESP-IDF environment, and run idf.py build to ensure the firmware compiles successfully.
- usb-input: Workflow file
- usb-output: Workflow file
You can view the build status and logs by clicking the badges above.
- Connect the USB keyboard to the input ESP32 (running usb-input or usb-input-tinyusb).
- Connect the output ESP32 to the PC.
- Wire the SPI bus between the two ESP32 boards (refer to
config.hfor pin assignments). - Customize the macro configuration inside
usb-output/main/config.h - Power both boards and reset if necessary.
- The PC should recognize the output ESP32 as a USB keyboard. Macros can be injected as configured in the firmware.
Macro sequences are defined in usb-output/main/config.h using the macro_sequence variable. Each sequence describes a set of HID actions (keyboard or mouse) that can be triggered by a specific key or mouse event.
A macro sequence is defined as an entry in the group_sequence_t macro_sequence structure. Each entry has the following fields:
| Parameter | Type/Example | Description |
|---|---|---|
.list |
{duration, event} array |
Steps in the sequence: each with a duration (in microseconds) and an event. |
.size |
int |
Number of steps in .list. |
.event_press |
ONE_KEYBOARD_KEY(...) |
Key or mouse event that triggers the sequence. |
.event_release |
ONE_KEYBOARD_KEY(...) |
(Optional) Key or mouse event that triggers on release. |
.loop |
bool (true/false) |
(Optional) Whether the sequence repeats while the trigger is held. |
static const group_sequence_t macro_sequence = {
.list = {
{
.list = {
{1000*1000, ONE_KEYBOARD_KEY(HID_KEY_A)},
{1000*1000, EMPTY_KEYBOARD},
},
.size = 2,
.event_press = ONE_KEYBOARD_KEY(HID_KEY_B),
},
}
};This example triggers a macro that presses "A" for 1 second, then releases it for 1 second, whenever "B" is pressed.
{
.list = {
{3*1000, MOUSE_MOUVEMENT(-2, 0)},
},
.size = 1,
.loop = true,
.event_press = ONE_KEYBOARD_KEY(HID_KEY_ARROW_LEFT),
},This macro moves the mouse left while the left arrow key is held.
- Edit
usb-output/main/config.hand locate themacro_sequencevariable. - Add or modify entries in the
.listarray to define new macros. - Each macro can be triggered by a specific key or mouse event.
- Use the provided macros like
ONE_KEYBOARD_KEY,ONE_MOUSE_KEY,MOUSE_MOUVEMENT, andEMPTY_KEYBOARDto define actions.
For advanced personalization, you can use a custom configuration file to override the default macro settings without modifying the main config.h.
- In
usb-output/main/config.h, set:#define CUSTOM_CONFIG 1
- When
CUSTOM_CONFIGis set to 1, the firmware will include and useusb-output/main/config_custom.hinstead of the default macro configuration. - Create your own
usb-output/main/config_custom.hfile. You can define your ownmacro_sequenceor other configuration parameters here.
Example usb-output/main/config_custom.h:
#pragma once
#include "macpass_macro.h"
static const group_sequence_t macro_sequence = {
.list = {
{
.list = {
{500*1000, ONE_KEYBOARD_KEY(HID_KEY_X)},
{500*1000, EMPTY_KEYBOARD},
},
.size = 2,
.event_press = ONE_KEYBOARD_KEY(HID_KEY_Y),
},
}
};This approach allows you to keep your personal configuration separate from the main codebase, making it easier to update or share the project without losing your custom settings.
For advanced customization, you can add your own logic to modify HID reports before or after they are sent to the PC. This is done by editing the hook functions in usb-output/main/macpass_macro.c:
macro_prehook_transmission(hid_transmit_t* report): Called before a HID report is sent. Returntrueto block the report, or modify the report in-place.macro_posthook_transmission(hid_transmit_t* report): Called after a HID report is sent. Can be used for logging or triggering additional actions.
bool macro_prehook_transmission(hid_transmit_t* report){
// Block both A and D from being pressed at the same time
if (report->header == HEADER_HID_KEYBOARD){
if (keycode_contains_key(report->event.keyboard, HID_KEY_A) &&
keycode_contains_key(report->event.keyboard, HID_KEY_D)){
remove_keycode(&report->event.keyboard, HID_KEY_A);
}
}
return false; // Return true to block the report entirely
}void macro_posthook_transmission(hid_transmit_t* report){
// Example: Log every time a macro is triggered
#if DEBUG_LOG
ESP_LOGI(pcTaskGetName(NULL), "Macro triggered!");
#endif
}To add your own logic, simply edit the code between the // --- START USER CUSTOM MACRO and // --- END comments in the respective functions.
Here is a sequence diagram showing all the functions that process an HID report, from receipt by a device to the return of an LED report by the PC. This helps to understand a little better how a HID report is processed. Here, a keyboard is used as an example, but it works the same way for a mouse.
sequenceDiagram
participant KBD as USB Keyboard
participant USBIN as ESP32-S3 USB-input
participant SPI as SPI Bus
participant USBOUT as ESP32-S3 USB-output
participant PC as PC
KBD->>USBIN: 1. CapsLock pressed (HID report)
USBIN->>USBIN: 2. hid_host_interface_callback()
USBIN->>SPI: 3. Send HID report: spi_send_master_hid_sender()
SPI->>USBOUT: Receive HID report: spi_task_slave_hid_receiver()
USBOUT->>+USBOUT: 4.1. macro_prehook_transmission()
USBOUT->>USBOUT: 4.2. Add to multiplexer queue: hid_add_report()
USBOUT->>-USBOUT: 4.3. macro_posthook_transmission()
USBOUT->>+USBOUT: 5. hid_task_multiplexer(): calls tud_hid_keyboard_report()
USBOUT->>PC: 6. Send HID report to PC (CapsLock)
PC->>USBOUT: 7. PC sends LED report, tud_hid_set_report_cb()
USBOUT->>-SPI: Send LED report: spi_send_master_pc_sender()
SPI->>USBIN: Receive LED report: spi_task_slave_pc_receiver()
USBIN->>USBIN: 10. hid_host_keyboard_report_output()
USBIN->>USBIN: 11. hid_class_request_set_report()
USBIN->>KBD: 12. LED update
Here the one for hardware interrupts that manage user-defined macros:
sequenceDiagram
participant USBOUT as ESP32-S3 USB-output
participant TIMER as ESP-Timer Interuption
participant PC as PC
USBOUT->>USBOUT: 1. macro_posthook_transmission()
USBOUT->>TIMER: 2. Configure timer (macro_sequence_callback)
loop For each sequence
TIMER->>USBOUT: x.1. Macro engine adds macro HID report(s) to queue hid_add_report()
USBOUT->>PC: x.2. hid_task_multiplexer(): calls tud_hid_keyboard_report()
TIMER->>TIMER: x.3. Configure timer for next sequence macro_sequence_callback()
end
The benchmark/ directory contains latency and throughput measurements for different configurations (e.g., ESP-IDF USB host, TinyUSB, SPI). See the text files for details.
This project is licensed under the terms of the LICENSE file.


