Skip to content

Instantly share code, notes, and snippets.

@wez
Last active March 23, 2024 21:00
Show Gist options
  • Star 37 You must be signed in to star a gist
  • Fork 7 You must be signed in to fork a gist
  • Save wez/b30683a4dfa329b86b9e0a2811a8c593 to your computer and use it in GitHub Desktop.
Save wez/b30683a4dfa329b86b9e0a2811a8c593 to your computer and use it in GitHub Desktop.
Split Bluetooth Keyboard

Split bluetooth keyboard

Just some quick notes on something I built fairly quickly; they're published here in the hope that they might be slightly useful for others, or to help inspire others to build something better.

Parts:

  • 2x Adafruit nRF52 https://www.adafruit.com/product/3406 (I got the Pro version, but you'll need a JLink to use the arduino code in this gist if you get the pro, so the link here is the non-pro version)
  • 72x 1N4148 signal diodes
  • 72x Cherry MX compatible keyswitches
  • 6x 1.25u keycaps for modifiers
  • 66x 1u keycaps
  • 2x lipoly battery with JST connector.
  • hot glue to secure the switches in the top plate
  • 20x M3 spacers and standoffs for the case supports
  • 8x M2.5 (M2 will also work) nuts and bolts to mount the controllers

LHS - Left Hand Side

This is a bluetooth peripheral that scans its matrix and publishes the data via bleuart.

RHS - Right Hand Side

This runs as a dual role device; it runs as a Central device that connects to the LHS to read its matrix via bleuart, and runs a bluetooth HID peripheral to publish the combined matrices of the two halves.

You'll want to pair your device with the RHS only!

Wiring

This uses standard matrix wiring (which I've unhelpfully not shown here); each switch has a diode to prevent ghosting. The diodes are used to form the rows. The columns are shown in matrix.svg.

The rows are connected to the A0-A5 pins on the LHS of the controller. The columns are connected to the pins on the other side. Take care to avoid the special pin 31 which is used to sample the battery voltage.

Case

The included case.svg file is ready for use with ponoko.com. I used a blue matte acrylic in my build.

Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
#include <bluefruit.h>
BLEDis bledis;
BLEUart bleuart;
static const int colPins[] = {16,15,7,11,30,27};
static const int rowPins[] = {2,3,4,5,28,29};
void setup()
{
for (auto &pin: rowPins) {
pinMode(pin, OUTPUT);
digitalWrite(pin, HIGH);
}
for (auto &pin: colPins) {
pinMode(pin, INPUT_PULLUP);
}
Serial.begin(115200);
Bluefruit.begin();
Bluefruit.autoConnLed(false);
Bluefruit.setTxPower(0);
Bluefruit.setName("Scission LHS");
bledis.setManufacturer("Wez Furlong");
bledis.setModel("Handwire1");
bledis.begin();
bleuart.begin();
startAdv();
}
void startAdv(void)
{
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
Bluefruit.Advertising.addTxPower();
Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_KEYBOARD);
Bluefruit.Advertising.addService(bleuart);
Bluefruit.ScanResponse.addName();
Bluefruit.Advertising.restartOnDisconnect(true);
Bluefruit.Advertising.setInterval(32, 244); // in unit of 0.625 ms
Bluefruit.Advertising.setFastTimeout(30); // number of seconds in fast mode
Bluefruit.Advertising.start(0); // 0 = Don't stop advertising after n seconds
}
struct matrix_t {
uint8_t rows[6];
};
struct matrix_t lastRead = {0,0,0,0,0,0};
struct matrix_t read_matrix() {
matrix_t matrix = {0,0,0,0,0,0};
for (int rowNum = 0; rowNum < 6; ++rowNum) {
digitalWrite(rowPins[rowNum], LOW);
for (int colNum = 0; colNum < 6; ++colNum) {
if (!digitalRead(colPins[colNum])) {
matrix.rows[rowNum] |= 1 << colNum;
}
}
digitalWrite(rowPins[rowNum], HIGH);
}
return matrix;
}
void loop()
{
auto down = read_matrix();
uint8_t report[16];
uint8_t repsize = 0;
for (int rowNum = 0; rowNum < 6; ++rowNum) {
for (int colNum = 0; colNum < 6; ++colNum) {
auto mask = 1 << colNum;
auto current = lastRead.rows[rowNum] & mask;
auto thisScan = down.rows[rowNum] & mask;
if (current != thisScan) {
auto scanCode = (rowNum * 6) + colNum;
if (thisScan) {
scanCode |= 0b10000000;
}
report[repsize++] = scanCode;
}
}
}
if (repsize) {
lastRead = down;
#if 0
Serial.print("repsize=");
Serial.print(repsize);
Serial.print(" ");
for (int i = 0; i < repsize; i++) {
Serial.print(report[i], HEX);
Serial.print(" ");
}
Serial.print("\r\n");
#endif
bleuart.write(report, repsize);
}
// Request CPU to enter low-power mode until an event/interrupt occurs
waitForEvent();
}
void rtos_idle_callback(void)
{
}
Display the source blob
Display the rendered blob
Raw
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
#include <bluefruit.h>
BLEDis dis;
BLEHidAdafruit hid;
BLEClientUart clientUart;
BLEBas battery;
static uint8_t battery_level = 0;
static uint32_t last_bat_time = 0;
static const int colPins[] = {16,15,7,11,30,27};
static const int rowPins[] = {2,3,4,5,28,29};
struct matrix_t {
uint16_t rows[6];
};
struct matrix_t remoteMatrix = {0,0,0,0,0,0};
struct matrix_t lastRead = {0,0,0,0,0,0};
void resetKeyMatrix();
void setup()
{
for (auto &pin: rowPins) {
pinMode(pin, OUTPUT);
digitalWrite(pin, HIGH);
}
for (auto &pin: colPins) {
pinMode(pin, INPUT_PULLUP);
}
Serial.begin(115200);
resetKeyMatrix();
// Central and peripheral
Bluefruit.begin(true, true);
//Bluefruit.clearBonds();
Bluefruit.autoConnLed(false);
battery.begin();
Bluefruit.setTxPower(0);
Bluefruit.setName("Scission RHS");
Bluefruit.Central.setConnectCallback(cent_connect_callback);
Bluefruit.Central.setDisconnectCallback(cent_disconnect_callback);
dis.setManufacturer("Wez Furlong");
dis.setModel("Handwire1");
dis.setHardwareRev("0001");
dis.setSoftwareRev(__DATE__);
dis.begin();
clientUart.begin();
// clientUart.setRxCallback(cent_bleuart_rx_callback);
/* Start Central Scanning
* - Enable auto scan if disconnected
* - Interval = 100 ms, window = 80 ms
* - Filter only accept bleuart service
* - Don't use active scan
* - Start(timeout) with timeout = 0 will scan forever (until connected)
*/
Bluefruit.Scanner.setRxCallback(scan_callback);
Bluefruit.Scanner.restartOnDisconnect(true);
Bluefruit.Scanner.setInterval(160, 80); // in unit of 0.625 ms
Bluefruit.Scanner.filterUuid(BLEUART_UUID_SERVICE);
Bluefruit.Scanner.useActiveScan(false);
Bluefruit.Scanner.start(0); // 0 = Don't stop scanning after n seconds
hid.begin();
// delay(5000);
// Bluefruit.printInfo();
startAdv();
}
void cent_connect_callback(uint16_t conn_handle)
{
char peer_name[32] = { 0 };
Bluefruit.Gap.getPeerName(conn_handle, peer_name, sizeof(peer_name));
Serial.print("[Cent] Connected to ");
Serial.println(peer_name);
if (clientUart.discover(conn_handle)) {
// Enable TXD's notify
clientUart.enableTXD();
} else {
Bluefruit.Central.disconnect(conn_handle);
}
resetKeyMatrix();
}
void cent_disconnect_callback(uint16_t conn_handle, uint8_t reason)
{
(void) conn_handle;
(void) reason;
Serial.println("[Cent] Disconnected");
resetKeyMatrix();
}
void scan_callback(ble_gap_evt_adv_report_t* report)
{
// Check if advertising contain BleUart service
if (Bluefruit.Scanner.checkReportForService(report, clientUart)) {
// Connect to device with bleuart service in advertising
Bluefruit.Central.connect(report);
}
}
void startAdv(void)
{
Bluefruit.Advertising.addFlags(BLE_GAP_ADV_FLAGS_LE_ONLY_GENERAL_DISC_MODE);
Bluefruit.Advertising.addTxPower();
Bluefruit.Advertising.addAppearance(BLE_APPEARANCE_HID_KEYBOARD);
Bluefruit.Advertising.addService(hid);
Bluefruit.ScanResponse.addService(battery);
Bluefruit.Advertising.addName();
Bluefruit.Advertising.restartOnDisconnect(true);
Bluefruit.Advertising.setInterval(32, 244); // in unit of 0.625 ms
Bluefruit.Advertising.setFastTimeout(30); // number of seconds in fast mode
Bluefruit.Advertising.start(0); // 0 = Don't stop advertising after n seconds
}
static constexpr uint32_t kMask = 0xf00;
static constexpr uint32_t kKeyPress = 0x100;
static constexpr uint32_t kModifier = 0x200;
static constexpr uint32_t kLayer = 0x300;
static constexpr uint32_t kTapHold = 0x400;
static constexpr uint32_t kToggleMod = 0x500;
static constexpr uint32_t kKeyAndMod = 0x600;
typedef uint32_t action_t;
#define PASTE(a, b) a ## b
#define ___ 0
#define KEY(a) kKeyPress | PASTE(HID_KEY_, a)
#define MOD(a) kModifier | PASTE(KEYBOARD_MODIFIER_, a)
#define TMOD(a) kToggleMod | PASTE(KEYBOARD_MODIFIER_, a)
#define TAPH(a, b) kTapHold | PASTE(HID_KEY_, a) | (PASTE(KEYBOARD_MODIFIER_, b) << 16)
#define KANDMOD(a, b) kKeyAndMod | PASTE(HID_KEY_, a) | (PASTE(KEYBOARD_MODIFIER_, b) << 16)
#define LAYER(n) kLayer | n
#define KEYMAP( \
l00, l01, l02, l03, l04, l05, \
l10, l11, l12, l13, l14, l15, \
l20, l21, l22, l23, l24, l25, \
l30, l31, l32, l33, l34, l35, \
l40, l41, l42, l43, l44, l45, \
l50, l51, l52, l53, l54, l55, \
r00, r01, r02, r03, r04, r05, \
r10, r11, r12, r13, r14, r15, \
r20, r21, r22, r23, r24, r25, \
r30, r31, r32, r33, r34, r35, \
r40, r41, r42, r43, r44, r45, \
r50, r51, r52, r53, r54, r55) \
{l00, l01, l02, l03, l04, l05, r00, r01, r02, r03, r04, r05, \
l10, l11, l12, l13, l14, l15, r10, r11, r12, r13, r14, r15, \
l20, l21, l22, l23, l24, l25, r20, r21, r22, r23, r24, r25, \
l30, l31, l32, l33, l34, l35, r30, r31, r32, r33, r34, r35, \
l40, l41, l42, l43, l44, l45, r40, r41, r42, r43, r44, r45, \
l50, l51, l52, l53, l54, l55, r50, r51, r52, r53, r54, r55}
struct keystate {
uint8_t scanCode;
bool down;
uint32_t lastChange;
action_t action;
};
struct keystate keyStates[16];
uint8_t layer_stack[8];
static uint8_t layer_pos = 0;
void resetKeyMatrix() {
layer_pos = 0;
layer_stack[0] = 0;
memset(&remoteMatrix, 0, sizeof(remoteMatrix));
memset(&lastRead, 0, sizeof(lastRead));
memset(keyStates, 0xff, sizeof(keyStates));
hid.keyRelease();
}
void printState(struct keystate *state) {
Serial.print("scanCode=");
Serial.print(state->scanCode, HEX);
Serial.print(" down=");
Serial.print(state->down);
Serial.print(" lastChange=");
Serial.print(state->lastChange);
Serial.print(" action=");
Serial.print(state->action, HEX);
Serial.println("");
}
struct keystate* stateSlot(uint8_t scanCode, uint32_t now) {
struct keystate *vacant = nullptr;
struct keystate *reap = nullptr;
for (auto &s : keyStates) {
if (s.scanCode == scanCode) {
return &s;
}
if (!vacant && s.scanCode == 0xff) {
vacant = &s;
continue;
}
if (!s.down) {
if (!reap) {
reap = &s;
} else if (now - s.lastChange > now - reap->lastChange) {
// Idle longer than the other reapable candidate; choose
// the eldest of them
reap = &s;
}
}
}
if (vacant) {
return vacant;
}
return reap;
}
const action_t keymap[2][72] = {
// Layer 0
KEYMAP(
// LEFT
KEY(1), KEY(2), KEY(3), ___ /* BLUE */, KANDMOD(C,LEFTGUI),MOD(LEFTALT),
KEY(Q), KEY(W), KEY(E), KEY(4), KEY(5), MOD(LEFTGUI),
KEY(A), KEY(S), KEY(D), KEY(R), KEY(T), KEY(TAB),
KEY(Z), KEY(X), KEY(C), KEY(F), KEY(G), KEY(DELETE),
KEY(BACKSLASH), KEY(MINUS), KEY(EQUAL), KEY(V), KEY(B), KEY(BACKSPACE),
LAYER(1), KEY(BRACKET_LEFT), KEY(BRACKET_RIGHT), MOD(LEFTSHIFT), ___ /* REKT */, TAPH(ESCAPE, LEFTCTRL),
// RIGHT
MOD(RIGHTALT), KANDMOD(V,LEFTGUI), ___ /* RED */, KEY(8), KEY(9), KEY(0),
MOD(RIGHTGUI), KEY(6), KEY(7), KEY(I), KEY(O), KEY(P),
KEY(PAGE_UP), KEY(Y), KEY(U), KEY(K), KEY(L), KEY(SEMICOLON),
KEY(PAGE_DOWN), KEY(H), KEY(J), KEY(COMMA), KEY(PERIOD), KEY(SLASH),
KEY(SPACE), KEY(N), KEY(M), KEY(GRAVE), KEY(ARROW_UP), KEY(APOSTROPHE),
MOD(RIGHTCTRL), KEY(RETURN), MOD(RIGHTSHIFT), KEY(ARROW_LEFT), KEY(ARROW_DOWN), KEY(ARROW_RIGHT)
),
// Layer 1
KEYMAP(
// LEFT
KEY(F1), KEY(F2), KEY(F3), ___, ___, ___,
___, ___, ___, KEY(F4), KEY(F5), ___,
___, ___, ___, ___, ___, ___,
___, ___, ___, ___, ___, ___,
___, ___, ___, ___, ___, ___,
___, ___, ___, ___, ___, ___,
// RIGHT
___, ___, ___, KEY(F8), KEY(F9), KEY(F10),
___, KEY(F6), KEY(F7), ___, ___, ___,
___, ___, ___, ___, ___, ___,
___, ___, ___, ___, ___, ___,
___, ___, ___, ___, ___, ___,
___, ___, ___, ___, ___, ___
)
};
// Remote matrix is the LHS
void updateRemoteMatrix() {
while (clientUart.available() )
{
auto ch = (uint8_t) clientUart.read();
auto down = ch & 0x80;
ch &= ~0x80;
auto rowNum = ch / 6;
auto colNum = ch - (rowNum * 6);
if (down) {
remoteMatrix.rows[rowNum] |= 1 << colNum;
} else {
remoteMatrix.rows[rowNum] &= ~(1 << colNum);
}
#if 0
Serial.print("remote=");
Serial.print(ch, HEX);
Serial.print("\r\n");
#endif
}
}
struct matrix_t readMatrix() {
matrix_t matrix = remoteMatrix;
for (int rowNum = 0; rowNum < 6; ++rowNum) {
digitalWrite(rowPins[rowNum], LOW);
for (int colNum = 0; colNum < 6; ++colNum) {
if (!digitalRead(colPins[colNum])) {
matrix.rows[rowNum] |= 1 << (colNum + 6);
}
}
digitalWrite(rowPins[rowNum], HIGH);
}
return matrix;
}
void readBattery() {
auto now = millis();
if (now - last_bat_time <= 10000) {
// There's a lot of variance in the reading, so no need
// to over-report it.
return;
}
last_bat_time = now;
constexpr int VBAT = 31; // pin 31 is available for sampling the battery
float measuredvbat = analogRead(VBAT) * 6.6 / 1024;
uint8_t bat_percentage = (uint8_t)round((measuredvbat - 3.7) * 200);
bat_percentage = min(bat_percentage, 100);
if (battery_level != bat_percentage) {
battery_level = bat_percentage;
battery.notify(battery_level);
}
}
static uint32_t resolveActionForScanCodeOnActiveLayer(uint8_t scanCode) {
int s = layer_pos;
while (s >= 0 && keymap[layer_stack[s]][scanCode] == ___) {
--s;
}
return keymap[layer_stack[s]][scanCode];
}
void loop()
{
auto down = readMatrix();
bool keysChanged = false;
updateRemoteMatrix();
readBattery();
auto now = millis();
for (int rowNum = 0; rowNum < 6; ++rowNum) {
for (int colNum = 0; colNum < 12; ++colNum) {
auto scanCode = (rowNum * 12) + colNum;
auto isDown = down.rows[rowNum] & (1 << colNum);
auto wasDown = lastRead.rows[rowNum] & (1 << colNum);
if (isDown == wasDown) {
continue;
}
keysChanged = true;
auto state = stateSlot(scanCode, now);
if (isDown && !state) {
// Silently drop this key; we're tracking too many
// other keys right now
continue;
}
printState(state);
bool isTransition = false;
if (state) {
if (state->scanCode == scanCode) {
// Update the transition time, if any
if (state->down != isDown) {
state->lastChange = now;
state->down = isDown;
if (isDown) {
state->action = resolveActionForScanCodeOnActiveLayer(scanCode);
}
isTransition = true;
}
} else {
// We claimed a new slot, so set the transition
// time to the current time.
state->down = isDown;
state->scanCode = scanCode;
state->lastChange = now;
if (isDown) {
state->action = resolveActionForScanCodeOnActiveLayer(scanCode);
}
isTransition = true;
}
if (isTransition) {
switch (state->action & kMask) {
case kLayer:
if (state->down) {
// Push the new layer stack position
layer_stack[++layer_pos] = state->action & 0xff;
} else {
// Pop off the layer stack
--layer_pos;
}
break;
}
}
}
}
}
if (keysChanged) {
uint8_t report[6] = {0,0,0,0,0,0};
uint8_t repsize = 0;
uint8_t mods = 0;
for (auto &state: keyStates) {
if (state.scanCode != 0xff && state.down) {
switch (state.action & kMask) {
case kTapHold:
if (now - state.lastChange > 200) {
// Holding
mods |= (state.action >> 16) & 0xff;
} else {
// Tapping
auto key = state.action & 0xff;
if (key != 0 && repsize < 6) {
report[repsize++] = key;
}
}
break;
case kKeyAndMod:
{
mods |= (state.action >> 16) & 0xff;
auto key = state.action & 0xff;
if (key != 0 && repsize < 6) {
report[repsize++] = key;
}
}
break;
case kKeyPress:
{
auto key = state.action & 0xff;
if (key != 0 && repsize < 6) {
report[repsize++] = key;
}
}
break;
case kModifier:
mods |= state.action & 0xff;
break;
case kToggleMod:
mods ^= state.action & 0xff;
break;
}
}
}
#if 1
Serial.print("mods=");
Serial.print(mods, HEX);
Serial.print(" repsize=");
Serial.print(repsize);
for (int i = 0; i < repsize; i++) {
Serial.print(" ");
Serial.print(report[i], HEX);
}
Serial.print("\r\n");
#endif
hid.keyboardReport(mods, report);
lastRead = down;
}
// Request CPU to enter low-power mode until an event/interrupt occurs
waitForEvent();
}
void rtos_idle_callback(void)
{
}
@underpk
Copy link

underpk commented May 11, 2018

Thanks for the built log, I want to build my first split ergo now 👍

@vexchow
Copy link

vexchow commented Sep 8, 2018

That's very nice. I love it so much.
Can I make the key to be mouse right button?
Thanks Wez!

@xeaone
Copy link

xeaone commented Sep 11, 2018

This is awesome @wez! Thanks. I am curious though about the j-link, is it required or can you not use the Arduino IDE?

@wez
Copy link
Author

wez commented Oct 2, 2018

@AlexanderElias:

This is awesome @wez! Thanks. I am curious though about the j-link, is it required or can you not use the Arduino IDE?

You only need a J-Link if you buy the Pro version of the nRF52. The non-pro version can be programmed via the Arduino IDE.

@wez
Copy link
Author

wez commented Oct 2, 2018

@vexchow: yeah, you just need to adjust the code to call the right methods on BLEHidAdafruit to have it sound mouse info

@CarlosAurelioArt
Copy link

@wez Thank you SO MUCH for this!

I am in the process of making a one-handed keyboard to use with my iPad while I draw.

Your code is the best I found so far that was simple enough for a first-time builder like myself to understand and modify for my project.
I will be posting more on the build on my page very soon.

Thank you!

@fuo213
Copy link

fuo213 commented Jan 14, 2020

@wez

Curious how much of this could transfer over to other split wireless boards. I have a Chimera Ergo which currently requires a dongle to be connected to the computer. However in talking a bit with the creator, he suggested the hardware could be capable of running over bluetooth alone. Would be curious to hear your thoughts as I'd love to live a dongle-free life haha.

@wez
Copy link
Author

wez commented Jan 15, 2020

If you know how to connect to the two halves of the matrix from the controllers then you could make something like that work.

@BurningBright
Copy link

Does this solution require an additional receiver?
Can I use my laptop's built-in Bluetooth receiver?
How about the battery life?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment