diff --git a/README.md b/README.md index 7a2bd92..e921659 100644 --- a/README.md +++ b/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 | |------|-------------| diff --git a/firmware/main/CMakeLists.txt b/firmware/main/CMakeLists.txt index 6736c74..7181c13 100644 --- a/firmware/main/CMakeLists.txt +++ b/firmware/main/CMakeLists.txt @@ -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 "." diff --git a/firmware/main/bt_hid.c b/firmware/main/bt_hid.c new file mode 100644 index 0000000..5166867 --- /dev/null +++ b/firmware/main/bt_hid.c @@ -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 +#include + +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; +} diff --git a/firmware/main/bt_hid.h b/firmware/main/bt_hid.h new file mode 100644 index 0000000..131ff37 --- /dev/null +++ b/firmware/main/bt_hid.h @@ -0,0 +1,24 @@ +/* + * bt_hid.h -- Classic Bluetooth HID Device (keyboard/mouse emulation) + */ + +#pragma once + +#include +#include +#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); diff --git a/firmware/main/cmd_dispatcher.c b/firmware/main/cmd_dispatcher.c index fbfedff..4deb035 100644 --- a/firmware/main/cmd_dispatcher.c +++ b/firmware/main/cmd_dispatcher.c @@ -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 }, diff --git a/firmware/main/protocol.h b/firmware/main/protocol.h index a6e901b..86653de 100644 --- a/firmware/main/protocol.h +++ b/firmware/main/protocol.h @@ -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" diff --git a/firmware/sdkconfig.defaults b/firmware/sdkconfig.defaults index 51c20cf..b533614 100644 --- a/firmware/sdkconfig.defaults +++ b/firmware/sdkconfig.defaults @@ -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 diff --git a/src/mcbluetooth_esp32/protocol.py b/src/mcbluetooth_esp32/protocol.py index a4253ff..2e784f2 100644 --- a/src/mcbluetooth_esp32/protocol.py +++ b/src/mcbluetooth_esp32/protocol.py @@ -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 # --------------------------------------------------------------------------- diff --git a/src/mcbluetooth_esp32/server.py b/src/mcbluetooth_esp32/server.py index b594abd..187f783 100644 --- a/src/mcbluetooth_esp32/server.py +++ b/src/mcbluetooth_esp32/server.py @@ -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) diff --git a/src/mcbluetooth_esp32/tools/hid.py b/src/mcbluetooth_esp32/tools/hid.py new file mode 100644 index 0000000..4343b9b --- /dev/null +++ b/src/mcbluetooth_esp32/tools/hid.py @@ -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)}