HijelHID_BLEKeyboard
April 9, 2026 · View on GitHub
A complete Bluetooth Low Energy (BLE) HID keyboard library for ESP32, built on NimBLE-Arduino.
Turn your ESP32 into a BLE HID Keyboard. Great for creating a physical device, or just emulating one.
Supports all keys on a standard 104/105-key keyboard with numpad, consumer/media keys, and international/language keys. Keyboard and media keys share a single unified API — press(), release(), releaseAll(), and tap() handle both automatically.
Works with iOS, Android, macOS, Windows, and Linux.
Testing completed on:
iOS 26.3 - Fully Tested
Android 16 - Fully Tested
macOS Ventura 13.7.8 - Fully Tested
Windows 11 Pro 25H2 - Fully Tested
Ubuntu 22.04.5 LTS - Fully Tested
Requirements
| Requirement | Version | Tested On |
|---|---|---|
| ESP32 Arduino Core | 3.x.x | 3.3.7 |
| NimBLE-Arduino | >= 2.3.8 [Minimum Required] | 2.5.0 |
| Arduino IDE | NA | 2.3.8 |
Install NimBLE-Arduino via Arduino IDE: Tools → Manage Libraries → search "NimBLE-Arduino"
Install Espressif arduino-esp32 core via Arduino IDE: Tools → Boards → Boards Manager → search "arduino-esp32"
An ESP32 board/module with BLE [ All except ESP32-S2 and ESP32-P4 ]
Installation
Arduino Library Manager: Sketch → Include Library → Manage Libraries Search for "HijelHID"
--- OR ---
Manual Zip Install:
- Download the Latest ZIP [Direct Download Link]
- In Arduino IDE:
Sketch → Include Library → Add .ZIP Library - Select the downloaded zip
Quick Start
#include <HijelHID_BLEKeyboard.h>
HijelHID_BLEKeyboard keyboard;
void setup() {
keyboard.begin();
}
void loop() {
delay(5000);
// Open a text editor on your host device
if (keyboard.isConnected()) {
// Print "Hello, World!"
keyboard.print("Hello, ");
keyboard.println("World!");
// Press and Tap "ESP32!"
keyboard.press(KEY_LSHIFT);
delay(25);
keyboard.tap(KEY_E);
keyboard.tap(KEY_S);
keyboard.tap(KEY_P);
keyboard.release(KEY_LSHIFT);
keyboard.tap(KEY_3);
keyboard.tap(KEY_2);
// tap an exclamation point "!"
keyboard.tap(KEY_1, KEY_MOD_LSHIFT);
keyboard.tap(KEY_RETURN);
keyboard.releaseAll();
}
}
API Reference
CLICK FOR API INDEX
Constructor
Create a keyboard object with an optional custom name, manufacturer, and battery level.
// Default — shows as "HijelHID KB" when pairing
HijelHID_BLEKeyboard keyboard;
// Custom name and manufacturer
HijelHID_BLEKeyboard keyboard("My Keyboard", "My Company", 100);
| Parameter | Description | Default |
|---|---|---|
deviceName | Name shown to the host when pairing. Max 29 chars. | "HijelHID KB" |
manufacturer | Manufacturer name. Max 512 chars. | "Hijel" |
batteryLevel | Starting battery level (1–100). | 100 |
Lifecycle
Call begin() once in setup() to start BLE advertising. The device will be discoverable and ready to pair.
void setup() {
keyboard.begin();
}
Call end() to disconnect and stop advertising. The BLE stack stays in memory so begin() can restart quickly without reinitialisation.
keyboard.end(); // pause — BLE stack stays alive
keyboard.begin(); // restart — fast, no full reinit
Call kill() to permanently shut down and deinitialise the BLE stack, freeing all BLE memory. begin() cannot be called after kill().
keyboard.kill(); // permanent shutdown — frees ~38KB of heap
// keyboard.begin(); // ← refused after kill(), logs a warning
| Method | Effect | begin() after? |
|---|---|---|
begin() | Initialise BLE and start advertising | N/A |
end() | Disconnect and stop advertising, BLE stack stays in memory | Yes — fast restart |
kill() | Full teardown, frees all BLE memory | No — permanently shut down |
Note
A small bounded memory leak (~308 bytes) remains after kill() due to an apperant upstream issue with NimBLE. Since begin() is refused after kill(), this leak cannot compound. For pause/resume scenarios, use end() and begin() instead.
Connection
isConnected()
Check whether a host is connected before sending keys.
void loop() {
if (keyboard.isConnected()) {
keyboard.tap(KEY_A);
delay(2000);
}
}
isPaired()
A more reliable ready-to-send signal than isConnected(). It returns true only after the host has fully authenticated — isConnected() becomes true briefly before the encryption handshake completes on reconnect, which can cause the first report to be dropped.
// Wait until fully ready before sending
while (!keyboard.isPaired()) {
delay(10);
}
keyboard.println("Ready!");
getIdleTime()
Returns the number of milliseconds since the last HID report was sent. Use it in your sketch to decide when to enter light or deep sleep.
if (keyboard.isPaired() && keyboard.getIdleTime() > 30000) {
keyboard.beforeSleep();
esp_light_sleep_start();
keyboard.afterWake();
}
Typing Text
print() and println() type a string of characters. Upper case, punctuation, and spaces are handled automatically. println() adds a newline at the end.
keyboard.print("Hello, World!");
keyboard.println("This line ends with Enter");
You can also send characters one at a time using write().
const char* str = "Writing Hello from ESP32!";
for (int i = 0; str[i] != '\0'; i++) {
keyboard.write((uint8_t)str[i]);
}
keyboard.tap(KEY_RETURN);
Tapping Keys
tap() is the simplest way to press and release a single key. Use KEY_* constants from src/BLEHIDKeys.h.
// Tap a single key
keyboard.tap(KEY_RETURN);
keyboard.tap(KEY_SPACE);
keyboard.tap(KEY_ESCAPE);
// Tap with a modifier (Shift, Ctrl, Alt, etc.)
keyboard.tap(KEY_A, KEY_MOD_LSHIFT); // Types uppercase 'A'
keyboard.tap(KEY_C, KEY_MOD_LCTRL); // Ctrl+C (copy)
keyboard.tap(KEY_Z, KEY_MOD_LCTRL); // Ctrl+Z (undo)
// Multiple modifiers — combine with |
keyboard.tap(KEY_DELETE, KEY_MOD_LCTRL | KEY_MOD_LALT); // Ctrl+Alt+Del
Pressing Keys
Use press() and release() when you need to hold a key down. You must add delay() calls yourself between each step.
// Hold Shift while pressing a key, then release
keyboard.press(KEY_H, KEY_MOD_LSHIFT);
delay(25);
keyboard.releaseAll();
delay(25);
keyboard.press(KEY_I);
delay(25);
keyboard.release(KEY_I);
delay(25);
keyboard.press(KEY_I);
delay(25);
keyboard.releaseAll();
Tip: For most use cases,
tap()is simpler and handles all timing automatically. Usepress()/release()only when you need precise control over hold timing.
releaseAll() releases every held key at once — useful as a safety call to clear any stuck keys.
keyboard.releaseAll();
Media / Consumer Keys
Media keys work with both tap() and press(). Use MEDIA_* constants from src/BLEHIDMediaKeys.h.
// Tap a media key (press and release automatically)
keyboard.tap(MEDIA_PLAY_PAUSE);
keyboard.tap(MEDIA_VOLUME_UP);
keyboard.tap(MEDIA_VOLUME_DOWN);
keyboard.tap(MEDIA_MUTE);
keyboard.tap(MEDIA_NEXT_TRACK);
keyboard.tap(MEDIA_PREV_TRACK);
// Hold a media key down, then release
keyboard.press(MEDIA_VOLUME_UP);
delay(500);
keyboard.releaseAll();
Timing
By default, tap() holds each key for 25ms and waits 25ms after release before the next key. You can adjust these globally, or override them for a single tap() call.
// Change timing globally (affects all tap() and print/println calls)
keyboard.setTapDelay(40); // hold each key for 40ms
keyboard.setKeyGap(40); // wait 40ms after each release
// Override timing for a single tap
keyboard.tap(KEY_A); // uses global timing
keyboard.tap(KEY_A, 0, 60, 40); // hold 60ms, gap 40ms
keyboard.tap(KEY_A, KEY_MOD_LSHIFT, 60, 40); // with modifier + custom timing
keyboard.tap(MEDIA_VOLUME_UP, 60, 40); // media key with custom timing
Battery Level
Update the battery percentage shown to the host at any time.
keyboard.setBatteryLevel(85); // Report 85% battery
Security / Pairing
By default the keyboard pairs automatically with no passkey. To require a passkey challenge, call setSecurityMode() before begin().
void setup() {
Serial.begin(115200);
keyboard.setSecurityMode(HIDSecurity::Passkey); // Must be before begin()
keyboard.begin();
}
| Mode | Behaviour |
|---|---|
HIDSecurity::JustWorks | Auto-pair with no passcode (default) |
HIDSecurity::Passkey | Require a numerical comparison passkey printed to Serial |
When passkey mode is active, the passkey is printed to Serial automatically. You can also register callbacks to handle the passkey and pairing result in your own code.
#include <HijelHID_BLEKeyboard.h>
HijelHID_BLEKeyboard keyboard;
// Called when a passkey needs to be displayed to the user.
// Show it however makes sense for your project — Serial, display, LEDs, etc.
void onPassKey(uint32_t passkey) {
Serial.print("Does this passkey match on your device? ");
Serial.println(passkey);
}
// Called when pairing completes or fails.
void onPairingComplete(bool success) {
if (success) {
Serial.println("Pairing successful — keyboard is ready.");
} else {
Serial.println("Pairing failed. Try removing and re-pairing.");
}
}
void setup() {
Serial.begin(115200);
keyboard.setSecurityMode(HIDSecurity::Passkey);
keyboard.setPasskeyCallback(onPassKey);
keyboard.onPairingComplete(onPairingComplete);
keyboard.begin();
}
Note
If HIDLogLevel::Off is set and no passkey callback is registered, the passkey code will not be displayed anywhere. Always register a passkey callback when using HIDLogLevel::Off in Passkey mode.
To forget all previously paired devices and force re-pairing:
keyboard.clearBonds();
To check if a bond is already stored:
if (keyboard.isBonded()) {
Serial.println("A device is already bonded.");
}
Power Saving
TX Power
The BLE radio transmit power can be reduced to save energy when the device is operating at close range. Valid levels are 1–8, 1 being the lowest TX power. Default is set to 8.
keyboard.setTxPower(1); // -12 dBm, lowest range/setting
keyboard.setTxPower(8); // +9 dBm, maximum range/setting (default)
Idle Radio Power Saving
The library automatically reduces the BLE radio duty cycle after 5 seconds of inactivity. The radio skips connection events during idle, reducing wake-ups from ~133/sec to ~1.6/sec. Full rate is restored immediately on the next keypress. No user code changes are required.
Use getIdleTime() in your sketch to check how long the keyboard has been idle, for example to decide when to enter light or deep sleep.
if (keyboard.isPaired() && keyboard.getIdleTime() > 30000) {
// No key sent for 30 seconds — enter light sleep
keyboard.beforeSleep();
esp_light_sleep_start();
keyboard.afterWake();
}
Light Sleep
Call beforeSleep() immediately before entering light sleep and afterWake() immediately after. afterWake() blocks until the host has fully reconnected and the HID stack has settled — or until the default timout (15000ms) expires.
If needed, you can change the timeout value by setting setAfterWakeTimeout() in your setup() function.
keyboard.beforeSleep();
esp_light_sleep_start();
keyboard.afterWake(); // blocks until host is ready
keyboard.println("Woke from light sleep!");
Deep Sleep
No special library calls are needed for deep sleep. However, you should call beforeSleep() before sleeping to release any held keys cleanly, then call begin() as normal in setup() on wakeup. The stored bond survives deep sleep and the host will reconnect automatically.
// Before sleeping:
keyboard.beforeSleep();
esp_deep_sleep_start();
// On wakeup, setup() runs as normal:
void setup() {
keyboard.begin(); // reconnects via stored bond automatically
}
LED State
The host sends LED state back to the keyboard (Num Lock, Caps Lock, Scroll Lock). You can read the current state or set a callback to react to changes.
// Read current state
if (keyboard.isCapsLockOn()) {
Serial.println("Caps Lock is ON");
}
// React to changes with a callback
keyboard.onLEDChange([](uint8_t leds) {
if (leds & HID_LED_CAPS_LOCK) {
Serial.println("Caps Lock ON");
} else {
Serial.println("Caps Lock OFF");
}
});
| Function | Returns |
|---|---|
isCapsLockOn() | true if Caps Lock is active |
isNumLockOn() | true if Num Lock is active |
isScrollLockOn() | true if Scroll Lock is active |
Debug Logging
Enable Serial logging to help with troubleshooting. Call before begin().
void setup() {
Serial.begin(115200);
keyboard.setLogLevel(HIDLogLevel::Normal); // Connection and pairing events
keyboard.begin();
}
| Level | Output |
|---|---|
HIDLogLevel::Off | No output (default) |
HIDLogLevel::Normal | Connection, pairing, and advertising events |
HIDLogLevel::Verbose | All of the above, plus every HID report sent |
Platform Notes
| Platform | Pairing | Notes |
|---|---|---|
| iOS | Auto or passkey | When attempting to change the deviceName, new names will only appear: 1) AFTER you re-pair the device 2) IF the new deviceName is SHORTER than the cached name 3) OR the new deviceName is LESS than 20 Characters |
| Android | Auto | Vendor quirks vary; Just Works works on most devices |
| macOS | Auto or passkey | May be asked to "Setup Keyboard", but canceling out of this setup appears to be harmless and tests still passed |
| Windows 10/11 | Auto or passkey | Caches HID descriptor — fully unpair before flashing a new descriptor during development |
| Linux (BlueZ) | Auto or passkey | Initial testing revealed odd modifier key behaviour, the culprit was found to be ibus intercepting modifier keys. You can uninstall ibus with sudo apt purge ibus if you don't need it. |
Troubleshooting
ESP32 stuck in reboot loop
- Ensure you have at least Nimble-Arduino 2.3.8 installed.
Device not appearing in Bluetooth scan
- Check that no previous bond is stored on both sides — call
keyboard.clearBonds()and remove from host Bluetooth settings
Keys not registering / wrong characters
- Confirm the host keyboard layout is set to US QWERTY
- The library uses US HID keycodes; non-US layouts will produce different characters for punctuation
- Make sure if you are using
press()that you are correctly setting timing delays and releasing keys
Windows shows ghost device after reflashing
- Completely remove the device in Windows Bluetooth settings before flashing
- Call
keyboard.clearBonds()in setup temporarily, then remove and re-add
Media keys not working on some apps
- Not all applications respond to consumer HID keys — test with the OS-level media player first
Acknowledgements
- The hundreds of contributors and maintainers of the Espressif arduino-esp32 library
- My fellow Canadian Ryan Powell AKA h2zero for his continued work on NimBLE-Arduino and all the contributors to the project.
- My good friend Claude over at Anthropic for working tirelessly and for always telling me how smart, and right, and great I am. Even when I'm being an absolute moron.
Support This Project
If you found this library useful, your support would mean a lot!
If you are intending to use this library in a commercial product, your support is expected.
My other projects you might like:
A Bluetooth Low Energy HID Mouse library for ESP32
A Github Analytics dashboard that automatically Gets, Stores, and Displays traffic data from multiple repos on a single page. Built completely on Githubs Free Tier.
Feel free to post your known working hardware/OS versions and combos in the Discussions section.
Please take the time to properly report any bugs you come across.