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:
parent
61da375a0c
commit
9a8eae1d2f
14
README.md
14
README.md
@ -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 |
|
||||
| **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 |
|
||||
| **Device Personas** | Presets for headset, speaker, keyboard, sensor, phone |
|
||||
| **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
|
||||
|
||||
@ -124,6 +125,17 @@ esp32_get_info() # → chip, fw_version, bt_mac
|
||||
| `esp32_spp_disconnect` | Close the active SPP connection |
|
||||
| `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
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
|
||||
@ -5,6 +5,7 @@ idf_component_register(
|
||||
"cmd_dispatcher.c"
|
||||
"bt_classic.c"
|
||||
"bt_ble.c"
|
||||
"bt_hid.c"
|
||||
"personas.c"
|
||||
"event_reporter.c"
|
||||
INCLUDE_DIRS "."
|
||||
|
||||
555
firmware/main/bt_hid.c
Normal file
555
firmware/main/bt_hid.c
Normal 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
24
firmware/main/bt_hid.h
Normal 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);
|
||||
@ -3,6 +3,7 @@
|
||||
#include "uart_handler.h"
|
||||
#include "bt_classic.h"
|
||||
#include "bt_ble.h"
|
||||
#include "bt_hid.h"
|
||||
#include "personas.h"
|
||||
|
||||
#include "esp_system.h"
|
||||
@ -161,6 +162,15 @@ static const cmd_entry_t cmd_table[] = {
|
||||
{ CMD_SPP_DISCONNECT, cmd_spp_disconnect },
|
||||
{ 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 */
|
||||
{ CMD_BLE_ENABLE, cmd_ble_enable },
|
||||
{ CMD_BLE_DISABLE, cmd_ble_disable },
|
||||
|
||||
@ -41,6 +41,15 @@
|
||||
#define CMD_SPP_DISCONNECT "spp_disconnect"
|
||||
#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 */
|
||||
#define CMD_BLE_ENABLE "ble_enable"
|
||||
#define CMD_BLE_DISABLE "ble_disable"
|
||||
@ -69,6 +78,11 @@
|
||||
#define EVT_SPP_CONNECT "spp_connect"
|
||||
#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 */
|
||||
#define IO_CAP_DISPLAY_ONLY "display_only"
|
||||
#define IO_CAP_DISPLAY_YESNO "display_yesno"
|
||||
|
||||
@ -3,9 +3,13 @@ CONFIG_BT_ENABLED=y
|
||||
CONFIG_BT_BLUEDROID_ENABLED=y
|
||||
CONFIG_BT_CLASSIC_ENABLED=y
|
||||
CONFIG_BT_BLE_ENABLED=y
|
||||
CONFIG_BT_A2DP_ENABLE=n
|
||||
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
|
||||
CONFIG_BT_GATTS_ENABLE=y
|
||||
CONFIG_BT_GATTC_ENABLE=n
|
||||
|
||||
@ -65,6 +65,15 @@ CMD_SPP_SEND = "spp_send"
|
||||
CMD_SPP_DISCONNECT = "spp_disconnect"
|
||||
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
|
||||
CMD_BLE_ENABLE = "ble_enable"
|
||||
CMD_BLE_DISABLE = "ble_disable"
|
||||
@ -93,6 +102,11 @@ EVT_SPP_DATA = "spp_data"
|
||||
EVT_SPP_CONNECT = "spp_connect"
|
||||
EVT_SPP_DISCONNECT = "spp_disconnect"
|
||||
|
||||
# HID Events
|
||||
EVT_HID_CONNECT = "hid_connect"
|
||||
EVT_HID_DISCONNECT = "hid_disconnect"
|
||||
EVT_HID_DATA = "hid_data"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -8,7 +8,7 @@ from fastmcp import FastMCP
|
||||
|
||||
from .resources import register_resources
|
||||
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(
|
||||
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
|
||||
Configure: esp32_configure, esp32_set_ssp_mode
|
||||
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
|
||||
Personas: esp32_load_persona, esp32_list_personas
|
||||
Events: esp32_get_events, esp32_wait_event, esp32_clear_events
|
||||
@ -43,6 +45,7 @@ register_resources(mcp)
|
||||
connection.register_tools(mcp)
|
||||
configure.register_tools(mcp)
|
||||
classic.register_tools(mcp)
|
||||
hid.register_tools(mcp)
|
||||
ble.register_tools(mcp)
|
||||
persona.register_tools(mcp)
|
||||
events.register_tools(mcp)
|
||||
|
||||
222
src/mcbluetooth_esp32/tools/hid.py
Normal file
222
src/mcbluetooth_esp32/tools/hid.py
Normal 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)}
|
||||
Loading…
x
Reference in New Issue
Block a user