Ryan Malloy 9a8eae1d2f 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.
2026-02-03 14:07:35 -07:00

556 lines
19 KiB
C

/*
* 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;
}