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

RequirementVersionTested On
ESP32 Arduino Core3.x.x3.3.7
NimBLE-Arduino>= 2.3.8 [Minimum Required]2.5.0
Arduino IDENA2.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

Latest Release Release Date

Arduino Library Manager: Sketch → Include Library → Manage Libraries Search for "HijelHID"

--- OR ---

Manual Zip Install:

  1. Download the Latest ZIP [Direct Download Link]
  2. In Arduino IDE: Sketch → Include Library → Add .ZIP Library
  3. 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);
ParameterDescriptionDefault
deviceNameName shown to the host when pairing. Max 29 chars."HijelHID KB"
manufacturerManufacturer name. Max 512 chars."Hijel"
batteryLevelStarting battery level (1–100).100

[Top]



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
MethodEffectbegin() after?
begin()Initialise BLE and start advertisingN/A
end()Disconnect and stop advertising, BLE stack stays in memoryYes — fast restart
kill()Full teardown, frees all BLE memoryNo — 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.

[Top]



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();
}

[Top]



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);

[Top]



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

[Top]



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. Use press()/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();

[Top]



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();

[Top]



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

[Top]



Battery Level

Update the battery percentage shown to the host at any time.

keyboard.setBatteryLevel(85);  // Report 85% battery

[Top]



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();
}
ModeBehaviour
HIDSecurity::JustWorksAuto-pair with no passcode (default)
HIDSecurity::PasskeyRequire 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.");
}

[Top]



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
}

[Top]



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");
    }
});
FunctionReturns
isCapsLockOn()true if Caps Lock is active
isNumLockOn()true if Num Lock is active
isScrollLockOn()true if Scroll Lock is active

[Top]



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();
}
LevelOutput
HIDLogLevel::OffNo output (default)
HIDLogLevel::NormalConnection, pairing, and advertising events
HIDLogLevel::VerboseAll of the above, plus every HID report sent

[Top]



Platform Notes

PlatformPairingNotes
iOSAuto or passkeyWhen 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
AndroidAutoVendor quirks vary; Just Works works on most devices
macOSAuto or passkeyMay be asked to "Setup Keyboard", but canceling out of this setup appears to be harmless and tests still passed
Windows 10/11Auto or passkeyCaches HID descriptor — fully unpair before flashing a new descriptor during development
Linux (BlueZ)Auto or passkeyInitial 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!

Securely Donate with Stripe

If you are intending to use this library in a commercial product, your support is expected.


My other projects you might like:

HijelHub/HijelHID_BLEMouse

A Bluetooth Low Energy HID Mouse library for ESP32

HijelHub/HijelHub_Dashboard

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.