fix(bluetooth): robust HID host autoconnect and combo keyboard parser

- Scan: store nameless directed adv to keep addr_type cache for Fosmon-type
  keyboards that wake without name -> fixes auto-connect on boot not finding device
- HID parser: strip Report ID, support standard 3/4-byte mouse [btn,x8,y8,wheel]
  instead of mis-detecting as 12-bit packed; heuristic only when std small
- HID reconnect: try PUBLIC then RANDOM addr_type with 8s timeout,
  periodic retry timer (3/10/30s backoff) that exists at boot via ensureAutoConnTimer,
  direct connect even without advert after empty scans to catch wake-on-keypress
- Verified on waveshare-esp32-s3-rlcd /dev/cu.usbmodem1101 - Fosmon mini
  B00BX0YKX4 combo now moves smoothly and reconnects after board restart

Co-authored-by: Hermes Agent <hermes@noreply>
This commit is contained in:
Adolfo Reyna
2026-07-12 15:26:40 -04:00
parent ff87ff4b1b
commit 78382d6deb
2 changed files with 263 additions and 42 deletions
@@ -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); memcpy(record.name, ctx->scan_results[i].name, BT_NAME_MAX + 1);
} }
ctx->scan_results[i].rssi = record.rssi; 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; found = true;
should_publish = (record.name[0] != '\0'); should_publish = (record.name[0] != '\0');
break; 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_results[ctx->scan_count] = record;
ctx->scan_addrs[ctx->scan_count] = disc.addr; // full addr (type+val) ctx->scan_addrs[ctx->scan_count] = disc.addr; // full addr (type+val)
ctx->scan_count++; ctx->scan_count++;
+254 -41
View File
@@ -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; if (len < 3 || !hid_host_key_queue) return;
uint8_t mod = data[0];
const uint8_t* curr = &data[2]; // Strip leading Report ID if present (some HOGP combo devices prepend it)
int nkeys = std::min((int)(len - 2), 6); 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++) { for (int i = 0; i < 6; i++) {
uint8_t kc = hid_host_prev_keys[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); 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) { static void hidHostMouseReadCb(lv_indev_t* /*indev*/, lv_indev_data_t* data) {
int32_t cx = hid_host_mouse_x.load(); int32_t cx = hid_host_mouse_x.load();
int32_t cy = hid_host_mouse_y.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 dx = 0;
int32_t dy = 0; int32_t dy = 0;
bool btn = false; bool btn = false;
if (len >= 5) { if (len == 0) return;
// 12-bit packed coordinate format (Logitech/high-res HID mice)
btn = (data[0] & 0x01) != 0;
int16_t raw_x = data[2] | ((data[3] & 0x0F) << 8); // Strip Report ID prefix if present. Many combo HOGP devices (e.g. Fosmon
if (raw_x & 0x0800) raw_x |= 0xF000; // sign extend 12-bit to 16-bit // mini KB + touchpad: https://a.co/d/0fg4vprm) use Report IDs:
dx = (int16_t)raw_x; // 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 (plen < 3) return;
if (raw_y & 0x0800) raw_y |= 0xF000; // sign extend 12-bit to 16-bit
dy = (int16_t)raw_y; if (plen >= 5) {
} else if (len >= 3) { // Two possible interpretations for 5-byte reports:
// Standard 3-byte boot protocol // A) Standard extended: [buttons, x8, y8, wheel, pan]
btn = (data[0] & 0x01) != 0; // B) Logitech 12-bit packed: [buttons, ?, x_lo, x_hi_nibble|y_lo_nibble, y_hi]
dx = (int8_t)data[1]; //
dy = (int8_t)data[2]; // 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 { } 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(); 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 ---- // ---- Asynchronous coordination helpers ----
static void hidHostOnDscsDiscovered(HidHostCtx& ctx) { 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); os_mbuf_copydata(event->notify_rx.om, 0, len, buf);
for (const auto& rpt : ctx.inputRpts) { for (const auto& rpt : ctx.inputRpts) {
if (rpt.valHandle != event->notify_rx.attr_handle) continue; 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) { switch (rpt.type) {
case HidReportType::Keyboard: hidHostHandleKeyboardReport(buf, len); break; case HidReportType::Keyboard:
case HidReportType::Mouse: hidHostHandleMouseReport(buf, len); break; hidHostHandleKeyboardReportInternal(buf, len, rpt.reportId);
break;
case HidReportType::Mouse:
hidHostHandleMouseReportInternal(buf, len, rpt.reportId);
break;
case HidReportType::Consumer: case HidReportType::Consumer:
LOGGER.info("Consumer report len={}", len); LOGGER.info("Consumer report len={}", len);
break; break;
case HidReportType::Unknown: case HidReportType::Unknown: {
if (len >= 6) hidHostHandleKeyboardReport(buf, len); // Unknown type: use length + heuristic. For combo devices
else if (len >= 3) hidHostHandleMouseReport(buf, len); // 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; break;
} }
@@ -834,6 +939,39 @@ static int hidHostGapCb(struct ble_gap_event* event, void* /*arg*/) {
// ---- Public functions ---- // ---- 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<uint8_t, 6>& addr) { void hidHostConnect(const std::array<uint8_t, 6>& addr) {
if (getRadioState() != RadioState::On) { if (getRadioState() != RadioState::On) {
LOGGER.warn("hidHostConnect: radio not on"); LOGGER.warn("hidHostConnect: radio not on");
@@ -852,7 +990,7 @@ void hidHostConnect(const std::array<uint8_t, 6>& addr) {
hid_host_ctx = std::make_unique<HidHostCtx>(); hid_host_ctx = std::make_unique<HidHostCtx>();
hid_host_ctx->peerAddr = addr; hid_host_ctx->peerAddr = addr;
// Create enc retry timer lazily // Create timers lazily
if (hid_enc_retry_timer == nullptr) { if (hid_enc_retry_timer == nullptr) {
esp_timer_create_args_t args = {}; esp_timer_create_args_t args = {};
args.callback = hidEncRetryTimerCb; args.callback = hidEncRetryTimerCb;
@@ -863,17 +1001,26 @@ void hidHostConnect(const std::array<uint8_t, 6>& addr) {
hid_enc_retry_timer = nullptr; 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. // 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); 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_t ble_addr = {};
ble_addr.type = BLE_ADDR_PUBLIC; ble_addr.type = BLE_ADDR_PUBLIC;
std::memcpy(ble_addr.val, addr.data(), 6); std::memcpy(ble_addr.val, addr.data(), 6);
uint8_t addr_type = 0; uint8_t addr_type = 0;
if (getCachedScanAddrType(addr.data(), &addr_type)) { if (getCachedScanAddrType(addr.data(), &addr_type)) {
ble_addr.type = 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; uint8_t own_addr_type;
@@ -881,7 +1028,23 @@ void hidHostConnect(const std::array<uint8_t, 6>& addr) {
own_addr_type = BLE_OWN_ADDR_PUBLIC; 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) { if (rc != 0) {
LOGGER.warn("ble_gap_connect failed rc={}", rc); LOGGER.warn("ble_gap_connect failed rc={}", rc);
hid_host_ctx.reset(); hid_host_ctx.reset();
@@ -895,7 +1058,7 @@ void hidHostConnect(const std::array<uint8_t, 6>& addr) {
bluetooth_fire_event(dev, e); bluetooth_fire_event(dev, e);
} }
} else { } else {
LOGGER.info("Connecting..."); LOGGER.info("Connecting... addr_type={}", (int)ble_addr.type);
} }
} }
@@ -920,34 +1083,84 @@ bool hidHostGetConnectedPeer(std::array<uint8_t, 6>& addr_out) {
void autoConnectHidHost() { void autoConnectHidHost() {
if (hidHostIsConnected()) return; if (hidHostIsConnected()) return;
// Connect to the first saved HID host peer that appeared in the last scan. ensureAutoConnTimer();
// cacheScanAddr() is populated during scanning so addr_type is available for ble_gap_connect.
// Phase 1: connect if we saw it in last scan (preferred: we have fresh addr_type + RSSI)
auto scan = getScanResults(); auto scan = getScanResults();
for (const auto& r : scan) { for (const auto& r : scan) {
settings::PairedDevice stored; settings::PairedDevice stored;
if (settings::load(settings::addrToHex(r.addr), stored) && if (settings::load(settings::addrToHex(r.addr), stored) &&
stored.autoConnect && stored.autoConnect &&
stored.profileId == BT_PROFILE_HID_HOST) { 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); hidHostConnect(r.addr);
return; return;
} }
} }
// Device not in the last scan. If we have an autoConnect HID host peer, restart // Phase 2: not in scan. Keep trying.
// scanning so we keep checking until the device powers back on. // 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(); auto peers = settings::loadAll();
bool found_auto_peer = false;
std::array<uint8_t, 6> direct_addr = {};
for (const auto& peer : peers) { for (const auto& peer : peers) {
if (peer.autoConnect && peer.profileId == BT_PROFILE_HID_HOST) { if (peer.autoConnect && peer.profileId == BT_PROFILE_HID_HOST) {
if (Device* dev = device_find_first_active_by_type(&BLUETOOTH_TYPE)) { found_auto_peer = true;
if (!bluetooth_is_scanning(dev)) { direct_addr = peer.addr;
LOGGER.info("Auto-connect HID host: device not in scan, retrying scan"); break; // take first
bluetooth_scan_start(dev);
}
}
break;
} }
} }
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 } // namespace tt::bluetooth