diff --git a/src/main.cpp b/src/main.cpp index 467df43..7ba7232 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -7,8 +7,213 @@ #include +// USB HID descriptor templates (TUD_HID_REPORT_DESC_KEYBOARD / _MOUSE). +// BLEHidAdafruit.cpp pulls this in for the composite report map; we need it here +// for the two SEPARATE keyboard-only and mouse-only report maps below. +// bluefruit.h already includes class/hid/hid.h (keycodes, hid_keyboard_report_t, +// hid_mouse_report_t, the hid_ascii_to_keycode[] table). +#include "class/hid/hid_device.h" + +// =========================================================================== +// DUAL-HID SPLIT (FW 1.4.0-dualhid2) — port of the 1.3.0-dualhid probe onto +// current main (1.2.5). Presents the dongle as TWO INDEPENDENT HID SERVICE +// INSTANCES on the ONE nRF52: +// - blekbd : keyboard-ONLY HID (report map = keyboard collection only). +// Intended to bind on iOS's hardware-keyboard text path (like an +// Apple Magic Keyboard) so the LEFTSHIFT modifier is HONORED — +// capitals/shifted symbols land instead of being stripped. +// - blemouse : mouse-ONLY HID (report map = mouse collection only). +// Intended to bind via AssistiveTouch so the cursor still moves +// and clicks. +// The composite mouse+keyboard is adopted by iOS via the AssistiveTouch/pointer +// path, and that path STRIPS the keyboard Shift modifier (A->a, !@#$->1234) +// while lowercase types fine. A proven keyboard-ONLY build types Shift +// correctly but has no cursor. This build tests the empirical question: does +// iOS adopt BOTH roles from one peripheral, with Shift intact AND the cursor +// moving — while keeping EVERY 1.2.x behavior (completion notify, chunk ticks, +// moveseq/click_seq, relative_drag + decel tail, chunked int16 move, bond +// persistence, whoami)? +// +// Topology rules honored here: +// - Two BLEHidGeneric instances coexist (report IDs are PER-service, attr +// table budget ample for 3 services « 20-service cap). +// - begin() is called SEQUENTIALLY (blekbd then blemouse), never interleaved: +// BLEService::lastService binds each characteristic to the most-recently- +// begun service, so one HID must FINISH begin() before the next starts. +// - The HID service UUID (0x1812) is advertised exactly ONCE (only blekbd is +// added to the advert; blemouse shares the same UUID — adding it again just +// wastes advert bytes). +// - The 128-bit custom command-service UUID stays in the Scan Response. +// - kbd-only + mouse-only (NOT two composites) avoids the documented Nordic +// failure of duplicate boot-keyboard characteristics: the keyboard instance +// creates the boot-keyboard chars, the mouse instance creates the +// boot-mouse char — no clash. +// =========================================================================== + +enum { REPORT_ID_KEYBOARD = 1 }; // report ID inside the keyboard-only service +enum { REPORT_ID_MOUSE = 1 }; // report ID inside the mouse-only service (per-service) + +static uint8_t const kbd_report_descriptor[] = +{ + TUD_HID_REPORT_DESC_KEYBOARD( HID_REPORT_ID(REPORT_ID_KEYBOARD) ) +}; + +static uint8_t const mouse_report_descriptor[] = +{ + TUD_HID_REPORT_DESC_MOUSE( HID_REPORT_ID(REPORT_ID_MOUSE) ) +}; + +// --------------------------------------------------------------------------- +// Keyboard-only HID (faithful port of BLEHidAdafruit's keyboard path: same +// hid_ascii_to_keycode[] table, same keyPress/keyRelease/keySequence semantics) +// minus the consumer + mouse collections. Report map = keyboard collection ONLY. +// --------------------------------------------------------------------------- +class BLEHidKeyboardOnly : public BLEHidGeneric +{ +public: + // 1 input report (keyboard), 1 output report (LED bitmap), 0 feature reports. + BLEHidKeyboardOnly(void) : BLEHidGeneric(1, 1, 0) {} + + virtual err_t begin(void) + { + uint16_t input_len[] = { sizeof(hid_keyboard_report_t) }; // 8 bytes + uint16_t output_len[] = { 1 }; // LED bitmap byte + setReportLen(input_len, output_len, NULL); + enableKeyboard(true); + enableMouse(false); + setReportMap(kbd_report_descriptor, sizeof(kbd_report_descriptor)); + + err_t status = BLEHidGeneric::begin(); + // Match BLEHidAdafruit::begin(): tighten the HID connection interval. + Bluefruit.Periph.setConnInterval(9, 12); + return status; + } + + //------------- Keyboard (port of BLEHidAdafruit) -------------// + bool keyboardReport(hid_keyboard_report_t* report) + { + if ( isBootMode() ) return bootKeyboardReport(report, sizeof(hid_keyboard_report_t)); + return inputReport(REPORT_ID_KEYBOARD, report, sizeof(hid_keyboard_report_t)); + } + + bool keyboardReport(uint8_t modifier, uint8_t keycode[6]) + { + hid_keyboard_report_t report; + memset(&report, 0, sizeof(report)); + report.modifier = modifier; + memcpy(report.keycode, keycode, 6); + return keyboardReport(&report); + } + + bool keyPress(char ch) + { + hid_keyboard_report_t report; + memset(&report, 0, sizeof(report)); + // Identical mapping to BLEHidAdafruit::keyPress — the shared + // hid_ascii_to_keycode[] table: [0]=needs-shift flag, [1]=keycode. + report.modifier = ( hid_ascii_to_keycode[(uint8_t)ch][0] ) ? KEYBOARD_MODIFIER_LEFTSHIFT : 0; + report.keycode[0] = hid_ascii_to_keycode[(uint8_t)ch][1]; + return keyboardReport(&report); + } + + bool keyRelease(void) + { + hid_keyboard_report_t report; + memset(&report, 0, sizeof(report)); + return keyboardReport(&report); + } + + bool keySequence(const char* str, int interval = 5) + { + // Byte-for-byte the BLEHidAdafruit::keySequence body (lookahead-gated + // release so repeated/last chars get an explicit key-up). + char ch; + while ( (ch = *str++) != 0 ) + { + char lookahead = *str; + keyPress(ch); + delay(interval); + if ( lookahead == ch || lookahead == 0 ) + { + keyRelease(); + delay(interval); + } + } + return true; + } +}; + +// --------------------------------------------------------------------------- +// Mouse-only HID (faithful port of BLEHidAdafruit's Mouse section: same +// hid_mouse_report_t, same _mse_buttons state machine). Report map = mouse +// collection ONLY. Report ID 1 is correct here because report IDs are PER +// service (the composite used ID 3 for its mouse; this service has one report). +// --------------------------------------------------------------------------- +class BLEHidMouseOnly : public BLEHidGeneric +{ +public: + // 1 input report (mouse), 0 output reports, 0 feature reports. + BLEHidMouseOnly(void) : BLEHidGeneric(1, 0, 0) { _mse_buttons = 0; } + + virtual err_t begin(void) + { + uint16_t input_len[] = { sizeof(hid_mouse_report_t) }; + setReportLen(input_len, NULL, NULL); + enableMouse(true); + enableKeyboard(false); + setReportMap(mouse_report_descriptor, sizeof(mouse_report_descriptor)); + + err_t status = BLEHidGeneric::begin(); + // Match BLEHidAdafruit::begin(): tighten the HID connection interval. + Bluefruit.Periph.setConnInterval(9, 12); + return status; + } + + //------------- Mouse (port of BLEHidAdafruit) -------------// + bool mouseReport(hid_mouse_report_t* report) + { + if ( isBootMode() ) return bootMouseReport(report, sizeof(hid_mouse_report_t)); + return inputReport(REPORT_ID_MOUSE, report, sizeof(hid_mouse_report_t)); + } + + bool mouseReport(uint8_t buttons, int8_t x, int8_t y, int8_t wheel = 0, int8_t pan = 0) + { + (void) pan; // pan field is commented out in the library's report struct + hid_mouse_report_t report = + { + .buttons = buttons, + .x = x, + .y = y, + .wheel = wheel, +// .pan = pan + }; + _mse_buttons = buttons; + return mouseReport(&report); + } + + bool mouseButtonPress(uint8_t buttons) + { + _mse_buttons = buttons; + return mouseReport(buttons, 0, 0, 0, 0); + } + + bool mouseButtonRelease(void) + { + return mouseReport(0, 0, 0, 0, 0); + } + + bool mouseMove(int8_t x, int8_t y) + { + return mouseReport(_mse_buttons, x, y, 0, 0); + } + +protected: + uint8_t _mse_buttons; +}; + BLEDis bledis; -BLEHidAdafruit blehid; +BLEHidKeyboardOnly blekbd; // keyboard-only HID (Shift-correct text path) +BLEHidMouseOnly blemouse; // mouse-only HID (AssistiveTouch cursor path) // Firmware version — appended to the advertised BLE name (see setName below) so // the flashed build is identifiable from the iOS side without guessing. BUMP @@ -142,7 +347,20 @@ BLEHidAdafruit blehid; // phone is just showing a stale cached GAP name. Guessing between them // cost a debug cycle, so the dongle now just says which. Actuation // untouched. -#define FW_VERSION "1.2.5" +// 1.4.0-dualhid2 — DUAL-HID SPLIT on current main (port of the 1.3.0-dualhid +// probe from branch dual-hid-split-experiment, which was cut at 1.1.15 +// and never merged). Replaces the single composite BLEHidAdafruit with +// TWO independent HID service instances: BLEHidKeyboardOnly blekbd +// (keyboard collection only -> binds on the iOS HW-keyboard text path, +// Shift honored) + BLEHidMouseOnly blemouse (mouse collection only -> +// binds via AssistiveTouch, cursor works). ONLY the HID object each +// existing code path calls changes: typing/key paths -> blekbd, +// mouse/click/drag paths -> blemouse. Completion notify, chunk ticks, +// click_seq/move_seq/move_atomic, relative_drag + decel tail, chunked +// int16 move, bond persistence, and whoami are ALL byte-for-byte 1.2.5. +// begin() is sequential (kbd then mouse); HID UUID advertised once; +// GAP appearance = Keyboard. +#define FW_VERSION "1.4.0-dualhid2" // The BLE advertised name, and the ONLY place it is written. String-literal // concatenation appends FW_VERSION automatically, so only the text below ever @@ -150,7 +368,16 @@ BLEHidAdafruit blehid; // rewrites that text; setup() feeds it to Bluefruit.setName(), and the `whoami` // serial command reports it back (see printWhoami) so the host can tell two // plugged-in dongles apart. -#define BLE_NAME "AGI Relay " FW_VERSION +// +// The iOS app gates its gesture path on BOTH the "move" and "seq" tokens being +// present in the peripheral name, so the "-moveseq" suffix is load-bearing. +// LENGTH BUDGET: Bluefruit's ScanResponse.addName() truncates the on-air name to +// 29 bytes (31-byte scan-response payload minus the 2-byte AD header), and a +// truncated name would drop the trailing "seq" token. Prefix + version + suffix +// must therefore total <= 29 chars — the short "AGI " prefix keeps +// "AGI 1.4.0-dualhid2-moveseq" at 26. flash-dongle rewrites the prefix text; +// keep the total under 29 when renaming. +#define BLE_NAME "AGI DualHID " FW_VERSION #define DEFAULT_MOVE_STEP 25 @@ -191,7 +418,7 @@ static inline bool coordsInRange(int a, int b, int c, int d) return coordInRange(a) && coordInRange(b) && coordInRange(c) && coordInRange(d); } -// CORE-B1: blehid.mouseMove() returns false when the report could NOT be queued +// CORE-B1: blemouse.mouseMove() returns false when the report could NOT be queued // (no free HVN TX buffer, or the CCCD/notifications are off). Discarding that // return silently DROPS a HID report, so the cursor lands short of its target and // every calibration sample taken from that move is corrupted with no error. @@ -210,10 +437,10 @@ static bool moveFailedThisCommand = false; static bool mouseMoveReliable(int8_t dx, int8_t dy, int retryDelayMs) { - if (blehid.mouseMove(dx, dy)) return true; + if (blemouse.mouseMove(dx, dy)) return true; // Transient failure: give the SoftDevice a moment to free a TX buffer, retry once. delay(retryDelayMs > 0 ? retryDelayMs : 2); - if (blehid.mouseMove(dx, dy)) return true; + if (blemouse.mouseMove(dx, dy)) return true; moveFailedThisCommand = true; Serial.print(" HID: mouseMove DROPPED after retry ("); Serial.print(dx); @@ -350,17 +577,17 @@ static void doubleClick(int x, int y) delay(50); // First click - blehid.mouseButtonPress(MOUSE_BUTTON_LEFT); + blemouse.mouseButtonPress(MOUSE_BUTTON_LEFT); delay(50); - blehid.mouseButtonRelease(); + blemouse.mouseButtonRelease(); // Settle for a second delay(100); // Second click - blehid.mouseButtonPress(MOUSE_BUTTON_LEFT); + blemouse.mouseButtonPress(MOUSE_BUTTON_LEFT); delay(50); - blehid.mouseButtonRelease(); + blemouse.mouseButtonRelease(); } static void pressAndHold(int x, int y, int holdMs) @@ -368,9 +595,9 @@ static void pressAndHold(int x, int y, int holdMs) slamUpperLeft(); mouseMoveReliable((int8_t) x, (int8_t) y, 20); delay(20); - blehid.mouseButtonPress(MOUSE_BUTTON_LEFT); + blemouse.mouseButtonPress(MOUSE_BUTTON_LEFT); delay(holdMs); - blehid.mouseButtonRelease(); + blemouse.mouseButtonRelease(); } static void drag(int x1, int y1, int x2, int y2) @@ -378,15 +605,15 @@ static void drag(int x1, int y1, int x2, int y2) slamUpperLeft(); mouseMoveReliable((int8_t) x1, (int8_t) y1, 20); delay(20); - blehid.mouseButtonPress(MOUSE_BUTTON_LEFT); + blemouse.mouseButtonPress(MOUSE_BUTTON_LEFT); delay(20); mouseMoveReliable((int8_t) (x2 - x1), (int8_t) (y2 - y1), 20); delay(20); - blehid.mouseButtonRelease(); + blemouse.mouseButtonRelease(); } // Chunked relative move — emits the (dx, dy) delta as a sequence of int8 -// HID reports because blehid.mouseMove takes signed-8-bit deltas. Mirrors +// HID reports because blemouse.mouseMove takes signed-8-bit deltas. Mirrors // the dispatch path used by the "move" command so the int8 saturation issue // that plagues the legacy drag() can't recur. `inter_chunk_delay_ms` is the // pause inserted between chunks; iOS coalesces chunks emitted faster than @@ -685,14 +912,14 @@ static void typeText(const char *text, int keyDelayMs = 100) size_t n = (len - i < CHUNK) ? (len - i) : CHUNK; memcpy(buf, text + i, n); buf[n] = '\0'; - blehid.keySequence(buf, keyDelayMs); + blekbd.keySequence(buf, keyDelayMs); statusChar.notify((const uint8_t *) "{\"tick\":true}", 13); // inter-chunk watchdog tick } } static void mouseDown(void) { - blehid.mouseButtonPress(MOUSE_BUTTON_LEFT); + blemouse.mouseButtonPress(MOUSE_BUTTON_LEFT); Serial.println(" HID: LEFT_DOWN report sent"); } @@ -702,16 +929,16 @@ static void mouseDown(void) // subsequent mouseMove from re-pressing the button. static void mouseUp(void) { - blehid.mouseButtonPress(0); // zero out _mse_buttons state - blehid.mouseButtonRelease(); // send zero-button HID report + blemouse.mouseButtonPress(0); // zero out _mse_buttons state + blemouse.mouseButtonRelease(); // send zero-button HID report Serial.println(" HID: button RELEASED report sent"); } static void sendKeyCombo(uint8_t modifier, uint8_t keycode) { uint8_t keycodes[6] = {keycode, HID_KEY_NONE, HID_KEY_NONE, HID_KEY_NONE, HID_KEY_NONE, HID_KEY_NONE}; - blehid.keyboardReport(modifier, keycodes); - blehid.keyRelease(); // important: stops “key stuck / repeats forever” + blekbd.keyboardReport(modifier, keycodes); + blekbd.keyRelease(); // important: stops “key stuck / repeats forever” } // Monotonic per-completed-command counter for the done-notify. Bumped once per @@ -1108,8 +1335,20 @@ void setup() bledis.setModel("AGI HID"); bledis.begin(); - // BLE HID - blehid.begin(); + // BLE HID — two independent service instances. begin() them SEQUENTIALLY + // (Gotcha A): BLEService::lastService binds each characteristic to the + // most-recently-begun service, so blekbd must FULLY finish begin() before + // blemouse starts, or the mouse characteristics would attach to the keyboard + // service. err_t status is checked only for the diagnostic Serial log; on + // NO_MEM/INVALID, enlarge the GATT attribute table with + // Bluefruit.configAttrTableSize(0x1400) BEFORE Bluefruit.begin() (the 0x1000 + // default suffices for kbd-only + mouse-only + the custom command service). + err_t kbdStatus = blekbd.begin(); + err_t mouseStatus = blemouse.begin(); + Serial.print("blekbd.begin() status=0x"); + Serial.print(kbdStatus, HEX); + Serial.print(" blemouse.begin() status=0x"); + Serial.println(mouseStatus, HEX); // Custom Service + Characteristic customService.begin(); @@ -1219,10 +1458,19 @@ static void startAdv(void) // Primary ADV: keep it small & HID-focused Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE); Bluefruit.Advertising.addTxPower(); - Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_MOUSE); - Bluefruit.Advertising.addService(blehid); - - // Scan Response: put the “extra” stuff here + // GAP appearance is a SINGLE device-level role hint and cannot describe a + // dual-role peripheral. We hint Keyboard (was BLE_APPEARANCE_HID_MOUSE) on the + // theory that biasing iOS toward the HW-keyboard text path is what makes Shift + // land. This appearance is THE candidate variable to revisit if the cursor + // fails to adopt — flip it to BLE_APPEARANCE_HID_MOUSE or a generic appearance. + Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_KEYBOARD); + // Gotcha B: advertise the HID service UUID (0x1812) exactly ONCE. blekbd and + // blemouse share that same UUID, so adding blemouse too would only waste advert + // bytes — iOS discovers both HID services via GATT after connecting. + Bluefruit.Advertising.addService(blekbd); + + // Scan Response: put the “extra” stuff here (Gotcha C: the 128-bit custom + // command-service UUID lives here to fit the 31-byte advert budget) Bluefruit.ScanResponse.addName(); Bluefruit.ScanResponse.addService(customService);