Add HID (Human Interface Device) profile support

Implements Classic Bluetooth HID Device profile for keyboard/mouse emulation:

Firmware:
- bt_hid.c/h: HID device driver with combo keyboard/mouse HID descriptor
- cmd_hid_{enable,disable,connect,disconnect}: HID lifecycle management
- cmd_hid_send_keyboard: Send keyboard reports (modifier + up to 6 keys)
- cmd_hid_send_mouse: Send mouse reports (buttons + relative X/Y)
- cmd_hid_status: Query HID state (enabled, registered, connected)

Python MCP tools:
- esp32_hid_enable/disable: Control HID device mode
- esp32_hid_connect/disconnect: Manage HID host connections
- esp32_hid_send_keyboard/send_mouse: Send HID reports
- esp32_hid_status: Get connection state

Config:
- Enable BT_HID_ENABLED + BT_HID_DEVICE_ENABLED in sdkconfig.defaults
- Add bt_hid.c to CMakeLists.txt

Tested E2E: Linux (hci1) connects to ESP32 HID device, keyboard and
mouse reports sent successfully.
This commit is contained in:
Ryan Malloy 2026-02-03 14:07:35 -07:00
parent 61da375a0c
commit 9a8eae1d2f
10 changed files with 862 additions and 3 deletions

View File

@ -35,10 +35,11 @@ The ESP32 can emulate various Bluetooth devices for testing:
|------------|-------------| |------------|-------------|
| **Classic BT Pairing** | All 4 SSP modes: Just Works, Numeric Comparison, Passkey Entry, Legacy PIN | | **Classic BT Pairing** | All 4 SSP modes: Just Works, Numeric Comparison, Passkey Entry, Legacy PIN |
| **SPP (Serial Port)** | Bidirectional data transfer over Classic Bluetooth virtual serial port | | **SPP (Serial Port)** | Bidirectional data transfer over Classic Bluetooth virtual serial port |
| **HID Device** | Keyboard/mouse combo emulation over Classic Bluetooth |
| **BLE GATT Server** | Dynamic service/characteristic creation at runtime | | **BLE GATT Server** | Dynamic service/characteristic creation at runtime |
| **Device Personas** | Presets for headset, speaker, keyboard, sensor, phone | | **Device Personas** | Presets for headset, speaker, keyboard, sensor, phone |
| **IO Capabilities** | Configurable: `no_io`, `display_only`, `display_yesno`, `keyboard_only`, `keyboard_display` | | **IO Capabilities** | Configurable: `no_io`, `display_only`, `display_yesno`, `keyboard_only`, `keyboard_display` |
| **Event Reporting** | Real-time events: pair_request, pair_complete, spp_data, gatt_read, gatt_write, gatt_subscribe | | **Event Reporting** | Real-time events: pair_request, pair_complete, spp_data, hid_connect, gatt_read, gatt_write |
### Hardware Requirements ### Hardware Requirements
@ -124,6 +125,17 @@ esp32_get_info() # → chip, fw_version, bt_mac
| `esp32_spp_disconnect` | Close the active SPP connection | | `esp32_spp_disconnect` | Close the active SPP connection |
| `esp32_spp_status` | Get SPP connection status and remote address | | `esp32_spp_status` | Get SPP connection status and remote address |
### HID (Human Interface Device)
| Tool | Description |
|------|-------------|
| `esp32_hid_enable` | Enable HID device (keyboard/mouse combo) |
| `esp32_hid_disable` | Disable HID device |
| `esp32_hid_connect` | Connect to a specific HID host |
| `esp32_hid_disconnect` | Disconnect from current host |
| `esp32_hid_send_keyboard` | Send keyboard report (keys + modifiers) |
| `esp32_hid_send_mouse` | Send mouse report (movement + buttons) |
| `esp32_hid_status` | Get HID connection status |
### BLE / GATT ### BLE / GATT
| Tool | Description | | Tool | Description |
|------|-------------| |------|-------------|

View File

@ -5,6 +5,7 @@ idf_component_register(
"cmd_dispatcher.c" "cmd_dispatcher.c"
"bt_classic.c" "bt_classic.c"
"bt_ble.c" "bt_ble.c"
"bt_hid.c"
"personas.c" "personas.c"
"event_reporter.c" "event_reporter.c"
INCLUDE_DIRS "." INCLUDE_DIRS "."

555
firmware/main/bt_hid.c Normal file
View File

@ -0,0 +1,555 @@
/*
* bt_hid.c -- Classic Bluetooth HID Device (keyboard/mouse emulation)
*
* Implements a combo HID device that can act as both keyboard and mouse.
* Uses ESP-IDF Bluedroid HID Device API.
*/
#include "bt_hid.h"
#include "protocol.h"
#include "uart_handler.h"
#include "event_reporter.h"
#include "esp_log.h"
#include "esp_bt.h"
#include "esp_bt_main.h"
#include "esp_bt_device.h"
#include "esp_gap_bt_api.h"
#include "esp_hidd_api.h"
#include <string.h>
#include <inttypes.h>
static const char *TAG = "bt_hid";
/* ------------------------------------------------------------------ */
/* HID Report Descriptor - Combo Keyboard + Mouse */
/* ------------------------------------------------------------------ */
/*
* This descriptor defines a combo device with:
* - Report ID 1: Keyboard (8-byte boot protocol compatible)
* - Report ID 2: Mouse (3-byte: buttons, X, Y)
*/
static const uint8_t hid_descriptor[] = {
/* Keyboard */
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x06, // Usage (Keyboard)
0xA1, 0x01, // Collection (Application)
0x85, 0x01, // Report ID (1)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0xE0, // Usage Minimum (224) - Left Control
0x29, 0xE7, // Usage Maximum (231) - Right GUI
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x75, 0x01, // Report Size (1)
0x95, 0x08, // Report Count (8)
0x81, 0x02, // Input (Data, Variable, Absolute) - Modifier byte
0x95, 0x01, // Report Count (1)
0x75, 0x08, // Report Size (8)
0x81, 0x01, // Input (Constant) - Reserved byte
0x95, 0x06, // Report Count (6)
0x75, 0x08, // Report Size (8)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x65, // Logical Maximum (101)
0x05, 0x07, // Usage Page (Key Codes)
0x19, 0x00, // Usage Minimum (0)
0x29, 0x65, // Usage Maximum (101)
0x81, 0x00, // Input (Data, Array) - Key array (6 keys)
0xC0, // End Collection
/* Mouse */
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x02, // Usage (Mouse)
0xA1, 0x01, // Collection (Application)
0x85, 0x02, // Report ID (2)
0x09, 0x01, // Usage (Pointer)
0xA1, 0x00, // Collection (Physical)
0x05, 0x09, // Usage Page (Buttons)
0x19, 0x01, // Usage Minimum (1)
0x29, 0x03, // Usage Maximum (3)
0x15, 0x00, // Logical Minimum (0)
0x25, 0x01, // Logical Maximum (1)
0x95, 0x03, // Report Count (3)
0x75, 0x01, // Report Size (1)
0x81, 0x02, // Input (Data, Variable, Absolute) - 3 buttons
0x95, 0x01, // Report Count (1)
0x75, 0x05, // Report Size (5)
0x81, 0x01, // Input (Constant) - Padding
0x05, 0x01, // Usage Page (Generic Desktop)
0x09, 0x30, // Usage (X)
0x09, 0x31, // Usage (Y)
0x15, 0x81, // Logical Minimum (-127)
0x25, 0x7F, // Logical Maximum (127)
0x75, 0x08, // Report Size (8)
0x95, 0x02, // Report Count (2)
0x81, 0x06, // Input (Data, Variable, Relative) - X, Y
0xC0, // End Collection
0xC0, // End Collection
};
/* ------------------------------------------------------------------ */
/* State */
/* ------------------------------------------------------------------ */
static struct {
bool enabled;
bool registered;
bool connected;
char remote_addr[18];
esp_hidd_protocol_mode_t protocol_mode;
} hid_state = {0};
/* ------------------------------------------------------------------ */
/* Helper: BD address to string */
/* ------------------------------------------------------------------ */
static void bd_addr_to_str(const esp_bd_addr_t bda, char *str)
{
snprintf(str, 18, "%02X:%02X:%02X:%02X:%02X:%02X",
bda[0], bda[1], bda[2], bda[3], bda[4], bda[5]);
}
/* ------------------------------------------------------------------ */
/* HID Device Callback */
/* ------------------------------------------------------------------ */
static void hidd_callback(esp_hidd_cb_event_t event, esp_hidd_cb_param_t *param)
{
char addr_str[18];
switch (event) {
case ESP_HIDD_INIT_EVT:
if (param->init.status == ESP_HIDD_SUCCESS) {
ESP_LOGI(TAG, "HID device initialized");
} else {
ESP_LOGE(TAG, "HID device init failed: %d", param->init.status);
}
break;
case ESP_HIDD_DEINIT_EVT:
ESP_LOGI(TAG, "HID device deinitialized");
hid_state.enabled = false;
hid_state.registered = false;
break;
case ESP_HIDD_REGISTER_APP_EVT:
if (param->register_app.status == ESP_HIDD_SUCCESS) {
ESP_LOGI(TAG, "HID app registered");
hid_state.registered = true;
if (param->register_app.in_use) {
bd_addr_to_str(param->register_app.bd_addr, addr_str);
ESP_LOGI(TAG, "Virtual cable to: %s", addr_str);
}
} else {
ESP_LOGE(TAG, "HID app register failed: %d", param->register_app.status);
}
break;
case ESP_HIDD_UNREGISTER_APP_EVT:
ESP_LOGI(TAG, "HID app unregistered");
hid_state.registered = false;
break;
case ESP_HIDD_OPEN_EVT:
if (param->open.status == ESP_HIDD_SUCCESS &&
param->open.conn_status == ESP_HIDD_CONN_STATE_CONNECTED) {
bd_addr_to_str(param->open.bd_addr, addr_str);
strncpy(hid_state.remote_addr, addr_str, sizeof(hid_state.remote_addr) - 1);
hid_state.connected = true;
ESP_LOGI(TAG, "HID connected to: %s", addr_str);
cJSON *d = cJSON_CreateObject();
cJSON_AddStringToObject(d, "address", addr_str);
cJSON_AddStringToObject(d, "transport", "hid");
event_report(EVT_HID_CONNECT, d);
}
break;
case ESP_HIDD_CLOSE_EVT:
ESP_LOGI(TAG, "HID disconnected (status=%d, conn_status=%d)",
param->close.status, param->close.conn_status);
{
cJSON *d = cJSON_CreateObject();
if (hid_state.remote_addr[0] != '\0') {
cJSON_AddStringToObject(d, "address", hid_state.remote_addr);
}
cJSON_AddStringToObject(d, "transport", "hid");
event_report(EVT_HID_DISCONNECT, d);
}
hid_state.connected = false;
hid_state.remote_addr[0] = '\0';
break;
case ESP_HIDD_SEND_REPORT_EVT:
if (param->send_report.status != ESP_HIDD_SUCCESS) {
ESP_LOGW(TAG, "Send report failed: %d (reason=%d)",
param->send_report.status, param->send_report.reason);
}
break;
case ESP_HIDD_SET_PROTOCOL_EVT:
hid_state.protocol_mode = param->set_protocol.protocol_mode;
ESP_LOGI(TAG, "Protocol mode set to: %s",
hid_state.protocol_mode == ESP_HIDD_BOOT_MODE ? "boot" : "report");
break;
case ESP_HIDD_INTR_DATA_EVT:
/* Host sent data to us (e.g., LED state for keyboard) */
ESP_LOGI(TAG, "Received data from host: report_id=%d, len=%d",
param->intr_data.report_id, param->intr_data.len);
{
cJSON *d = cJSON_CreateObject();
cJSON_AddNumberToObject(d, "report_id", param->intr_data.report_id);
cJSON_AddNumberToObject(d, "length", param->intr_data.len);
/* Encode as hex */
if (param->intr_data.len > 0 && param->intr_data.len < 64) {
char hex[129];
for (int i = 0; i < param->intr_data.len; i++) {
sprintf(hex + i * 2, "%02x", param->intr_data.data[i]);
}
hex[param->intr_data.len * 2] = '\0';
cJSON_AddStringToObject(d, "data_hex", hex);
}
event_report(EVT_HID_DATA, d);
}
break;
default:
ESP_LOGD(TAG, "HID event: %d", event);
break;
}
}
/* ------------------------------------------------------------------ */
/* Initialization */
/* ------------------------------------------------------------------ */
void bt_hid_init(void)
{
ESP_LOGI(TAG, "HID device module ready");
}
/* ------------------------------------------------------------------ */
/* Command: hid_enable */
/* ------------------------------------------------------------------ */
void cmd_hid_enable(const char *id, cJSON *params)
{
if (hid_state.enabled) {
uart_send_response(id, STATUS_OK, cJSON_CreateString("already enabled"));
return;
}
/* Register callback */
esp_err_t err = esp_bt_hid_device_register_callback(hidd_callback);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to register HID callback: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err)));
return;
}
/* Initialize HID device */
err = esp_bt_hid_device_init();
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to init HID device: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err)));
return;
}
/* Register app with HID descriptor */
esp_hidd_app_param_t app_param = {
.name = "ESP32 HID",
.description = "Keyboard/Mouse Combo",
.provider = "mcbluetooth-esp32",
.subclass = ESP_HID_CLASS_COM, /* Combo keyboard/mouse */
.desc_list = (uint8_t *)hid_descriptor,
.desc_list_len = sizeof(hid_descriptor),
};
esp_hidd_qos_param_t qos = {
.service_type = 0x01, /* Best effort */
.token_rate = 0,
.token_bucket_size = 0,
.peak_bandwidth = 0,
.access_latency = 0,
.delay_variation = 0,
};
err = esp_bt_hid_device_register_app(&app_param, &qos, &qos);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to register HID app: %s", esp_err_to_name(err));
esp_bt_hid_device_deinit();
uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err)));
return;
}
hid_state.enabled = true;
hid_state.protocol_mode = ESP_HIDD_REPORT_MODE;
ESP_LOGI(TAG, "HID device enabled (keyboard/mouse combo)");
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "enabled", true);
cJSON_AddStringToObject(data, "device_class", "combo");
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* Command: hid_disable */
/* ------------------------------------------------------------------ */
void cmd_hid_disable(const char *id, cJSON *params)
{
(void)params;
if (!hid_state.enabled) {
uart_send_response(id, STATUS_OK, cJSON_CreateString("already disabled"));
return;
}
if (hid_state.connected) {
esp_bt_hid_device_disconnect();
}
if (hid_state.registered) {
esp_bt_hid_device_unregister_app();
}
esp_bt_hid_device_deinit();
hid_state.enabled = false;
hid_state.registered = false;
hid_state.connected = false;
ESP_LOGI(TAG, "HID device disabled");
uart_send_response(id, STATUS_OK, NULL);
}
/* ------------------------------------------------------------------ */
/* Command: hid_connect - Initiate connection to a host */
/* ------------------------------------------------------------------ */
void cmd_hid_connect(const char *id, cJSON *params)
{
if (!hid_state.enabled) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("HID not enabled"));
return;
}
cJSON *j_addr = cJSON_GetObjectItem(params, "address");
if (!cJSON_IsString(j_addr)) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("missing 'address'"));
return;
}
/* Parse address */
esp_bd_addr_t bd_addr;
unsigned int addr[6];
if (sscanf(j_addr->valuestring, "%02X:%02X:%02X:%02X:%02X:%02X",
&addr[0], &addr[1], &addr[2], &addr[3], &addr[4], &addr[5]) != 6) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("invalid address format"));
return;
}
for (int i = 0; i < 6; i++) bd_addr[i] = (uint8_t)addr[i];
esp_err_t err = esp_bt_hid_device_connect(bd_addr);
if (err != ESP_OK) {
ESP_LOGE(TAG, "HID connect failed: %s", esp_err_to_name(err));
uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err)));
return;
}
ESP_LOGI(TAG, "HID connecting to %s", j_addr->valuestring);
uart_send_response(id, STATUS_OK, cJSON_CreateString("connecting"));
}
/* ------------------------------------------------------------------ */
/* Command: hid_disconnect */
/* ------------------------------------------------------------------ */
void cmd_hid_disconnect(const char *id, cJSON *params)
{
(void)params;
if (!hid_state.enabled) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("HID not enabled"));
return;
}
if (!hid_state.connected) {
uart_send_response(id, STATUS_OK, cJSON_CreateString("not connected"));
return;
}
esp_err_t err = esp_bt_hid_device_disconnect();
if (err != ESP_OK) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err)));
return;
}
uart_send_response(id, STATUS_OK, NULL);
}
/* ------------------------------------------------------------------ */
/* Command: hid_send_keyboard - Send keyboard report */
/* ------------------------------------------------------------------ */
void cmd_hid_send_keyboard(const char *id, cJSON *params)
{
if (!hid_state.enabled) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("HID not enabled"));
return;
}
if (!hid_state.connected) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("not connected"));
return;
}
/*
* Keyboard report format (8 bytes):
* [0] Modifier keys (bit flags: Ctrl, Shift, Alt, GUI - left/right)
* [1] Reserved (always 0)
* [2-7] Key codes (up to 6 simultaneous keys)
*
* Modifier bits: 0x01=LCtrl, 0x02=LShift, 0x04=LAlt, 0x08=LGUI,
* 0x10=RCtrl, 0x20=RShift, 0x40=RAlt, 0x80=RGUI
*/
uint8_t report[8] = {0};
cJSON *j_modifier = cJSON_GetObjectItem(params, "modifier");
if (cJSON_IsNumber(j_modifier)) {
report[0] = (uint8_t)j_modifier->valuedouble;
}
cJSON *j_keys = cJSON_GetObjectItem(params, "keys");
if (cJSON_IsArray(j_keys)) {
int key_count = cJSON_GetArraySize(j_keys);
if (key_count > 6) key_count = 6;
for (int i = 0; i < key_count; i++) {
cJSON *key = cJSON_GetArrayItem(j_keys, i);
if (cJSON_IsNumber(key)) {
report[2 + i] = (uint8_t)key->valuedouble;
}
}
}
/* Single key shorthand */
cJSON *j_key = cJSON_GetObjectItem(params, "key");
if (cJSON_IsNumber(j_key)) {
report[2] = (uint8_t)j_key->valuedouble;
}
esp_err_t err = esp_bt_hid_device_send_report(
ESP_HIDD_REPORT_TYPE_INTRDATA, 0x01, sizeof(report), report);
if (err != ESP_OK) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err)));
return;
}
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "modifier", report[0]);
cJSON *keys_arr = cJSON_CreateArray();
for (int i = 0; i < 6; i++) {
if (report[2 + i] != 0) {
cJSON_AddItemToArray(keys_arr, cJSON_CreateNumber(report[2 + i]));
}
}
cJSON_AddItemToObject(data, "keys", keys_arr);
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* Command: hid_send_mouse - Send mouse report */
/* ------------------------------------------------------------------ */
void cmd_hid_send_mouse(const char *id, cJSON *params)
{
if (!hid_state.enabled) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("HID not enabled"));
return;
}
if (!hid_state.connected) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString("not connected"));
return;
}
/*
* Mouse report format (3 bytes):
* [0] Buttons (bit 0=left, bit 1=right, bit 2=middle)
* [1] X movement (-127 to +127)
* [2] Y movement (-127 to +127)
*/
uint8_t report[3] = {0};
cJSON *j_buttons = cJSON_GetObjectItem(params, "buttons");
if (cJSON_IsNumber(j_buttons)) {
report[0] = (uint8_t)j_buttons->valuedouble;
}
cJSON *j_x = cJSON_GetObjectItem(params, "x");
if (cJSON_IsNumber(j_x)) {
int x = (int)j_x->valuedouble;
if (x < -127) x = -127;
if (x > 127) x = 127;
report[1] = (int8_t)x;
}
cJSON *j_y = cJSON_GetObjectItem(params, "y");
if (cJSON_IsNumber(j_y)) {
int y = (int)j_y->valuedouble;
if (y < -127) y = -127;
if (y > 127) y = 127;
report[2] = (int8_t)y;
}
esp_err_t err = esp_bt_hid_device_send_report(
ESP_HIDD_REPORT_TYPE_INTRDATA, 0x02, sizeof(report), report);
if (err != ESP_OK) {
uart_send_response(id, STATUS_ERROR, cJSON_CreateString(esp_err_to_name(err)));
return;
}
cJSON *data = cJSON_CreateObject();
cJSON_AddNumberToObject(data, "buttons", report[0]);
cJSON_AddNumberToObject(data, "x", (int8_t)report[1]);
cJSON_AddNumberToObject(data, "y", (int8_t)report[2]);
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* Command: hid_status */
/* ------------------------------------------------------------------ */
void cmd_hid_status(const char *id, cJSON *params)
{
(void)params;
cJSON *data = cJSON_CreateObject();
cJSON_AddBoolToObject(data, "enabled", hid_state.enabled);
cJSON_AddBoolToObject(data, "registered", hid_state.registered);
cJSON_AddBoolToObject(data, "connected", hid_state.connected);
if (hid_state.connected && hid_state.remote_addr[0] != '\0') {
cJSON_AddStringToObject(data, "remote_address", hid_state.remote_addr);
}
cJSON_AddStringToObject(data, "protocol_mode",
hid_state.protocol_mode == ESP_HIDD_BOOT_MODE ? "boot" : "report");
uart_send_response(id, STATUS_OK, data);
}
/* ------------------------------------------------------------------ */
/* State queries */
/* ------------------------------------------------------------------ */
bool bt_hid_is_enabled(void)
{
return hid_state.enabled;
}
bool bt_hid_is_connected(void)
{
return hid_state.connected;
}

24
firmware/main/bt_hid.h Normal file
View File

@ -0,0 +1,24 @@
/*
* bt_hid.h -- Classic Bluetooth HID Device (keyboard/mouse emulation)
*/
#pragma once
#include <stdbool.h>
#include <stdint.h>
#include "cJSON.h"
void bt_hid_init(void);
/* Command handlers (dispatched from cmd_dispatcher) */
void cmd_hid_enable(const char *id, cJSON *params);
void cmd_hid_disable(const char *id, cJSON *params);
void cmd_hid_connect(const char *id, cJSON *params);
void cmd_hid_disconnect(const char *id, cJSON *params);
void cmd_hid_send_keyboard(const char *id, cJSON *params);
void cmd_hid_send_mouse(const char *id, cJSON *params);
void cmd_hid_status(const char *id, cJSON *params);
/* State query */
bool bt_hid_is_enabled(void);
bool bt_hid_is_connected(void);

View File

@ -3,6 +3,7 @@
#include "uart_handler.h" #include "uart_handler.h"
#include "bt_classic.h" #include "bt_classic.h"
#include "bt_ble.h" #include "bt_ble.h"
#include "bt_hid.h"
#include "personas.h" #include "personas.h"
#include "esp_system.h" #include "esp_system.h"
@ -161,6 +162,15 @@ static const cmd_entry_t cmd_table[] = {
{ CMD_SPP_DISCONNECT, cmd_spp_disconnect }, { CMD_SPP_DISCONNECT, cmd_spp_disconnect },
{ CMD_SPP_STATUS, cmd_spp_status }, { CMD_SPP_STATUS, cmd_spp_status },
/* HID */
{ CMD_HID_ENABLE, cmd_hid_enable },
{ CMD_HID_DISABLE, cmd_hid_disable },
{ CMD_HID_CONNECT, cmd_hid_connect },
{ CMD_HID_DISCONNECT, cmd_hid_disconnect },
{ CMD_HID_SEND_KEYBOARD, cmd_hid_send_keyboard },
{ CMD_HID_SEND_MOUSE, cmd_hid_send_mouse },
{ CMD_HID_STATUS, cmd_hid_status },
/* BLE */ /* BLE */
{ CMD_BLE_ENABLE, cmd_ble_enable }, { CMD_BLE_ENABLE, cmd_ble_enable },
{ CMD_BLE_DISABLE, cmd_ble_disable }, { CMD_BLE_DISABLE, cmd_ble_disable },

View File

@ -41,6 +41,15 @@
#define CMD_SPP_DISCONNECT "spp_disconnect" #define CMD_SPP_DISCONNECT "spp_disconnect"
#define CMD_SPP_STATUS "spp_status" #define CMD_SPP_STATUS "spp_status"
/* HID commands */
#define CMD_HID_ENABLE "hid_enable"
#define CMD_HID_DISABLE "hid_disable"
#define CMD_HID_CONNECT "hid_connect"
#define CMD_HID_DISCONNECT "hid_disconnect"
#define CMD_HID_SEND_KEYBOARD "hid_send_keyboard"
#define CMD_HID_SEND_MOUSE "hid_send_mouse"
#define CMD_HID_STATUS "hid_status"
/* BLE commands */ /* BLE commands */
#define CMD_BLE_ENABLE "ble_enable" #define CMD_BLE_ENABLE "ble_enable"
#define CMD_BLE_DISABLE "ble_disable" #define CMD_BLE_DISABLE "ble_disable"
@ -69,6 +78,11 @@
#define EVT_SPP_CONNECT "spp_connect" #define EVT_SPP_CONNECT "spp_connect"
#define EVT_SPP_DISCONNECT "spp_disconnect" #define EVT_SPP_DISCONNECT "spp_disconnect"
/* HID events */
#define EVT_HID_CONNECT "hid_connect"
#define EVT_HID_DISCONNECT "hid_disconnect"
#define EVT_HID_DATA "hid_data"
/* SSP IO capabilities */ /* SSP IO capabilities */
#define IO_CAP_DISPLAY_ONLY "display_only" #define IO_CAP_DISPLAY_ONLY "display_only"
#define IO_CAP_DISPLAY_YESNO "display_yesno" #define IO_CAP_DISPLAY_YESNO "display_yesno"

View File

@ -3,9 +3,13 @@ CONFIG_BT_ENABLED=y
CONFIG_BT_BLUEDROID_ENABLED=y CONFIG_BT_BLUEDROID_ENABLED=y
CONFIG_BT_CLASSIC_ENABLED=y CONFIG_BT_CLASSIC_ENABLED=y
CONFIG_BT_BLE_ENABLED=y CONFIG_BT_BLE_ENABLED=y
CONFIG_BT_A2DP_ENABLE=n
CONFIG_BT_SPP_ENABLED=y CONFIG_BT_SPP_ENABLED=y
# Classic BT Profiles
CONFIG_BT_HID_ENABLED=y
CONFIG_BT_HID_DEVICE_ENABLED=y
CONFIG_BT_A2DP_ENABLE=n
# GAP & GATTS # GAP & GATTS
CONFIG_BT_GATTS_ENABLE=y CONFIG_BT_GATTS_ENABLE=y
CONFIG_BT_GATTC_ENABLE=n CONFIG_BT_GATTC_ENABLE=n

View File

@ -65,6 +65,15 @@ CMD_SPP_SEND = "spp_send"
CMD_SPP_DISCONNECT = "spp_disconnect" CMD_SPP_DISCONNECT = "spp_disconnect"
CMD_SPP_STATUS = "spp_status" CMD_SPP_STATUS = "spp_status"
# HID (Human Interface Device)
CMD_HID_ENABLE = "hid_enable"
CMD_HID_DISABLE = "hid_disable"
CMD_HID_CONNECT = "hid_connect"
CMD_HID_DISCONNECT = "hid_disconnect"
CMD_HID_SEND_KEYBOARD = "hid_send_keyboard"
CMD_HID_SEND_MOUSE = "hid_send_mouse"
CMD_HID_STATUS = "hid_status"
# BLE # BLE
CMD_BLE_ENABLE = "ble_enable" CMD_BLE_ENABLE = "ble_enable"
CMD_BLE_DISABLE = "ble_disable" CMD_BLE_DISABLE = "ble_disable"
@ -93,6 +102,11 @@ EVT_SPP_DATA = "spp_data"
EVT_SPP_CONNECT = "spp_connect" EVT_SPP_CONNECT = "spp_connect"
EVT_SPP_DISCONNECT = "spp_disconnect" EVT_SPP_DISCONNECT = "spp_disconnect"
# HID Events
EVT_HID_CONNECT = "hid_connect"
EVT_HID_DISCONNECT = "hid_disconnect"
EVT_HID_DATA = "hid_data"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Protocol constants # Protocol constants
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@ -8,7 +8,7 @@ from fastmcp import FastMCP
from .resources import register_resources from .resources import register_resources
from .serial_client import init_client from .serial_client import init_client
from .tools import ble, classic, configure, connection, events, persona from .tools import ble, classic, configure, connection, events, hid, persona
mcp = FastMCP( mcp = FastMCP(
name="mcbluetooth-esp32", name="mcbluetooth-esp32",
@ -26,6 +26,8 @@ Bluetooth peripheral for E2E testing with mcbluetooth (Linux BlueZ).
Connection: esp32_connect, esp32_disconnect, esp32_status, esp32_ping, esp32_reset Connection: esp32_connect, esp32_disconnect, esp32_status, esp32_ping, esp32_reset
Configure: esp32_configure, esp32_set_ssp_mode Configure: esp32_configure, esp32_set_ssp_mode
Classic BT: esp32_classic_enable, esp32_classic_set_discoverable, esp32_classic_pair_respond Classic BT: esp32_classic_enable, esp32_classic_set_discoverable, esp32_classic_pair_respond
SPP: esp32_spp_send, esp32_spp_disconnect, esp32_spp_status
HID: esp32_hid_enable, esp32_hid_send_keyboard, esp32_hid_send_mouse, esp32_hid_status
BLE: esp32_ble_advertise, esp32_gatt_add_service, esp32_gatt_add_characteristic, esp32_gatt_set_value, esp32_gatt_notify BLE: esp32_ble_advertise, esp32_gatt_add_service, esp32_gatt_add_characteristic, esp32_gatt_set_value, esp32_gatt_notify
Personas: esp32_load_persona, esp32_list_personas Personas: esp32_load_persona, esp32_list_personas
Events: esp32_get_events, esp32_wait_event, esp32_clear_events Events: esp32_get_events, esp32_wait_event, esp32_clear_events
@ -43,6 +45,7 @@ register_resources(mcp)
connection.register_tools(mcp) connection.register_tools(mcp)
configure.register_tools(mcp) configure.register_tools(mcp)
classic.register_tools(mcp) classic.register_tools(mcp)
hid.register_tools(mcp)
ble.register_tools(mcp) ble.register_tools(mcp)
persona.register_tools(mcp) persona.register_tools(mcp)
events.register_tools(mcp) events.register_tools(mcp)

View File

@ -0,0 +1,222 @@
"""HID (Human Interface Device) tools for ESP32 MCP server.
Provides keyboard and mouse emulation over Classic Bluetooth.
"""
from __future__ import annotations
from typing import Any
from fastmcp import FastMCP
from ..protocol import (
CMD_HID_ENABLE,
CMD_HID_DISABLE,
CMD_HID_CONNECT,
CMD_HID_DISCONNECT,
CMD_HID_SEND_KEYBOARD,
CMD_HID_SEND_MOUSE,
CMD_HID_STATUS,
Status,
)
from ..serial_client import get_client
def register_tools(mcp: FastMCP) -> None:
"""Register HID tools with the MCP server."""
@mcp.tool()
async def esp32_hid_enable() -> dict[str, Any]:
"""Enable HID (Human Interface Device) mode on the ESP32.
Initializes the ESP32 as a Bluetooth HID device that can act as
a keyboard and/or mouse. After enabling, the device will be
discoverable and can be paired with a host (computer, phone, etc.).
The ESP32 emulates a combo HID device supporting both keyboard
and mouse reports.
Returns:
Response with enabled status and device class.
"""
try:
client = get_client()
response = await client.send_command(CMD_HID_ENABLE)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_hid_disable() -> dict[str, Any]:
"""Disable HID mode on the ESP32.
Disconnects any active HID connection and unregisters the HID
device from the Bluetooth stack.
Returns:
Response confirming HID is disabled.
"""
try:
client = get_client()
response = await client.send_command(CMD_HID_DISABLE)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_hid_connect(address: str) -> dict[str, Any]:
"""Initiate HID connection to a specific host.
Connects to a previously paired host. The host must have the
ESP32 HID device paired and be ready to accept connections.
Args:
address: Bluetooth address of the host (e.g., "AA:BB:CC:DD:EE:FF").
Returns:
Response with connection status.
"""
try:
client = get_client()
response = await client.send_command(CMD_HID_CONNECT, {"address": address})
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_hid_disconnect() -> dict[str, Any]:
"""Disconnect from the current HID host.
Terminates the active HID connection but keeps HID mode enabled.
The device can reconnect or accept new connections.
Returns:
Response confirming disconnection.
"""
try:
client = get_client()
response = await client.send_command(CMD_HID_DISCONNECT)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_hid_send_keyboard(
key: int | None = None,
keys: list[int] | None = None,
modifier: int = 0,
) -> dict[str, Any]:
"""Send a keyboard report to the connected HID host.
Sends key press information. To release keys, send a report with
no keys pressed (key=None, keys=[]).
Args:
key: Single key code (USB HID usage code, e.g., 4='a', 5='b').
See USB HID Usage Tables for key codes.
keys: List of up to 6 simultaneous key codes.
modifier: Modifier key bitmask:
- 0x01: Left Control
- 0x02: Left Shift
- 0x04: Left Alt
- 0x08: Left GUI (Windows/Command)
- 0x10: Right Control
- 0x20: Right Shift
- 0x40: Right Alt
- 0x80: Right GUI
Returns:
Response with the sent report details.
Example:
# Press 'a' key
esp32_hid_send_keyboard(key=4)
# Release all keys
esp32_hid_send_keyboard()
# Press Ctrl+C
esp32_hid_send_keyboard(key=6, modifier=0x01)
"""
try:
client = get_client()
params: dict[str, Any] = {"modifier": modifier}
if key is not None:
params["key"] = key
if keys is not None:
params["keys"] = keys
response = await client.send_command(CMD_HID_SEND_KEYBOARD, params)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_hid_send_mouse(
x: int = 0,
y: int = 0,
buttons: int = 0,
) -> dict[str, Any]:
"""Send a mouse report to the connected HID host.
Sends relative mouse movement and button state.
Args:
x: Relative X movement (-127 to +127). Positive = right.
y: Relative Y movement (-127 to +127). Positive = down.
buttons: Button state bitmask:
- 0x01: Left button
- 0x02: Right button
- 0x04: Middle button
Returns:
Response with the sent report details.
Example:
# Move mouse right 50 pixels
esp32_hid_send_mouse(x=50)
# Click left button
esp32_hid_send_mouse(buttons=0x01)
# Release button
esp32_hid_send_mouse(buttons=0)
"""
try:
client = get_client()
params = {"x": x, "y": y, "buttons": buttons}
response = await client.send_command(CMD_HID_SEND_MOUSE, params)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}
@mcp.tool()
async def esp32_hid_status() -> dict[str, Any]:
"""Get the current HID device status.
Returns information about whether HID is enabled, registered,
and connected, along with the remote host address if connected.
Returns:
Response with HID status including:
- enabled: Whether HID mode is active
- registered: Whether HID app is registered with Bluetooth stack
- connected: Whether connected to a host
- remote_address: Host address (if connected)
- protocol_mode: "boot" or "report" mode
"""
try:
client = get_client()
response = await client.send_command(CMD_HID_STATUS)
if response.status == Status.OK:
return {"status": "ok", **response.data}
return {"status": "error", "error": response.data.get("error", "unknown error")}
except Exception as exc:
return {"error": str(exc)}