A Bluetooth Low Energy (BLE) HID mouse library for ESP32, built on NimBLE-Arduino.
Turn your ESP32 into a BLE HID Mouse. Great for building a physical mouse device with real buttons and a sensor, or just emulating one.
Supports 3, 5, or 7(5+horizontal scroll) buttons, vertical scroll, horizontal scroll, and xy movement. Non-blocking at the base layer — all core input methods are safe to call directly from an interrupt handler.
The library automatically reduces BLE radio activity after 5 seconds of inactivity to save power, and instantly resumes full speed the moment input is sent.
Works with iOS, Android, macOS, Windows, and Linux.
Testing completed on:
iOS 26.3 - Fully Tested - Requires Assistive Touch to be On
macOS Ventura 13.7.8 - Fully Tested
Windows 11 Pro 25H2 - Fully Tested
Ubuntu 22.04.5 LTS - Fully Tested
| Requirement | Version | Tested On |
|---|---|---|
| ESP32 Arduino Core | 3.x.x | 3.3.7 |
| NimBLE-Arduino | >= 2.3.8 | 2.3.9 |
| 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 variants except ESP32-S2 and ESP32-P4 ]
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
#include <HijelHID_BLEMouse.h>
HijelBLEMouse mouse;
void setup() {
mouse.begin();
}
void loop() {
if (mouse.isPaired()) {
// Move the cursor right 100 units, then click left button
mouse.moveTo(100, 0);
mouse.click(MouseButton::Left);
delay(2000);
}
}This library has two layers. Pick the one that fits your project:
| Base Layer | Macro Layer | |
|---|---|---|
| Best for | Physical mouse devices (buttons wired to GPIO, sensor in an interrupt) | Mouse emulators (scripting movement and clicks in loop()) |
| ISR-safe | ✅ Yes — safe to call from an interrupt handler | ❌ No — call from loop() or a FreeRTOS task only |
| Blocking | ✅ Never blocks | |
| Movement | Single-report delta (±127 max per call) | Multi-report movement spread over time |
You can mix both layers freely — for example, use setButton() from an interrupt for buttons, and moveTo() from loop() for smooth cursor movement.
CLICK FOR API INDEX
Setup
Base Layer (ISR-safe — for physical mouse devices)
Macro Layer (for emulators and scripted input)
Configuration
Create a mouse object. All parameters are optional.
// Default — shows as "BLE Mouse" when pairing, 5 buttons, no horizontal scroll
HijelBLEMouse mouse;
// Custom name and manufacturer
HijelBLEMouse mouse("My Mouse", "My Company");
// Full options — 3-button mouse with horizontal scroll (tilt wheel)
HijelBLEMouse mouse("My Mouse", "My Company", 100, 3, true);| Parameter | Description | Default |
|---|---|---|
deviceName |
Name shown to the host when pairing | "BLE Mouse" |
manufacturer |
Manufacturer string reported to the host | "Hijel" |
batteryLevel |
Starting battery level (0–100) | 100 |
buttonCount |
Number of buttons — 3 or 5. Use 5 for Back/Forward buttons |
5 |
horizontalScroll |
true enables horizontal scroll (AC Pan tilt-wheel axis) |
false |
Call begin() once in setup(). The device will start advertising and be discoverable to any host.
void setup() {
mouse.begin();
}Use isConnected() to check if a host has connected (before pairing is complete), and isPaired() to check if the link is fully authenticated and ready to receive input. Input methods have no effect until isPaired() returns true.
void loop() {
if (mouse.isPaired()) {
mouse.moveTo(10, 0);
delay(100);
}
}| Method | Returns |
|---|---|
isConnected() |
true if a host has connected (before or after pairing) |
isPaired() |
true if the link is fully authenticated — input is active |
getIdleTime() |
Milliseconds since the last input call |
ISR-safe and non-blocking. Safe to call directly from an interrupt handler. Best suited for physical mouse devices where buttons and sensors trigger interrupts.
Send a single relative XY movement. Values are clamped to ±127 — anything outside that range is discarded. This sends in the next report only; for large movements spread over multiple reports, use moveTo() from the macro layer instead.
// Move right 50, down 30
mouse.move(50, 30);
// Move left 20, up 10
mouse.move(-20, -10);| Parameter | Description |
|---|---|
dx |
Horizontal delta. Positive = right, negative = left. Range: ±127 |
dy |
Vertical delta. Positive = down, negative = up. Range: ±127 |
Note
Using a sensor? move() accepts int16_t so you can pass raw sensor values directly without pre-clamping. Values outside ±127 are clamped silently.
Accumulate a scroll delta. The value is added to an internal accumulator and drained one chunk per report, so calling this rapidly from an interrupt will not lose scroll ticks.
// Scroll down 3 ticks
mouse.addScroll(-3);
// Scroll up 1 tick
mouse.addScroll(1);
// Horizontal scroll right (only works if horizontalScroll = true in constructor)
mouse.addScrollH(2);| Method | Parameter | Description |
|---|---|---|
addScroll(dz) |
int8_t |
Vertical scroll. Positive = up, negative = down |
addScrollH(dz) |
int8_t |
Horizontal scroll. Positive = right, negative = left. No effect if horizontalScroll is false |
Set or clear individual buttons. Button state persists until you explicitly change it — press sets it, release clears it. This is the right approach for physical buttons wired to GPIO pins.
// Press and hold left button
mouse.setButton(MouseButton::Left, true);
// Release left button
mouse.setButton(MouseButton::Left, false);
// Set multiple buttons at once using a bitmask
mouse.setButtons((uint8_t)MouseButton::Left | (uint8_t)MouseButton::Right);
// Release all buttons at once
mouse.setButtons(0x00);| Button Constant | Description | Requires |
|---|---|---|
MouseButton::Left |
Left button | — |
MouseButton::Right |
Right button | — |
MouseButton::Middle |
Middle button / scroll wheel click | — |
MouseButton::Back |
Browser back / back thumb button | buttonCount = 5 |
MouseButton::Forward |
Browser forward / forward thumb button | buttonCount = 5 |
Note
Typical interrupt pattern:
void ARDUINO_ISR_ATTR onLeftButtonChange() {
// true if button is pressed (pin LOW), false if released (pin HIGH)
bool pressed = (digitalRead(BTN_LEFT_PIN) == LOW);
mouse.setButton(MouseButton::Left, pressed);
}
void setup() {
pinMode(BTN_LEFT_PIN, INPUT_PULLUP);
attachInterrupt(BTN_LEFT_PIN, onLeftButtonChange, CHANGE);
mouse.begin();
}Not ISR-safe. Call from loop() or a FreeRTOS task. These methods handle timing and multi-report sequencing automatically. Best suited for mouse emulators and scripted input.
Move the cursor by a total of dx, dy spread across multiple HID reports. Blocks the calling task until the move is complete.
// Move 500 units right as fast as possible
mouse.moveTo(500, 0);
// Move diagonally over 1 second
mouse.moveTo(300, 200, 1000);
// Move up 100 units as fast as possible
mouse.moveTo(0, -100);| Parameter | Description | Default |
|---|---|---|
dx |
Total horizontal movement. Per Call Limit: ±32,767 | — |
dy |
Total vertical movement. Per Call Limit: ±32,767 | — |
durationMs |
Time to complete the move in milliseconds. 0 = as fast as possible |
0 |
How it works:
durationMs = 0— The move is broken into as many 127-unit chunks as needed and sent as fast as the report rate allows. A 500-unit move at 125Hz takes about 32ms.durationMs > 0— The total movement is spread evenly over the requested time. If the distance is very small relative to the duration, the library uses "spacing mode" — injecting ±1 pixel at regular intervals to fill the time.
Queue a large scroll value. The library drains it one chunk per report so the scroll feels smooth rather than jumping all at once.
// Scroll down 20 ticks smoothly
mouse.scroll(-20);
// Scroll up 10 ticks
mouse.scroll(10);
// Horizontal scroll right 5 ticks (requires horizontalScroll = true)
mouse.scrollH(5);| Method | Parameter | Description |
|---|---|---|
scroll(dz) |
int16_t |
Vertical scroll. Positive = up, negative = down |
scrollH(dz) |
int16_t |
Horizontal scroll. Positive = right, negative = left. No effect if horizontalScroll is false |
Hold a button down, release it, or release everything at once. You are responsible for adding delays between steps.
// Hold left button down, wait 500ms, then release
mouse.press(MouseButton::Left);
delay(500);
mouse.release(MouseButton::Left);
// Release everything — useful as a safety call
mouse.releaseAll();Tip
For a simple click, use click() instead — it handles press, delay, and release automatically.
Press and release a button automatically, with timing handled for you. Both methods block for their duration.
// Single left click
mouse.click(MouseButton::Left);
// Right click
mouse.click(MouseButton::Right);
// Left click with a longer hold time (50ms)
mouse.click(MouseButton::Left, 50);
// Double click with default timing
mouse.doubleClick(MouseButton::Left);
// Double click — 30ms hold, 80ms between clicks
mouse.doubleClick(MouseButton::Left, 30, 80);| Parameter | Description | Default |
|---|---|---|
button |
The button to click | — |
releaseDelay_ms |
Time in ms between press and release. 0 = two report intervals |
0 |
betweenDelay_ms |
(doubleClick only) Time in ms between the two clicks. 0 = two report intervals |
0 |
Note
Why two report intervals? A single BLE report interval (~8ms at 125Hz) is too short to guarantee the host sees the press and release as separate events. The two-interval default (~15ms) gives the host's BLE stack time to process both notifications. If clicks are missed on a particular platform, try increasing releaseDelay_ms to 20 or higher.
Controls how often the device sends reports to the host. The default of 125Hz (7.5ms interval) is the fastest BLE allows. Lower rates reduce CPU load and power draw.
// Set before or after begin()
mouse.setUpdateRate(HIDRate::Hz125); // 125 reports/sec — default
mouse.setUpdateRate(HIDRate::Hz100); // 100 reports/sec
mouse.setUpdateRate(HIDRate::Hz50); // 50 reports/sec
mouse.setUpdateRate(HIDRate::Hz25); // 25 reports/sec| Constant | Rate | BLE Interval |
|---|---|---|
HIDRate::Hz125 |
125 reports/sec | 7.5 ms (BLE minimum) — Default |
HIDRate::Hz100 |
100 reports/sec | 10 ms |
HIDRate::Hz50 |
50 reports/sec | 20 ms |
HIDRate::Hz25 |
25 reports/sec | 40 ms |
Report a battery percentage to the host at any time. The host displays this in its Bluetooth device list.
mouse.setBatteryLevel(85); // Report 85% batteryBy default the mouse pairs automatically with no passkey (Just Works). To require a numeric passkey challenge, call setSecurityMode() before begin().
void setup() {
Serial.begin(115200);
mouse.setSecurityMode(HIDSecurity::Passkey); // Must be before begin()
mouse.begin();
}| Mode | Behaviour |
|---|---|
HIDSecurity::JustWorks |
Auto-pair with no passcode — encrypted but no verification (default) |
HIDSecurity::Passkey |
Numeric comparison — a 6-digit code must be confirmed on both sides |
When Passkey mode is active, register a callback to display the code to the user. The device confirms automatically — the user only needs to confirm on the host side.
#include <HijelHID_BLEMouse.h>
HijelBLEMouse mouse;
// Called when a passkey needs to be shown to the user.
// Display it however suits your project — Serial, a screen, LEDs, etc.
// The user checks that this code matches what their host device is showing,
// then confirms on the host. No action is required in your sketch.
void onPassKey(uint32_t passkey) {
Serial.print("Check that this code matches your host device: ");
Serial.println(passkey);
}
void setup() {
Serial.begin(115200);
mouse.setSecurityMode(HIDSecurity::Passkey);
mouse.setPasskeyCallback(onPassKey);
mouse.begin();
}Use isBonded() to check whether bond data from a previous pairing is stored in flash. Use clearBonds() to erase it and force a fresh pair on the next connection.
// Check if a bond already exists
if (mouse.isBonded()) {
Serial.println("A bond is stored — host will reconnect automatically.");
} else {
Serial.println("No bond stored — waiting to pair...");
}
// Erase all stored bonds (forces a full re-pair on next connection)
mouse.clearBonds();Note
When to use clearBonds(): If you change buttonCount or horizontalScroll and reflash, the host may refuse to reconnect because its cached HID descriptor no longer matches. Call clearBonds() on the device and remove it from the host's Bluetooth settings, then re-pair from scratch.
The library handles BLE peripheral latency automatically. After 5 seconds with no input the connection interval relaxes to reduce radio wake-ups to roughly 1.6 times per second. The moment any input is sent the connection snaps back to the full report rate. No code is needed on your part.
Set the BLE radio transmit power, reduce to save energy when operating at close range. Valid levels are 1–8. Default is 8.
mouse.setTxPower(1); // -12 dBm — lowest power, shortest range
mouse.setTxPower(8); // +9 dBm — maximum power, longest range (default)Call beforeSleep() immediately before entering light sleep and afterWake() immediately after waking. The BLE stack is shut down cleanly and restarted on wake — the bonded host will reconnect automatically.
mouse.beforeSleep();
esp_light_sleep_start();
mouse.afterWake(); // restarts BLE — bonded host will auto-reconnectDeep sleep wipes RAM entirely, so no special library calls are needed before sleeping or after waking. Just call begin() in setup() as normal — the bonding data survives in NVS and the host will reconnect automatically.
// --- Before sleeping ---
esp_deep_sleep_start();
// --- On wake, setup() runs as normal ---
void setup() {
mouse.begin(); // bonded host will auto-reconnect
}Enable Serial logging to help diagnose connection or pairing issues. Call before begin().
void setup() {
Serial.begin(115200);
mouse.setLogLevel(HIDLogLevel::Normal); // connection and pairing events
mouse.begin();
}| Level | Output |
|---|---|
HIDLogLevel::Off |
No output (default) |
HIDLogLevel::Normal |
Connection, pairing, and sleep/wake events |
HIDLogLevel::Verbose |
All of the above, plus every HID report sent and every connection parameter update |
| Platform | Pairing | Notes |
|---|---|---|
| iOS | Auto or passkey | Requires activation of AssistiveTouch [Settings >> Accessibility >> Touch >> AssistiveTouch] |
| Android | Auto or passkey | Vendor quirks vary; Just Works works on most devices, Passkey on newer Android devices |
| macOS | Auto or passkey | Scroll wheel may need tuning due to OS quirks. |
| Windows 10/11 | Auto or passkey | Caches the HID descriptor. If you change buttonCount or horizontalScroll and reflash, fully remove and re-pair the device in Bluetooth settings |
| Linux (BlueZ) | Auto or passkey | Works via generic usbhid / BlueZ HID profile |
ESP32 stuck in a reboot loop
- Ensure you have NimBLE-Arduino 2.3.8 or later installed
Device not appearing in Bluetooth scan
- Make sure
mouse.begin()has been called insetup() - Check no previous bond is stored on the host — remove the device from Bluetooth settings and re-pair
Cursor not moving / buttons not responding
- Confirm
mouse.isPaired()returnstruebefore sending input — input methods have no effect until the link is fully authenticated - If you are calling base layer methods from an interrupt, make sure the function is marked with
ARDUINO_ISR_ATTR
Click not registering on some platforms
- Increase
releaseDelay_msinclick()/doubleClick()to20or higher - At slower report rates (25Hz / 50Hz) the default two-interval hold is already longer, so this is mainly a concern at 125Hz
Windows shows a ghost / duplicate device after reflashing
- Completely remove the device in Windows Bluetooth settings before flashing a new descriptor (any change to
buttonCountorhorizontalScrollchanges the HID descriptor)
Horizontal scroll has no effect
- Confirm
horizontalScroll = truewas passed to the constructor — it defaults tofalse - Use
scrollH()(macro layer) oraddScrollH()(base layer) —scroll()andaddScroll()are vertical only - Not all OS's / Apps accept or handle horizontal scroll in a manner you would expect.
- 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.
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.
A Bluetooth Low Energy HID Keyboard 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.