diff --git a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp index eff61293..0a5df306 100644 --- a/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp +++ b/Platforms/platform-esp32/source/drivers/bluetooth/esp32_ble_scan.cpp @@ -82,12 +82,20 @@ int ble_gap_disc_event_handler(struct ble_gap_event* event, void* arg) { memcpy(record.name, ctx->scan_results[i].name, BT_NAME_MAX + 1); } ctx->scan_results[i].rssi = record.rssi; + // Always keep addr_type up to date (RPA can rotate) + ctx->scan_addrs[i] = disc.addr; found = true; should_publish = (record.name[0] != '\0'); break; } } - if (!found && record.name[0] != '\0' && ctx->scan_count < 64) { + if (!found && ctx->scan_count < 64) { + // Store ALL unique advertisers, not only those with names. + // This is required for auto-connect: bonded keyboards (e.g. Fosmon + // mini keyboard/touchpad combo) may advertise with only Appearance + // + flags after waking from sleep, or use RPA without a name. + // UI can still filter for display, but cache must contain them so + // addr_type is available for ble_gap_connect(). ctx->scan_results[ctx->scan_count] = record; ctx->scan_addrs[ctx->scan_count] = disc.addr; // full addr (type+val) ctx->scan_count++; diff --git a/Tactility/Source/bluetooth/BluetoothHidHost.cpp b/Tactility/Source/bluetooth/BluetoothHidHost.cpp index 5928f8a5..9a6d1410 100644 --- a/Tactility/Source/bluetooth/BluetoothHidHost.cpp +++ b/Tactility/Source/bluetooth/BluetoothHidHost.cpp @@ -145,11 +145,30 @@ static void hidHostKeyboardReadCb(lv_indev_t* /*indev*/, lv_indev_data_t* data) } } -static void hidHostHandleKeyboardReport(const uint8_t* data, uint16_t len) { +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; - uint8_t mod = data[0]; - const uint8_t* curr = &data[2]; - int nkeys = std::min((int)(len - 2), 6); + + // 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]; @@ -175,6 +194,10 @@ static void hidHostHandleKeyboardReport(const uint8_t* data, uint16_t len) { 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(); @@ -215,29 +238,83 @@ static void hidHostMouseReadCb(lv_indev_t* /*indev*/, lv_indev_data_t* data) { } } -static void hidHostHandleMouseReport(const uint8_t* data, uint16_t len) { +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 >= 5) { - // 12-bit packed coordinate format (Logitech/high-res HID mice) - btn = (data[0] & 0x01) != 0; + if (len == 0) return; - int16_t raw_x = data[2] | ((data[3] & 0x0F) << 8); - if (raw_x & 0x0800) raw_x |= 0xF000; // sign extend 12-bit to 16-bit - dx = (int16_t)raw_x; + // 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; + } - int16_t raw_y = (data[3] >> 4) | (data[4] << 4); - if (raw_y & 0x0800) raw_y |= 0xF000; // sign extend 12-bit to 16-bit - dy = (int16_t)raw_y; - } else if (len >= 3) { - // Standard 3-byte boot protocol - btn = (data[0] & 0x01) != 0; - dx = (int8_t)data[1]; - dy = (int8_t)data[2]; + 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 { - return; + // 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(); @@ -275,6 +352,10 @@ static void hidHostHandleMouseReport(const uint8_t* data, uint16_t len) { } } +static void hidHostHandleMouseReport(const uint8_t* data, uint16_t len) { + hidHostHandleMouseReportInternal(data, len, 0); +} + // ---- Asynchronous coordination helpers ---- static void hidHostOnDscsDiscovered(HidHostCtx& ctx) { @@ -809,16 +890,40 @@ static int hidHostGapCb(struct ble_gap_event* event, void* /*arg*/) { 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: hidHostHandleKeyboardReport(buf, len); break; - case HidReportType::Mouse: hidHostHandleMouseReport(buf, len); break; + 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: - if (len >= 6) hidHostHandleKeyboardReport(buf, len); - else if (len >= 3) hidHostHandleMouseReport(buf, len); + 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; } @@ -834,6 +939,39 @@ static int hidHostGapCb(struct ble_gap_event* event, void* /*arg*/) { // ---- 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"); @@ -852,7 +990,7 @@ void hidHostConnect(const std::array& addr) { hid_host_ctx = std::make_unique(); hid_host_ctx->peerAddr = addr; - // Create enc retry timer lazily + // Create timers lazily if (hid_enc_retry_timer == nullptr) { esp_timer_create_args_t args = {}; args.callback = hidEncRetryTimerCb; @@ -863,17 +1001,26 @@ void hidHostConnect(const std::array& addr) { 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. + // 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; @@ -881,7 +1028,23 @@ void hidHostConnect(const std::array& addr) { own_addr_type = BLE_OWN_ADDR_PUBLIC; } - int rc = ble_gap_connect(own_addr_type, &ble_addr, 5000, nullptr, hidHostGapCb, nullptr); + 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(); @@ -895,7 +1058,7 @@ void hidHostConnect(const std::array& addr) { bluetooth_fire_event(dev, e); } } else { - LOGGER.info("Connecting..."); + LOGGER.info("Connecting... addr_type={}", (int)ble_addr.type); } } @@ -920,34 +1083,84 @@ bool hidHostGetConnectedPeer(std::array& addr_out) { void autoConnectHidHost() { if (hidHostIsConnected()) return; - // Connect to the first saved HID host peer that appeared in the last scan. - // cacheScanAddr() is populated during scanning so addr_type is available for ble_gap_connect. + 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 {}", settings::addrToHex(r.addr)); + LOGGER.info("Auto-connecting HID host to {} (name='{}' rssi={}) [from scan]", + settings::addrToHex(r.addr), r.name, (int)r.rssi); hidHostConnect(r.addr); return; } } - // Device not in the last scan. If we have an autoConnect HID host peer, restart - // scanning so we keep checking until the device powers back on. + // 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) { - 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, retrying scan"); - bluetooth_scan_start(dev); - } - } - break; + 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