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