Skip to content

Instantly share code, notes, and snippets.

@wez
Last active October 4, 2024 02:31
Show Gist options
  • Save wez/b30683a4dfa329b86b9e0a2811a8c593 to your computer and use it in GitHub Desktop.
Save wez/b30683a4dfa329b86b9e0a2811a8c593 to your computer and use it in GitHub Desktop.

Revisions

  1. wez revised this gist Nov 25, 2017. 2 changed files with 3 additions and 0 deletions.
    Binary file not shown.
    3 changes: 3 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -10,6 +10,9 @@ Parts:
    * 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

  2. wez revised this gist Nov 25, 2017. 1 changed file with 14 additions and 3 deletions.
    17 changes: 14 additions & 3 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -17,12 +17,23 @@ This is a bluetooth peripheral that scans its matrix and publishes the data via

    ## 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.
    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.
    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.
    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.
  3. wez revised this gist Nov 25, 2017. 3 changed files with 0 additions and 0 deletions.
    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
  4. wez revised this gist Nov 25, 2017. 1 changed file with 28 additions and 0 deletions.
    28 changes: 28 additions & 0 deletions readme.md
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,28 @@
    # 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.

    ## 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.
  5. wez revised this gist Nov 25, 2017. 3 changed files with 708 additions and 0 deletions.
    706 changes: 706 additions & 0 deletions case.svg
    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
    2 changes: 2 additions & 0 deletions matrix.svg
    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
    Binary file added schematic.png
    Loading
    Sorry, something went wrong. Reload?
    Sorry, we cannot display this file.
    Sorry, this file is invalid so it cannot be displayed.
  6. wez created this gist Nov 25, 2017.
    115 changes: 115 additions & 0 deletions lhs.ino
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,115 @@
    #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)
    {
    }

    482 changes: 482 additions & 0 deletions rhs.ino
    Original file line number Diff line number Diff line change
    @@ -0,0 +1,482 @@
    #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)
    {
    }