diff --git a/CHANGES.md b/CHANGES.md index cb4be36d..288512f8 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -27,6 +27,8 @@ Update to version 3.0.0 - Bug fix: bad field highlighting when the keypad is shown over its location on screen. - Hyphenator for 16 languages. They are Albanian, Croatian, Czech, Danish, Dutch, English, French, German, Icelandic, Italian, Polish, Portuguese, Slovak, Slovenian, Spanish, and Turkish - Bluetooth support for mini keypads in use with the following Inkplate devices: 5V2, 6V2, and 10V2. +- BLE keypads that only expose their HID service to a bonded peer are now paired properly: security is requested on connection and the HID characteristics are searched again once the link is encrypted. +- Support for the mini keypads advertising themselves as `MUZHTEN`. They are sold as a Beauty-R1 but send a different set of HID reports. The `bt_keypad_type` config value for them is 3. **Page locations (PageLocs) architecture** diff --git a/README.md b/README.md index 28c147ff..c3d76f58 100644 --- a/README.md +++ b/README.md @@ -65,11 +65,13 @@ To streamline our GitHub release page, distributions for all supported devices a |6PLUS V2|Soldered|release-v3.0.0-inkplate_6plusv2.zip| |6FLICK|Soldered|release-v3.0.0-inkplate_6flick.zip| -The following Inkplates require a Bluetooth BLE mini keypad to be usable with this application. You can find the two supported mini keypads on AliExpress: +The following Inkplates require a Bluetooth BLE mini keypad to be usable with this application. You can find the supported mini keypads on AliExpress: * **Beauty-R1**: https://www.aliexpress.com/item/1005007944515439.html * **J06 Pro**: https://www.aliexpress.com/item/1005011855666831.html +Some units sold as a Beauty-R1 advertise themselves as **MUZHTEN** and send different HID reports. They are recognized as a distinct keypad and are supported as well. + |Inkplate device|From|Release filename| |:-------------:|:--:|----------------| |5 V2|Soldered|release-v3.0.0-inkplate_5v2.zip| diff --git a/SDCard/config.txt b/SDCard/config.txt index 97587659..ecdda7ac 100644 --- a/SDCard/config.txt +++ b/SDCard/config.txt @@ -38,6 +38,10 @@ # column_count # bt_keypad_mac # bt_keypad_type +# +# The bt_keypad_type values are: 0 = none, 1 = Beauty-R1, 2 = J06 Pro, 3 = MUZHTEN. +# Both bt_keypad values are set automatically when a supported keypad is +# discovered. Set them by hand only if automatic discovery fails. # --- version = 1 diff --git a/SDCard/config_distrib.txt b/SDCard/config_distrib.txt index 4e04954b..bb01c064 100644 --- a/SDCard/config_distrib.txt +++ b/SDCard/config_distrib.txt @@ -36,6 +36,11 @@ # line_height # column_count # bt_keypad_mac +# bt_keypad_type +# +# The bt_keypad_type values are: 0 = none, 1 = Beauty-R1, 2 = J06 Pro, 3 = MUZHTEN. +# Both bt_keypad values are set automatically when a supported keypad is +# discovered. Set them by hand only if automatic discovery fails. # --- version = 1 @@ -63,4 +68,5 @@ tz = "" battery_trim = 0.93 line_height = 1 column_count = 1 -bt_keypad_mac = "00:00:00:00:00:00" \ No newline at end of file +bt_keypad_mac = "00:00:00:00:00:00" +bt_keypad_type = 0 \ No newline at end of file diff --git a/components/ble_keypad/src/ble_keypad.cpp b/components/ble_keypad/src/ble_keypad.cpp index 91bc76a9..7417ab31 100644 --- a/components/ble_keypad/src/ble_keypad.cpp +++ b/components/ble_keypad/src/ble_keypad.cpp @@ -399,6 +399,139 @@ } } + // The processMuzhtenPacket is tailored to the mini keypad advertising itself as "MUZHTEN". It is + // sold under the Beauty-R1 name but emits a completely different set of HID reports, on two + // streams: a 4 bytes one for the arrow and select keys, a 2 bytes one for the home key. Every + // key press produces a burst of reports ending with a release report, recognized by a null first + // byte, that is unique to the key and stable from one press to the next. Only the release + // reports are decoded. + // + // Here are all the packets received for all available keys: + // + // Right Key: + // Packet: 02 52 61 18 + // Packet: 02 52 01 19 + // Packet: 03 52 61 18 + // Packet: 03 36 61 18 + // Packet: 03 c6 60 18 + // Packet: 03 32 00 19 + // Packet: 02 32 60 18 + // Packet: 00 32 60 18 + // + // Left Key: + // Packet: 02 96 60 18 + // Packet: 03 96 00 19 + // Packet: 03 c6 60 18 + // Packet: 03 36 61 18 + // Packet: 03 c2 01 19 + // Packet: 02 c2 61 18 + // Packet: 00 c2 61 18 + // + // Up Key: + // Packet: 03 18 01 0d + // Packet: 03 18 41 12 + // Packet: 03 18 c1 1c + // Packet: 03 18 41 27 + // Packet: 03 22 c1 31 + // Packet: 00 18 01 37 + // Packet: 00 22 01 37 + // + // Down key: + // Packet: 03 2c 01 30 + // Packet: 03 18 e1 2a + // Packet: 03 2c a1 20 + // Packet: 03 18 61 16 + // Packet: 03 2c 21 0c + // Packet: 00 18 01 07 + // Packet: 00 18 01 07 + // + // Select key: + // Packet: 03 2c 81 1c + // Packet: 00 2c 81 1c + // + // Home key, on the 2 bytes stream: + // Packet: 01 00 (or 02 00) + // Packet: 00 00 + // + // Home key, on the 4 bytes stream (left undecoded): + // Packet: 03 52 51 32 + // Packet: 02 52 51 32 + // Packet: 00 b5 51 32 + // + // The keypad can be purchased through AliExpress: + // + // https://www.aliexpress.com/item/1005007944515439.html + // + + auto BLEKeypad::processMuzhtenPacket(const uint8_t *data, size_t length) -> void { + if (data == nullptr) { return; } + + Event event{ EventKind::NONE }; + + if (length == 2) { + // Home key. The trailing 00 00 packet is ignored. + if ((data[0] == 1 || data[0] == 2) && data[1] == 0) { + event.kind = EventKind::DBL_SELECT; + } + } else if ((length == 4) && (data[0] == 0x00)) { + switch ((data[1] << 16) | (data[2] << 8) | data[3]) { + case 0x326018: event.kind = EventKind::NEXT; break; // Right key + case 0xC26118: event.kind = EventKind::PREV; break; // Left key + case 0x180137: + case 0x220137: event.kind = EventKind::DBL_PREV; break; // Up key + case 0x180107: event.kind = EventKind::DBL_NEXT; break; // Down key + case 0x2C811C: event.kind = EventKind::SELECT; break; // Select key + default: // Home key release and the + break; // intermediate reports + } + } + + if (event.kind == EventKind::NONE) { return; } + + // The up and down keys send their release report twice, up to 250 msecs apart. The other keys + // send a single one and are left untouched to keep page turns responsive. + if ((event.kind == EventKind::DBL_NEXT) || (event.kind == EventKind::DBL_PREV)) { + static EventKind lastKind{ EventKind::NONE }; + static int64_t lastTime{ 0 }; + + int64_t now = esp_timer_get_time(); + + if ((event.kind == lastKind) && ((now - lastTime) < 400000)) { return; } + + lastKind = event.kind; + lastTime = now; + } + + if (bleEventQueue) { + xQueueSend(bleEventQueue, &event, 0); + } else { + LOG_E("Event bleEventQueue not initialized. Unable to send event."); + } + } + + // --- HID CHARACTERISTIC DISCOVERY LAUNCHER --- + // Runs on connection and again once the link is encrypted, as some keypads only expose + // their HID service to a bonded peer + auto BLEKeypad::discoverHidChars() -> int { + if (discoveryActive || hidFound) { return 0; } + + ble_uuid16_t hidUuid = { + .u = { .type = BLE_UUID_TYPE_16 }, + .value = HID_REPORT_CHAR_UUID + }; + + discoveryActive = true; + + int rc = ble_gattc_disc_chrs_by_uuid(glConnId, 1, 0xffff, &hidUuid.u, + BLEKeypad::discoveryStub, nullptr); + if (rc != 0) { + discoveryActive = false; + LOG_E("GATT query initialization failure; rc={}", rc); + } + + return rc; + } + // --- NIMBLE CENTRAL GAP EVENT LOOP --- auto BLEKeypad::handleGapEvent(struct ble_gap_event *event) -> int { int rc; @@ -429,6 +562,11 @@ match = true; keypadType = KeypadType::BEAUTY_R1; LOG_I("Found Beauty-R1!"); + } else if (data.contains("MUZHTEN")) { + // Sold as a Beauty-R1 but advertising the manufacturer name, with its own reports + match = true; + keypadType = KeypadType::MUZHTEN; + LOG_I("Found MUZHTEN keypad!"); } else if (data.contains("J06 Pro")) { match = true; keypadType = KeypadType::J06_PRO; @@ -485,17 +623,16 @@ glConnId = event->connect.conn_handle; isConnecting = false; - ble_uuid16_t hidUuid = { - .u = { .type = BLE_UUID_TYPE_16 }, - .value = HID_REPORT_CHAR_UUID - }; + // Some keypads hide their HID service until the peer is bonded. Discovery is redone + // in BLE_GAP_EVENT_ENC_CHANGE once encryption is up. + rc = ble_gap_security_initiate(glConnId); + if (rc != 0) { + LOG_W("Unable to initiate BLE security; rc={}. Keeping the link unencrypted.", rc); + } // Ground-level discovery pass passing discoveryStub to register the endpoints - rc = ble_gattc_disc_chrs_by_uuid(glConnId, 1, 0xffff, &hidUuid.u, - BLEKeypad::discoveryStub, nullptr); - if (rc != 0) { - LOG_E("GATT query initialization failure; rc={}", rc); - } else { + rc = discoverHidChars(); + if (rc == 0) { paired = true; Event event = { EventKind::PAIRING_ON }; @@ -519,6 +656,8 @@ glConnId = BLE_HS_CONN_HANDLE_NONE; isConnecting = false; paired = false; + hidFound = false; + discoveryActive = false; Event event = { EventKind::PAIRING_OFF }; if (bleEventQueue) { @@ -551,10 +690,12 @@ case BLE_GAP_EVENT_ENC_CHANGE: { if (event->enc_change.status == 0) { LOG_D("Security Encryption established successfully! Device is now secured & bonded."); + + // A keypad gating its HID service behind encryption only reveals it now. + discoverHidChars(); } else { - LOG_E("Security encryption negotiation failed; status={}", event->enc_change.status); - // If security fails, force a connection reset to clear bad state - ble_gap_terminate(glConnId, BLE_ERR_REM_USER_CONN_TERM); + // Not fatal: keypads exposing their HID service on a plain link keep working. + LOG_W("Security encryption negotiation failed; status={}", event->enc_change.status); } return 0; } @@ -568,6 +709,7 @@ // --- GATT CHARACTERISTIC PARSER LOOP --- auto BLEKeypad::handleDiscovery(const struct ble_gatt_chr *chr) -> void { if (chr->properties & BLE_GATT_CHR_PROP_NOTIFY) { + hidFound = true; LOG_W("Found valid HID Notification Handle at: {}. Activating stream...", chr->val_handle); uint16_t cccdHandle = chr->val_handle + 1; diff --git a/components/ble_keypad/src/ble_keypad.hpp b/components/ble_keypad/src/ble_keypad.hpp index b1cbd83a..cafaf1e0 100644 --- a/components/ble_keypad/src/ble_keypad.hpp +++ b/components/ble_keypad/src/ble_keypad.hpp @@ -16,6 +16,7 @@ #include "services/gap/ble_svc_gap.h" #include + #include #define TRACING_BLE_KEYPAD 0 @@ -31,7 +32,7 @@ private: static constexpr const char *TAG = "BLEKeypad"; - enum class KeypadType : int8_t { NONE = 0, BEAUTY_R1 = 1, J06_PRO = 2 }; + enum class KeypadType : int8_t { NONE = 0, BEAUTY_R1 = 1, J06_PRO = 2, MUZHTEN = 3 }; // Singleton pointer used by static C callbacks to route back into C++ object context static BLEKeypad *instance; @@ -49,6 +50,10 @@ bool isConnecting{ false }; bool paired{ false }; + // Guards used to keep the two discovery attempts from overlapping + bool hidFound{ false }; + bool discoveryActive{ false }; + // Standard SIG BLE UUID Definitions for HID Devices static constexpr uint16_t HID_REPORT_CHAR_UUID = 0x2A4D; static constexpr uint16_t BLE_CCCD_UUID = 0x2902; @@ -63,8 +68,10 @@ auto processJ06ProPacket(const uint8_t *data, size_t length) -> void; auto processBeautyR1Packet(uint8_t *data, size_t length) -> void; + auto processMuzhtenPacket(const uint8_t *data, size_t length) -> void; // --- C++ NimBLE Class Instance Handlers --- + auto discoverHidChars() -> int; auto handleGapEvent(struct ble_gap_event *event) -> int; auto handleDiscovery(const struct ble_gatt_chr *chr) -> void; auto handleSubscription(int status, uint16_t attrHandle) -> void; @@ -82,6 +89,7 @@ // 1. Check if the discovery procedure completed or encountered an error if (error->status != 0) { + if (instance) { instance->discoveryActive = false; } if (error->status == BLE_HS_EDONE) { ESP_LOGI("BLEKeypad", "Characteristic discovery process completed successfully."); } else { @@ -114,6 +122,16 @@ uint8_t *data = (uint8_t *)malloc(len); if (data) { os_mbuf_copydata(om, 0, len, data); + + #if TRACING_BLE_KEYPAD + // Each handle is a distinct HID report, tagged here to tell the streams apart + std::string hex; + for (uint16_t i = 0; i < len; i++) { + hex += std::format("{:02x} ", data[i]); + } + LOG_I("RX handle={} len={}: {}", attrHandle, len, hex.c_str()); + #endif + switch (instance->getKeypadType()) { case KeypadType::BEAUTY_R1: instance->processBeautyR1Packet(data, len); @@ -121,6 +139,9 @@ case KeypadType::J06_PRO: instance->processJ06ProPacket(data, len); break; + case KeypadType::MUZHTEN: + instance->processMuzhtenPacket(data, len); + break; default: LOG_W("Unknown BLE Keypad Type!"); break; diff --git a/components/config/src/config.hpp b/components/config/src/config.hpp index ec216c8f..e69feb2f 100644 --- a/components/config/src/config.hpp +++ b/components/config/src/config.hpp @@ -96,7 +96,7 @@ enum class ConfigIdent { static const double defaultBatteryTrim = 4.086 / 4.26; static const int8_t defaultLineHeight = 1; // 0 = TIGHT, 1 = MEDIUM, 2 = LARGE static const int8_t defaultColumnCount = 1; // 1 to 4 - static const int8_t defaultBtKeypadType = 0; // 0 = NONE, 1 = Beauty_R1, 2 = J06_PRO + static const int8_t defaultBtKeypadType = 0; // 0 = NONE, 1 = Beauty_R1, 2 = J06_PRO, 3 = MUZHTEN template <> Config::CfgType Config::cfg = { {