Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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|
Expand Down
4 changes: 4 additions & 0 deletions SDCard/config.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 7 additions & 1 deletion SDCard/config_distrib.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -63,4 +68,5 @@ tz = ""
battery_trim = 0.93
line_height = 1
column_count = 1
bt_keypad_mac = "00:00:00:00:00:00"
bt_keypad_mac = "00:00:00:00:00:00"
bt_keypad_type = 0
166 changes: 154 additions & 12 deletions components/ble_keypad/src/ble_keypad.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
Expand All @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand All @@ -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;
Expand Down
23 changes: 22 additions & 1 deletion components/ble_keypad/src/ble_keypad.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
#include "services/gap/ble_svc_gap.h"

#include <functional>
#include <string>

#define TRACING_BLE_KEYPAD 0

Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -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 {
Expand Down Expand Up @@ -114,13 +122,26 @@
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);
break;
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;
Expand Down
2 changes: 1 addition & 1 deletion components/config/src/config.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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 = { {
Expand Down