#ifdef ESP_PLATFORM #include #endif #if defined(CONFIG_BT_NIMBLE_ENABLED) #include #include #include #include #include #include #include #include #include #include #include #include #ifdef min #undef min #endif #ifdef max #undef max #endif #include #include #include #include #include #include #include #include #include #include #define TAG "BtHidHost" #include namespace tt::bluetooth { static const auto LOGGER = Logger("BtHidHost"); // ---- Report type ---- enum class HidReportType : uint8_t { Unknown = 0, Keyboard, Mouse, Consumer }; struct HidHostInputRpt { uint16_t valHandle; uint16_t cccdHandle; uint16_t rptRefHandle; uint8_t reportId; HidReportType type; }; struct HidHostCtx { uint16_t connHandle = BLE_HS_CONN_HANDLE_NONE; uint16_t hidSvcStart = 0; uint16_t hidSvcEnd = 0; std::vector inputRpts; std::vector allChrDefHandles; int subscribeIdx = 0; int dscDiscIdx = 0; int rptRefReadIdx = 0; uint16_t rptMapHandle = 0; std::vector rptMap; bool securityInitiated = false; bool typeResolutionDone = false; bool readyBlockFired = false; bool encrypted = false; bool dscsDiscovered = false; lv_indev_t* kbIndev = nullptr; lv_indev_t* mouseIndev = nullptr; lv_obj_t* mouseCursor = nullptr; std::array peerAddr = {}; }; // ---- Globals ---- static std::unique_ptr hid_host_ctx; static QueueHandle_t hid_host_key_queue = nullptr; static uint8_t hid_host_prev_keys[6] = {}; static esp_timer_handle_t hid_enc_retry_timer = nullptr; static std::atomic hid_host_mouse_x{0}; static std::atomic hid_host_mouse_y{0}; static std::atomic hid_host_mouse_btn{false}; static std::atomic hid_host_mouse_active{false}; #define HID_HOST_KEY_QUEUE_SIZE 64 struct HidHostKeyEvt { uint32_t key; bool pressed; }; // ---- Forward declarations ---- static void hidHostSubscribeNext(HidHostCtx& ctx); static void hidHostStartRptRefRead(HidHostCtx& ctx); static void hidHostReadReportMap(HidHostCtx& ctx); static uint16_t getDescEndHandle(const HidHostCtx& ctx, uint16_t valHandle); // ---- Keycode mapping ---- static uint32_t hidHostMapKeycode(uint8_t mod, uint8_t kc) { bool shift = (mod & 0x22) != 0; switch (kc) { case 0x28: return LV_KEY_ENTER; case 0x29: return LV_KEY_ESC; case 0x2A: return LV_KEY_BACKSPACE; case 0x4C: return LV_KEY_DEL; case 0x2B: return shift ? (uint32_t)LV_KEY_PREV : (uint32_t)LV_KEY_NEXT; case 0x52: return LV_KEY_UP; case 0x51: return LV_KEY_DOWN; case 0x50: return LV_KEY_LEFT; case 0x4F: return LV_KEY_RIGHT; case 0x4A: return LV_KEY_HOME; case 0x4D: return LV_KEY_END; default: break; } if (kc >= 0x04 && kc <= 0x1D) { uint32_t c = static_cast('a' + (kc - 0x04)); return shift ? (c - 0x20) : c; } if (kc >= 0x1E && kc <= 0x27) { static const char nums[] = "1234567890"; static const char snums[] = "!@#$%^&*()"; int i = kc - 0x1E; return shift ? static_cast(snums[i]) : static_cast(nums[i]); } if (kc == 0x2C) return ' '; return 0; } static void hidHostKeyboardReadCb(lv_indev_t* /*indev*/, lv_indev_data_t* data) { if (!hid_host_key_queue) { data->state = LV_INDEV_STATE_RELEASED; return; } HidHostKeyEvt evt = {}; if (xQueueReceive(hid_host_key_queue, &evt, 0) == pdTRUE) { data->key = evt.key; data->state = evt.pressed ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; data->continue_reading = (uxQueueMessagesWaiting(hid_host_key_queue) > 0); } else { data->state = LV_INDEV_STATE_RELEASED; } } static void hidHostHandleKeyboardReportInternal(const uint8_t* data, uint16_t len, uint8_t expected_report_id); static void hidHostHandleMouseReportInternal(const uint8_t* data, uint16_t len, uint8_t expected_report_id); static void hidHostHandleKeyboardReport(const uint8_t* data, uint16_t len); static void hidHostHandleMouseReport(const uint8_t* data, uint16_t len); // ---- Early impls so other funcs can call them ---- static void hidHostHandleKeyboardReportInternal(const uint8_t* data, uint16_t len, uint8_t expected_report_id) { if (len < 3 || !hid_host_key_queue) return; // Strip leading Report ID if present (some HOGP combo devices prepend it) size_t offset = 0; if (expected_report_id != 0 && len >= 1 && data[0] == expected_report_id) { offset = 1; if (len - offset < 3) return; } const uint8_t* body = data + offset; uint16_t body_len = len - offset; uint8_t mod = body[0]; const uint8_t* curr = (body_len >= 2) ? &body[2] : body; int curr_offset = (body_len >= 2) ? 2 : 0; int nkeys = std::min((int)(body_len - curr_offset), 6); for (int i = 0; i < 6; i++) { uint8_t kc = hid_host_prev_keys[i]; if (kc == 0) continue; bool still = false; for (int j = 0; j < nkeys; j++) { if (curr[j] == kc) { still = true; break; } } if (!still) { uint32_t lv = hidHostMapKeycode(0, kc); if (lv) { HidHostKeyEvt e{lv, false}; xQueueSend(hid_host_key_queue, &e, 0); } } } for (int i = 0; i < nkeys; i++) { uint8_t kc = curr[i]; if (kc == 0) continue; bool had = false; for (int j = 0; j < 6; j++) { if (hid_host_prev_keys[j] == kc) { had = true; break; } } if (!had) { uint32_t lv = hidHostMapKeycode(mod, kc); if (lv) { HidHostKeyEvt e{lv, true}; xQueueSend(hid_host_key_queue, &e, 0); } } } std::memcpy(hid_host_prev_keys, curr, nkeys); if (nkeys < 6) std::memset(hid_host_prev_keys + nkeys, 0, 6 - nkeys); } static void hidHostHandleKeyboardReport(const uint8_t* data, uint16_t len) { hidHostHandleKeyboardReportInternal(data, len, 0); } static void hidHostMouseReadCb(lv_indev_t* /*indev*/, lv_indev_data_t* data) { int32_t cx = hid_host_mouse_x.load(); int32_t cy = hid_host_mouse_y.load(); lv_display_t* disp = lv_display_get_default(); if (disp) { int32_t ow = lv_display_get_original_horizontal_resolution(disp); int32_t oh = lv_display_get_original_vertical_resolution(disp); switch (lv_display_get_rotation(disp)) { case LV_DISPLAY_ROTATION_0: data->point.x = (lv_coord_t)cx; data->point.y = (lv_coord_t)cy; break; case LV_DISPLAY_ROTATION_90: data->point.x = (lv_coord_t)(ow - cy - 1); data->point.y = (lv_coord_t)cx; break; case LV_DISPLAY_ROTATION_180: data->point.x = (lv_coord_t)(ow - cx - 1); data->point.y = (lv_coord_t)(oh - cy - 1); break; case LV_DISPLAY_ROTATION_270: data->point.x = (lv_coord_t)cy; data->point.y = (lv_coord_t)(oh - cx - 1); break; } } else { data->point.x = (lv_coord_t)cx; data->point.y = (lv_coord_t)cy; } data->state = hid_host_mouse_btn.load() ? LV_INDEV_STATE_PRESSED : LV_INDEV_STATE_RELEASED; if (!hid_host_mouse_active.load()) { hid_host_mouse_active.store(true); if (hid_host_ctx && hid_host_ctx->mouseCursor) { lv_obj_remove_flag(hid_host_ctx->mouseCursor, LV_OBJ_FLAG_HIDDEN); } } } static void hidHostHandleMouseReportInternal(const uint8_t* data, uint16_t len, uint8_t expected_report_id) { int32_t dx = 0; int32_t dy = 0; bool btn = false; if (len == 0) return; // Strip Report ID prefix if present. Many combo HOGP devices (e.g. Fosmon // mini KB + touchpad: https://a.co/d/0fg4vprm) use Report IDs: // ID 1 = keyboard, ID 2 = mouse/touchpad. NimBLE notifies the raw char // value *including* the ID byte for some stacks. If first byte matches // the expected ID and remaining length looks like a mouse report, strip it. const uint8_t* p = data; uint16_t plen = len; if (expected_report_id != 0 && len >= 2 && data[0] == expected_report_id) { p = data + 1; plen = len - 1; } if (plen < 3) return; if (plen >= 5) { // Two possible interpretations for 5-byte reports: // A) Standard extended: [buttons, x8, y8, wheel, pan] // B) Logitech 12-bit packed: [buttons, ?, x_lo, x_hi_nibble|y_lo_nibble, y_hi] // // Heuristic to avoid breaking cheap touchpads (Fosmon/etc) which send (A): // - Try standard decode first: dx=(int8)p[1], dy=(int8)p[2] // - Try 12-bit decode: raw_x = p[2] | ((p[3]&0x0F)<<8), raw_y = (p[3]>>4)|(p[4]<<4) // If standard dx/dy are small (|dx|,|dy| <= 127 fits int8) and 12-bit result // would imply a huge jump (abs>200) while standard is modest (<50), prefer standard. // This keeps Logitech high-res mice working (they emit true 12-bit values where // standard decode looks like random / large wheel bytes) while not breaking // touchpads that emit [btn, x, y, wheel, 0]. int8_t std_dx = (int8_t)p[1]; int8_t std_dy = (int8_t)p[2]; int16_t raw_x12 = p[2] | ((p[3] & 0x0F) << 8); if (raw_x12 & 0x0800) raw_x12 |= 0xF000; int16_t raw_y12 = (int16_t)((p[3] >> 4) | (p[4] << 4)); if (raw_y12 & 0x0800) raw_y12 |= 0xF000; // Detect 12-bit: if wheel byte position (p[3]) in standard view looks like // plausible wheel (typically 0 or small) but 12-bit gives large coordinate, // AND standard dx/dy are within typical touchpad incremental range, use standard. bool std_small = (std::abs((int)std_dx) <= 48 && std::abs((int)std_dy) <= 48); bool s12_large = (std::abs((int)raw_x12) > 128 || std::abs((int)raw_y12) > 128); if (std_small && s12_large) { // Most likely NOT 12-bit packed, it's a standard report with wheel byte btn = (p[0] & 0x01) != 0; dx = std_dx; dy = std_dy; } else if (s12_large || !std_small) { // Could be either, but if standard dx/dy are huge maybe it's really 12-bit // Prefer 12-bit only when standard parse looks pathological AND 12-bit looks // like plausible incremental movement (abs <= 300) if (std::abs((int)raw_x12) <= 300 && std::abs((int)raw_y12) <= 300 && (std::abs((int)std_dx) > 64 || std::abs((int)std_dy) > 64 || p[1] == 0)) { btn = (p[0] & 0x01) != 0; dx = (int16_t)raw_x12; dy = (int16_t)raw_y12; } else { btn = (p[0] & 0x01) != 0; dx = std_dx; dy = std_dy; } } else { btn = (p[0] & 0x01) != 0; dx = std_dx; dy = std_dy; } } else { // 3 or 4 bytes: standard boot protocol btn = (p[0] & 0x01) != 0; dx = (int8_t)p[1]; dy = (int8_t)p[2]; } lv_display_t* disp = lv_display_get_default(); int32_t w = disp ? lv_display_get_horizontal_resolution(disp) : 320; int32_t h = disp ? lv_display_get_vertical_resolution(disp) : 240; int32_t nx = hid_host_mouse_x.load() + dx; int32_t ny = hid_host_mouse_y.load() + dy; if (nx < 0) nx = 0; if (nx >= w) nx = w - 1; if (ny < 0) ny = 0; if (ny >= h) ny = h - 1; hid_host_mouse_x.store(nx); hid_host_mouse_y.store(ny); hid_host_mouse_btn.store(btn); if (hid_host_ctx && hid_host_ctx->mouseIndev == nullptr) { getMainDispatcher().dispatch([] { if (!hid_host_ctx || hid_host_ctx->mouseIndev != nullptr) return; if (!tt::lvgl::lock(1000)) { LOGGER.warn("LVGL lock failed for mouse indev"); return; } auto* ms = lv_indev_create(); lv_indev_set_type(ms, LV_INDEV_TYPE_POINTER); lv_indev_set_read_cb(ms, hidHostMouseReadCb); auto* cur = lv_image_create(lv_layer_sys()); lv_obj_remove_flag(cur, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(cur, LV_OBJ_FLAG_HIDDEN); lv_image_set_src(cur, TT_ASSETS_UI_CURSOR); lv_indev_set_cursor(ms, cur); hid_host_ctx->mouseIndev = ms; hid_host_ctx->mouseCursor = cur; tt::lvgl::unlock(); LOGGER.info("Mouse indev registered"); }); } } static void hidHostHandleMouseReport(const uint8_t* data, uint16_t len) { hidHostHandleMouseReportInternal(data, len, 0); } // ---- Asynchronous coordination helpers ---- static void hidHostOnDscsDiscovered(HidHostCtx& ctx) { ctx.dscsDiscovered = true; if (ctx.encrypted) { ctx.rptRefReadIdx = 0; hidHostStartRptRefRead(ctx); } else { LOGGER.info("Descriptors discovered, waiting for encryption"); } } static void hidHostOnEncrypted(HidHostCtx& ctx) { ctx.encrypted = true; if (ctx.dscsDiscovered) { ctx.rptRefReadIdx = 0; hidHostStartRptRefRead(ctx); } else { LOGGER.info("Encryption established, waiting for descriptor discovery"); } } // ---- Timer callback for post-encryption CCCD retry ---- static void hidEncRetryTimerCb(void* /*arg*/) { if (hid_host_ctx) { LOGGER.info("CCCD delay complete — starting subscriptions"); hidHostSubscribeNext(*hid_host_ctx); } } // ---- Report Map parsing ---- static void applyReportMapTypes(HidHostCtx& ctx) { const uint8_t* data = ctx.rptMap.data(); size_t len = ctx.rptMap.size(); uint16_t usagePage = 0, usage = 0; uint8_t reportId = 0; int depth = 0; HidReportType collType = HidReportType::Unknown; struct Entry { uint8_t id; HidReportType type; }; std::vector typeMap; std::vector collOrder; bool collHadInput = false; size_t i = 0; while (i < len) { uint8_t prefix = data[i++]; if (prefix == 0xFE) { if (i + 1 >= len) break; uint8_t lsz = data[i++]; i++; i += lsz; continue; } uint8_t bSize = prefix & 0x03; uint8_t bType = (prefix >> 2) & 0x03; uint8_t bTag = (prefix >> 4) & 0x0F; uint8_t dataLen = (bSize == 3) ? 4 : bSize; if (i + dataLen > len) break; uint32_t value = 0; for (uint8_t j = 0; j < dataLen; j++) value |= (uint32_t)data[i++] << (8 * j); if (bType == 0) { if (bTag == 0xA) { if (depth == 0 && value == 0x01) { if (usagePage == 0x01 && usage == 0x06) collType = HidReportType::Keyboard; else if (usagePage == 0x01 && usage == 0x02) collType = HidReportType::Mouse; else if (usagePage == 0x0C) collType = HidReportType::Consumer; else collType = HidReportType::Unknown; collHadInput = false; } depth++; usage = 0; } else if (bTag == 0xC) { if (depth > 0) depth--; if (depth == 0) { collType = HidReportType::Unknown; collHadInput = false; } usage = 0; } else if (bTag == 0x8) { if (depth > 0 && collType != HidReportType::Unknown) { if (!collHadInput) { collOrder.push_back(collType); collHadInput = true; } if (reportId != 0) { bool found = false; for (const auto& e : typeMap) { if (e.id == reportId) { found = true; break; } } if (!found) typeMap.push_back({reportId, collType}); } } usage = 0; } else { usage = 0; } } else if (bType == 1) { if (bTag == 0x0) usagePage = (uint16_t)value; else if (bTag == 0x8) reportId = (uint8_t)value; } else if (bType == 2) { if (bTag == 0x0) usage = (uint16_t)value; } } bool anyNonZeroId = false; for (const auto& rpt : ctx.inputRpts) { if (rpt.reportId != 0) { anyNonZeroId = true; break; } } size_t zeroRptIdx = 0; for (auto& rpt : ctx.inputRpts) { if (anyNonZeroId) { for (const auto& e : typeMap) { if (e.id == rpt.reportId) { rpt.type = e.type; break; } } } else { if (zeroRptIdx < collOrder.size()) rpt.type = collOrder[zeroRptIdx]; zeroRptIdx++; } LOGGER.info("Report val_handle={} reportId={} type={}", rpt.valHandle, rpt.reportId, (int)rpt.type); } ctx.rptMap.clear(); } // ---- Report Reference read chain ---- static void hidHostStartRptRefRead(HidHostCtx& ctx) { while (ctx.rptRefReadIdx < (int)ctx.inputRpts.size() && ctx.inputRpts[ctx.rptRefReadIdx].rptRefHandle == 0) { ctx.rptRefReadIdx++; } if (ctx.rptRefReadIdx >= (int)ctx.inputRpts.size()) { hidHostReadReportMap(ctx); return; } uint16_t handle = ctx.inputRpts[ctx.rptRefReadIdx].rptRefHandle; int rc = ble_gattc_read(ctx.connHandle, handle, [](uint16_t conn_handle, const struct ble_gatt_error* error, struct ble_gatt_attr* attr, void* /*arg*/) -> int { if (!hid_host_ctx) return 0; auto& ctx = *hid_host_ctx; if (conn_handle != ctx.connHandle) return 0; if (error->status == BLE_HS_EDONE) return 0; if (error->status == 0 && attr != nullptr) { if (OS_MBUF_PKTLEN(attr->om) >= 2 && ctx.rptRefReadIdx < (int)ctx.inputRpts.size()) { uint8_t rpt_ref[2] = {}; os_mbuf_copydata(attr->om, 0, 2, rpt_ref); ctx.inputRpts[ctx.rptRefReadIdx].reportId = rpt_ref[0]; LOGGER.info("Report[{}] val_handle={} reportId={}", ctx.rptRefReadIdx, ctx.inputRpts[ctx.rptRefReadIdx].valHandle, rpt_ref[0]); } } ctx.rptRefReadIdx++; hidHostStartRptRefRead(ctx); return 0; }, nullptr); if (rc != 0) { LOGGER.warn("rptRef read[{}] failed rc={} — skipping", ctx.rptRefReadIdx, rc); ctx.rptRefReadIdx++; hidHostStartRptRefRead(ctx); } } // ---- Report Map read ---- static void hidHostReadReportMap(HidHostCtx& ctx) { if (ctx.rptMapHandle == 0) { LOGGER.info("No Report Map char — skipping type resolution"); ctx.typeResolutionDone = true; ctx.subscribeIdx = 0; if (hid_enc_retry_timer) { esp_timer_stop(hid_enc_retry_timer); esp_timer_start_once(hid_enc_retry_timer, 500 * 1000); } else { hidHostSubscribeNext(ctx); } return; } int rc = ble_gattc_read_long(ctx.connHandle, ctx.rptMapHandle, 0, [](uint16_t conn_handle, const struct ble_gatt_error* error, struct ble_gatt_attr* attr, void* /*arg*/) -> int { if (!hid_host_ctx) return 0; auto& ctx = *hid_host_ctx; if (conn_handle != ctx.connHandle) return 0; if (error->status == 0 && attr != nullptr) { uint16_t chunk = OS_MBUF_PKTLEN(attr->om); size_t old_sz = ctx.rptMap.size(); ctx.rptMap.resize(old_sz + chunk); os_mbuf_copydata(attr->om, 0, chunk, ctx.rptMap.data() + old_sz); return 0; } if (!ctx.rptMap.empty()) { LOGGER.info("Report map read ({} bytes)", ctx.rptMap.size()); applyReportMapTypes(ctx); } else { LOGGER.warn("Report map read failed — types remain Unknown"); } ctx.typeResolutionDone = true; ctx.subscribeIdx = 0; LOGGER.info("Type resolution complete — delaying CCCD subscriptions by 500ms"); if (hid_enc_retry_timer) { esp_timer_stop(hid_enc_retry_timer); esp_timer_start_once(hid_enc_retry_timer, 500 * 1000); } else { hidHostSubscribeNext(ctx); } return 0; }, nullptr); if (rc != 0) { LOGGER.warn("Report map read_long failed rc={} — skipping", rc); ctx.typeResolutionDone = true; ctx.subscribeIdx = 0; if (hid_enc_retry_timer) { esp_timer_stop(hid_enc_retry_timer); esp_timer_start_once(hid_enc_retry_timer, 500 * 1000); } else { hidHostSubscribeNext(ctx); } } } // ---- CCCD subscription chain ---- static int hidHostCccdWriteCb(uint16_t conn_handle, const struct ble_gatt_error* error, struct ble_gatt_attr* /*attr*/, void* /*arg*/) { if (!hid_host_ctx) return 0; auto& ctx = *hid_host_ctx; if (conn_handle != ctx.connHandle) return 0; if (error->status != 0 && error->status != BLE_HS_EDONE) { if ((error->status == BLE_HS_ATT_ERR(BLE_ATT_ERR_INSUFFICIENT_AUTHEN) || error->status == BLE_HS_ATT_ERR(BLE_ATT_ERR_INSUFFICIENT_ENC)) && !ctx.securityInitiated) { LOGGER.info("CCCD auth required — initiating security"); ctx.securityInitiated = true; ble_gap_security_initiate(conn_handle); return 0; } if (error->status == BLE_HS_ETIMEOUT) { LOGGER.warn("CCCD write timed out for report[{}] — skipping", ctx.subscribeIdx); ctx.subscribeIdx++; hidHostSubscribeNext(ctx); return 0; } if (error->status == BLE_HS_ENOTCONN) { LOGGER.warn("CCCD write failed — not connected"); return 0; } LOGGER.warn("CCCD write failed status={}", error->status); } ctx.subscribeIdx++; hidHostSubscribeNext(ctx); return 0; } static void hidHostSubscribeNext(HidHostCtx& ctx) { if (ctx.subscribeIdx >= (int)ctx.inputRpts.size()) { if (ctx.readyBlockFired) { LOGGER.info("Subscribe ready block already ran — ignoring duplicate"); return; } ctx.readyBlockFired = true; LOGGER.info("All {} reports subscribed — ready", ctx.inputRpts.size()); if (hid_enc_retry_timer) esp_timer_stop(hid_enc_retry_timer); if (!hid_host_key_queue) { hid_host_key_queue = xQueueCreate(HID_HOST_KEY_QUEUE_SIZE, sizeof(HidHostKeyEvt)); } getMainDispatcher().dispatch([] { if (!hid_host_ctx || hid_host_ctx->kbIndev != nullptr) return; bool has_keyboard = false; for (const auto& rpt : hid_host_ctx->inputRpts) { if (rpt.type == HidReportType::Keyboard || rpt.type == HidReportType::Unknown) { has_keyboard = true; break; } } if (!has_keyboard) { LOGGER.info("No keyboard reports found — skipping keyboard indev registration"); return; } if (!tt::lvgl::lock(1000)) { LOGGER.warn("LVGL lock failed for kb indev"); return; } auto* kb = lv_indev_create(); lv_indev_set_type(kb, LV_INDEV_TYPE_KEYPAD); lv_indev_set_read_cb(kb, hidHostKeyboardReadCb); hid_host_ctx->kbIndev = kb; tt::lvgl::hardware_keyboard_set_indev(kb); tt::lvgl::unlock(); LOGGER.info("Keyboard indev registered"); }); auto peer_addr = ctx.peerAddr; getMainDispatcher().dispatch([peer_addr] { // Find name from cached scan results std::string name; { auto results = getScanResults(); for (const auto& r : results) { if (r.addr == peer_addr) { name = r.name; break; } } } settings::PairedDevice device; device.addr = peer_addr; device.profileId = BT_PROFILE_HID_HOST; device.autoConnect = true; const auto addr_hex = settings::addrToHex(peer_addr); settings::PairedDevice existing; if (settings::load(addr_hex, existing)) { device.autoConnect = existing.autoConnect; } device.name = name; settings::save(device); if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { BtEvent e = {}; e.type = BT_EVENT_PROFILE_STATE_CHANGED; e.profile_state.state = BT_PROFILE_STATE_CONNECTED; e.profile_state.profile = BT_PROFILE_HID_HOST; bluetooth_fire_event(dev, e); } }); return; } auto& rpt = ctx.inputRpts[ctx.subscribeIdx]; if (rpt.cccdHandle == 0) { ctx.subscribeIdx++; hidHostSubscribeNext(ctx); return; } static const uint16_t notify_val = 0x0001; int rc = ble_gattc_write_flat(ctx.connHandle, rpt.cccdHandle, ¬ify_val, sizeof(notify_val), hidHostCccdWriteCb, nullptr); if (rc != 0) { LOGGER.warn("gattc_write_flat CCCD failed rc={}", rc); ctx.subscribeIdx++; hidHostSubscribeNext(ctx); } } // ---- Descriptor discovery ---- static int hidHostDscDiscCb(uint16_t conn_handle, const struct ble_gatt_error* error, uint16_t chr_val_handle, const struct ble_gatt_dsc* dsc, void* /*arg*/) { if (!hid_host_ctx) return 0; auto& ctx = *hid_host_ctx; if (conn_handle != ctx.connHandle) return 0; if (error->status == 0 && dsc != nullptr) { uint16_t dsc_uuid = ble_uuid_u16(&dsc->uuid.u); for (auto& rpt : ctx.inputRpts) { if (rpt.valHandle != chr_val_handle) continue; if (dsc_uuid == 0x2902) { rpt.cccdHandle = dsc->handle; } else if (dsc_uuid == 0x2908) { rpt.rptRefHandle = dsc->handle; } break; } } else if (error->status == BLE_HS_EDONE) { int next_idx = ctx.dscDiscIdx + 1; if (next_idx < (int)ctx.inputRpts.size()) { ctx.dscDiscIdx = next_idx; auto& next_rpt = ctx.inputRpts[next_idx]; uint16_t end = getDescEndHandle(ctx, next_rpt.valHandle); int rc = ble_gattc_disc_all_dscs(ctx.connHandle, next_rpt.valHandle, end, hidHostDscDiscCb, nullptr); if (rc != 0) { LOGGER.warn("disc_all_dscs[{}] failed rc={}", next_idx, rc); hidHostOnDscsDiscovered(ctx); } } else { hidHostOnDscsDiscovered(ctx); } } return 0; } static uint16_t getDescEndHandle(const HidHostCtx& ctx, uint16_t valHandle) { for (uint16_t dh : ctx.allChrDefHandles) { if (dh > valHandle) return dh - 1; } return ctx.hidSvcEnd; } // ---- Characteristic discovery ---- static int hidHostChrDiscCb(uint16_t conn_handle, const struct ble_gatt_error* error, const struct ble_gatt_chr* chr, void* /*arg*/) { if (!hid_host_ctx) return 0; auto& ctx = *hid_host_ctx; if (conn_handle != ctx.connHandle) return 0; if (error->status == 0 && chr != nullptr) { ctx.allChrDefHandles.push_back(chr->def_handle); uint16_t uuid16 = ble_uuid_u16(&chr->uuid.u); if (uuid16 == 0x2A4D && (chr->properties & BLE_GATT_CHR_PROP_NOTIFY)) { HidHostInputRpt rpt = {}; rpt.valHandle = chr->val_handle; ctx.inputRpts.push_back(rpt); LOGGER.info("Input Report chr val_handle={}", chr->val_handle); } else if (uuid16 == 0x2A4B) { ctx.rptMapHandle = chr->val_handle; } } else if (error->status == BLE_HS_EDONE) { std::sort(ctx.allChrDefHandles.begin(), ctx.allChrDefHandles.end()); if (ctx.inputRpts.empty()) { LOGGER.warn("No Input Report chars — disconnecting"); ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM); return 0; } ctx.dscDiscIdx = 0; auto& first = ctx.inputRpts[0]; uint16_t end = getDescEndHandle(ctx, first.valHandle); int rc = ble_gattc_disc_all_dscs(ctx.connHandle, first.valHandle, end, hidHostDscDiscCb, nullptr); if (rc != 0) { LOGGER.warn("disc_all_dscs[0] failed rc={}", rc); hidHostOnDscsDiscovered(ctx); } } return 0; } // ---- Service discovery ---- static int hidHostSvcDiscCb(uint16_t conn_handle, const struct ble_gatt_error* error, const struct ble_gatt_svc* svc, void* /*arg*/) { if (!hid_host_ctx) return 0; auto& ctx = *hid_host_ctx; if (conn_handle != ctx.connHandle) return 0; if (error->status == 0 && svc != nullptr) { if (ble_uuid_u16(&svc->uuid.u) == 0x1812) { ctx.hidSvcStart = svc->start_handle; ctx.hidSvcEnd = svc->end_handle; LOGGER.info("HID service start={} end={}", ctx.hidSvcStart, ctx.hidSvcEnd); } } else if (error->status == BLE_HS_EDONE) { if (ctx.hidSvcStart == 0) { LOGGER.warn("No HID service found — disconnecting"); ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM); return 0; } int rc = ble_gattc_disc_all_chrs(ctx.connHandle, ctx.hidSvcStart, ctx.hidSvcEnd, hidHostChrDiscCb, nullptr); if (rc != 0) { LOGGER.warn("disc_all_chrs failed rc={}", rc); ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM); } } return 0; } // ---- GAP callback for HID host central connection ---- static int hidHostGapCb(struct ble_gap_event* event, void* /*arg*/) { if (!hid_host_ctx) return 0; auto& ctx = *hid_host_ctx; switch (event->type) { case BLE_GAP_EVENT_CONNECT: if (event->connect.status == 0) { ctx.connHandle = event->connect.conn_handle; LOGGER.info("Connected (handle={})", ctx.connHandle); // Initiate security immediately to encrypt the link for HOGP (HID over GATT) ble_gap_security_initiate(ctx.connHandle); ctx.securityInitiated = true; int rc = ble_gattc_disc_all_svcs(ctx.connHandle, hidHostSvcDiscCb, nullptr); if (rc != 0) { LOGGER.warn("disc_all_svcs failed rc={}", rc); ble_gap_terminate(ctx.connHandle, BLE_ERR_REM_USER_CONN_TERM); } } else { LOGGER.warn("Connect failed status={}", event->connect.status); hid_host_ctx.reset(); if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { bluetooth_set_hid_host_active(dev, false); struct BtEvent e = {}; e.type = BT_EVENT_PROFILE_STATE_CHANGED; e.profile_state.state = BT_PROFILE_STATE_IDLE; e.profile_state.profile = BT_PROFILE_HID_HOST; bluetooth_fire_event(dev, e); } } break; case BLE_GAP_EVENT_DISCONNECT: { LOGGER.info("Disconnected reason={}", event->disconnect.reason); lv_indev_t* saved_kb = hid_host_ctx ? hid_host_ctx->kbIndev : nullptr; lv_indev_t* saved_mouse = hid_host_ctx ? hid_host_ctx->mouseIndev : nullptr; lv_obj_t* saved_cursor = hid_host_ctx ? hid_host_ctx->mouseCursor : nullptr; QueueHandle_t saved_queue = hid_host_key_queue; hid_host_ctx.reset(); hid_host_key_queue = nullptr; std::memset(hid_host_prev_keys, 0, sizeof(hid_host_prev_keys)); hid_host_mouse_x.store(0); hid_host_mouse_y.store(0); hid_host_mouse_btn.store(false); hid_host_mouse_active.store(false); if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { bluetooth_set_hid_host_active(dev, false); struct BtEvent e = {}; e.type = BT_EVENT_PROFILE_STATE_CHANGED; e.profile_state.state = BT_PROFILE_STATE_IDLE; e.profile_state.profile = BT_PROFILE_HID_HOST; bluetooth_fire_event(dev, e); } getMainDispatcher().dispatch([saved_kb, saved_mouse, saved_cursor, saved_queue] { if (!tt::lvgl::lock(1000)) { LOGGER.warn("Failed to acquire LVGL lock for indev cleanup"); if (saved_queue) vQueueDelete(saved_queue); return; } if (saved_kb) { tt::lvgl::hardware_keyboard_set_indev(nullptr); lv_indev_delete(saved_kb); } if (saved_mouse) lv_indev_delete(saved_mouse); if (saved_cursor) lv_obj_delete(saved_cursor); tt::lvgl::unlock(); if (saved_queue) vQueueDelete(saved_queue); }); break; } case BLE_GAP_EVENT_ENC_CHANGE: if (event->enc_change.conn_handle == ctx.connHandle) { if (event->enc_change.status == 0) { LOGGER.info("Encryption established — notifying state machine"); hidHostOnEncrypted(ctx); } else { LOGGER.warn("Encryption failed status={}", event->enc_change.status); } } break; case BLE_GAP_EVENT_NOTIFY_RX: if (event->notify_rx.conn_handle == ctx.connHandle) { uint16_t len = OS_MBUF_PKTLEN(event->notify_rx.om); if (len > 0 && len <= 64) { uint8_t buf[64] = {}; os_mbuf_copydata(event->notify_rx.om, 0, len, buf); for (const auto& rpt : ctx.inputRpts) { if (rpt.valHandle != event->notify_rx.attr_handle) continue; // Pass reportId so handlers can strip leading ID byte when needed // (combo keyboard/mouse devices like Fosmon B00BX0YKX4 often include ID) switch (rpt.type) { case HidReportType::Keyboard: hidHostHandleKeyboardReportInternal(buf, len, rpt.reportId); break; case HidReportType::Mouse: hidHostHandleMouseReportInternal(buf, len, rpt.reportId); break; case HidReportType::Consumer: LOGGER.info("Consumer report len={}", len); break; case HidReportType::Unknown: { // Unknown type: use length + heuristic. For combo devices // where reportId identifies type, trust reportId if non-zero. // Also trust buffer length: keyboard is typically 8 bytes // (mod+reserved+6keys) possibly with ID -> 9. // Mouse is 3-5 bytes. if (rpt.reportId != 0) { // If we have a Report Map type parsed later, we may not yet // know type here. Make educated guess from len excluding ID. uint16_t effective_len = len; if (len >= 1 && buf[0] == rpt.reportId) effective_len = len - 1; if (effective_len >= 8 || effective_len == 6) { hidHostHandleKeyboardReportInternal(buf, len, rpt.reportId); } else if (effective_len >= 3) { hidHostHandleMouseReportInternal(buf, len, rpt.reportId); } } else { if (len >= 6) hidHostHandleKeyboardReport(buf, len); else if (len >= 3) hidHostHandleMouseReport(buf, len); } break; } } break; } } } break; default: break; } return 0; } // ---- Public functions ---- static esp_timer_handle_t hid_autoconn_retry_timer = nullptr; static int hid_autoconn_empty_scan_count = 0; static constexpr int HID_AUTOCONN_DIRECT_AFTER = 1; // after N empty scans, try direct connect without seeing advert static constexpr int HID_AUTOCONN_MAX_FAST = 20; // ~60s @ 3s interval static constexpr int HID_AUTOCONN_MAX_TOTAL = 100; // then backoff longer but keep trying static void ensureAutoConnTimer() { if (hid_autoconn_retry_timer != nullptr) return; esp_timer_create_args_t args = {}; args.callback = [](void* /*arg*/) { LOGGER.info("Auto-connect retry timer fired (empty_scans={})", hid_autoconn_empty_scan_count); if (hidHostIsConnected()) { hid_autoconn_empty_scan_count = 0; return; } // If not already scanning, start a scan; autoConnectHidHost() will be called // on SCAN_FINISHED via bt_event_bridge. if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { if (!bluetooth_is_scanning(dev)) { bluetooth_scan_start(dev); } } // Keep periodic retry even if we are scanning - as fallback // The timer will be re-armed in autoConnectHidHost() }; args.dispatch_method = ESP_TIMER_TASK; args.name = "hid_autoconn_retry"; if (esp_timer_create(&args, &hid_autoconn_retry_timer) != ESP_OK) { LOGGER.error("Failed to create hid_autoconn_retry timer"); hid_autoconn_retry_timer = nullptr; } } void hidHostConnect(const std::array& addr) { if (getRadioState() != RadioState::On) { LOGGER.warn("hidHostConnect: radio not on"); return; } if (hid_host_ctx) { LOGGER.warn("hidHostConnect: already connecting/connected"); return; } hid_host_mouse_x.store(0); hid_host_mouse_y.store(0); hid_host_mouse_btn.store(false); hid_host_mouse_active.store(false); hid_host_ctx = std::make_unique(); hid_host_ctx->peerAddr = addr; // Create timers lazily if (hid_enc_retry_timer == nullptr) { esp_timer_create_args_t args = {}; args.callback = hidEncRetryTimerCb; args.dispatch_method = ESP_TIMER_TASK; args.name = "hid_enc_retry"; if (esp_timer_create(&args, &hid_enc_retry_timer) != ESP_OK) { LOGGER.error("Failed to create hid_enc_retry timer"); hid_enc_retry_timer = nullptr; } } ensureAutoConnTimer(); // Cancel any pending auto-connect retry (we are connecting now) if (hid_autoconn_retry_timer) esp_timer_stop(hid_autoconn_retry_timer); hid_autoconn_empty_scan_count = 0; // Notify driver that a HID host central connection is starting. if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) bluetooth_set_hid_host_active(dev, true); // Look up the addr_type from the cached scan results. Bonded devices may // advertise with RPA (random) or public; cache now includes nameless adverts // so we usually have it. Fall back to trying both types. ble_addr_t ble_addr = {}; ble_addr.type = BLE_ADDR_PUBLIC; std::memcpy(ble_addr.val, addr.data(), 6); uint8_t addr_type = 0; if (getCachedScanAddrType(addr.data(), &addr_type)) { ble_addr.type = addr_type; LOGGER.info("hidHostConnect: using addr_type={} from scan cache", addr_type); } else { LOGGER.info("hidHostConnect: addr_type unknown, will try PUBLIC then RANDOM"); } uint8_t own_addr_type; if (ble_hs_id_infer_auto(0, &own_addr_type) != 0) { own_addr_type = BLE_OWN_ADDR_PUBLIC; } int rc = ble_gap_connect(own_addr_type, &ble_addr, 8000, nullptr, hidHostGapCb, nullptr); if (rc != 0 && ble_addr.type == BLE_ADDR_PUBLIC) { // Common fail: device actually uses RANDOM (RPA). Retry with RANDOM. LOGGER.info("ble_gap_connect PUBLIC failed rc={}, retrying RANDOM", rc); ble_addr.type = BLE_ADDR_RANDOM; rc = ble_gap_connect(own_addr_type, &ble_addr, 8000, nullptr, hidHostGapCb, nullptr); } if (rc != 0 && ble_addr.type == BLE_ADDR_RANDOM) { // Try PUBLIC again if RANDOM was first (or both failed, we already tried public) // Only if we originally had a cached RANDOM type, try PUBLIC now. if (addr_type == BLE_ADDR_RANDOM) { LOGGER.info("ble_gap_connect RANDOM failed rc={}, retrying PUBLIC", rc); ble_addr.type = BLE_ADDR_PUBLIC; rc = ble_gap_connect(own_addr_type, &ble_addr, 8000, nullptr, hidHostGapCb, nullptr); } } if (rc != 0) { LOGGER.warn("ble_gap_connect failed rc={}", rc); hid_host_ctx.reset(); if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { bluetooth_set_hid_host_active(dev, false); // Fire IDLE so bt_event_bridge can start a new scan and retry. BtEvent e = {}; e.type = BT_EVENT_PROFILE_STATE_CHANGED; e.profile_state.state = BT_PROFILE_STATE_IDLE; e.profile_state.profile = BT_PROFILE_HID_HOST; bluetooth_fire_event(dev, e); } } else { LOGGER.info("Connecting... addr_type={}", (int)ble_addr.type); } } void hidHostDisconnect() { if (!hid_host_ctx || hid_host_ctx->connHandle == BLE_HS_CONN_HANDLE_NONE) return; ble_gap_terminate(hid_host_ctx->connHandle, BLE_ERR_REM_USER_CONN_TERM); } bool hidHostIsConnected() { return hid_host_ctx != nullptr && hid_host_ctx->connHandle != BLE_HS_CONN_HANDLE_NONE && !hid_host_ctx->inputRpts.empty() && hid_host_ctx->subscribeIdx >= (int)hid_host_ctx->inputRpts.size(); } bool hidHostGetConnectedPeer(std::array& addr_out) { if (!hidHostIsConnected()) return false; addr_out = hid_host_ctx->peerAddr; return true; } void autoConnectHidHost() { if (hidHostIsConnected()) return; ensureAutoConnTimer(); // Phase 1: connect if we saw it in last scan (preferred: we have fresh addr_type + RSSI) auto scan = getScanResults(); for (const auto& r : scan) { settings::PairedDevice stored; if (settings::load(settings::addrToHex(r.addr), stored) && stored.autoConnect && stored.profileId == BT_PROFILE_HID_HOST) { LOGGER.info("Auto-connecting HID host to {} (name='{}' rssi={}) [from scan]", settings::addrToHex(r.addr), r.name, (int)r.rssi); hidHostConnect(r.addr); return; } } // Phase 2: not in scan. Keep trying. // This handles boot timing: keyboard may be off, asleep, or using RPA that we // haven't cached yet. We need to: // a) keep scanning periodically so we eventually see wake-up adv // b) after a few empty scans, try DIRECT connect to bonded address — NimBLE // can often connect via directed connection even if not currently advertising, // using the bond store, or it will start scanning internally. auto peers = settings::loadAll(); bool found_auto_peer = false; std::array direct_addr = {}; for (const auto& peer : peers) { if (peer.autoConnect && peer.profileId == BT_PROFILE_HID_HOST) { found_auto_peer = true; direct_addr = peer.addr; break; // take first } } if (!found_auto_peer) { // No autoConnect peers at all — stop retry timer if (hid_autoconn_retry_timer) esp_timer_stop(hid_autoconn_retry_timer); hid_autoconn_empty_scan_count = 0; return; } hid_autoconn_empty_scan_count++; if (hid_autoconn_empty_scan_count >= HID_AUTOCONN_DIRECT_AFTER) { // Try direct connect to saved peer, even though it wasn't in scan. // hidHostConnect() will attempt both PUBLIC and RANDOM addr types. // This is crucial for after reboot where keyboard may be bonded but not // currently advertising in our 5s window, or cache was cleared on SCAN_STARTED. // We only attempt direct every few scans to avoid tight connect loops. if (hid_autoconn_empty_scan_count % 2 == 1 || hid_autoconn_empty_scan_count <= HID_AUTOCONN_DIRECT_AFTER+1) { LOGGER.info("Auto-connect HID host: device not seen after {} scans, trying direct to {}", hid_autoconn_empty_scan_count, settings::addrToHex(direct_addr)); hidHostConnect(direct_addr); return; } } // Not yet at direct-connect threshold, or direct failed last time — keep scanning if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { if (!bluetooth_is_scanning(dev)) { LOGGER.info("Auto-connect HID host: device not in scan (attempt {}/{}), retrying scan", hid_autoconn_empty_scan_count, HID_AUTOCONN_DIRECT_AFTER); bluetooth_scan_start(dev); } } // Schedule next retry uint64_t delay_us = 3 * 1000 * 1000ULL; // 3s fast phase if (hid_autoconn_empty_scan_count >= HID_AUTOCONN_MAX_FAST) { delay_us = 10 * 1000 * 1000ULL; // 10s after 60s if (hid_autoconn_empty_scan_count >= HID_AUTOCONN_MAX_TOTAL) { delay_us = 30 * 1000 * 1000ULL; // 30s after 5min, keep trying forever but slow } } if (hid_autoconn_retry_timer) { esp_timer_stop(hid_autoconn_retry_timer); esp_timer_start_once(hid_autoconn_retry_timer, delay_us); } } } // namespace tt::bluetooth #endif // CONFIG_BT_NIMBLE_ENABLED