Slab Keyboard v3
An open-source modular mechanical keyboard system.
GitHub Links
- headblockhead/slab
- headblockhead/slab-firmware
- headblockhead/SQUIRREL
- headblockhead/slab-case
- headblockhead/slab-pcb
- headblockhead/OpenRGB (fork with Slab V2 support)
- headblockhead/pico-ssd1306 (fork with Nix Flake support)
Feature List
- USB-C
- Hot-swap switches
- Column-staggered ortholinear layout
- Full per-key RGB LED lighting
- South-facing switches
- MX, Choc V1, or Choc V2 switch support
- 4-Pin magnetic connectors for chaning modules
- 128x32 OLED displays
- Analog sliders read with a 16-bit ADC
- Onboard buzzer/beeper
- Low-profile rotary encoders, with a shine-through LED
- Optional TRRS support for chaining modules too!
Video showcase
I recorded a quick showcase video for the project, to show off the hardware in its final form. Enjoy!
Hardware
All hardware is open-source, with custom-designed PCBs for each module, made in KiCAD. The 3D-Printable cases are generated using an OpenSCAD script to allow for quick and easy production of new cases.
PCB prototypes were provided for free by PCBX (sponsored link).
3D-Printable case
Originally, the case was designed using FreeCAD, however after making one case, I realised it would take too long, and be incredibly boring, to make many of the same, slightly different keyboard cases. I had heard of OpenSCAD (a script-based CAD program where models are defined programmatically), and wanted to know more about it.
I decided to design the case as my very first OpenSCAD project, using a central 'case.scad' file to control the main generation of cases so all cases could be updated simultaneously. I defined each case's width, height, and stagger-amounts in variables stored in each case file. (e.g. 'right.scad')
Circuitboard design considerations
I chose to use IO expanders to read each switch separately instead of wiring them in a matrix, as it decreases the component count and assembly cost by removing the need for diodes, and frees GPIO to allow for adding more features, such as RGB LEDs and rotary encoders.
I also chose to use a Seed Studio Xiao RP2040, instead of adding the RP2040 directly to the PCB as it makes the board possible to hand-solder using just an iron, making prototyping significantly cheaper as factory assembly services are not required for prototypes.
I added UART pins for debugging the board while developing, however I didn't get much use from them as most issues I encountered either hung or crashed the processor, meaning log output either froze, or simply never occured. The most important pins for debugging are single-wire-debug (SWD) pins, which sadly aren't exposed on the Xiao RP2040, so most debugging used educated-guesswork, along with countless reads and re-reads of the communication code.
For communication between modules, I chose to use I²C, but not how it is used typically. Typically, all devices share a single bus, and a master controls slaves using a 7-bit address, however this means board addresses must be managed centrally making future or community-added boards potentially incompatible with eachother.
Instead, I decided to use the RP2040's two I²C buses, each board being both a master and a slave. Each board's master bus controlls onboard hardware, for example the IO expanders and display, but crucially also acts as the master for other board's slave bus. This means boards could theoretically be chained together infinitely, without running out of addresses.
This introduces some unique challenges when, for example, the USB port is not plugged in to the leftmost board, 'layer' keys are used, or more than three boards are connected. I'll discuss more on how I solved these issues in the firmware section.
Mistakes and revisions
My first PCB had a fatal flaw, the magnetic connectors had been flipped upside-down in the design, connecting voltage directly to ground when boards were connected.
A few other changes I had to make were due to simple circuit errors, such as accidently adding a transistor to step up the voltage of a 3V buzzer to 5V, and creating some magic smoke.
One very strange issue I found was that the top half of the analog sliders entirely registered as 100%, despite the slider clearly not being at its max position. I guessed that the ADC was reading a higher voltage than it could handle, and I re-read through its datasheet. Sure enough, it expects a max of 2.048V to compare with its onboard reference. I calculated the correct resistance of resistor to add before the slider, and to test, I simulated the circuit, then bodged my current circuitboard by cutting the trace, and soldering a resistor of approximately the correct value in-between the broken trace. I tested it, and it worked great! Finally, I updated the next revision of the PCB to include a spot for an SMD resistor of equivalent value.
Firmware
The firmware was written from scratch in C for the RP2040, using the Pico SDK and TinyUSB libraries. All keyboard logic is implemented in my SQUIRREL library, to provide QMK-style layers.
A speicific goal I wanted to achieve by creating and using my own keyboard library is to remap keys while the keyboard is running, and to make implementing the communication between modules easier by having control over the code and interfaces with the keyboard logic.
Because I thought it'd be cool, I made keys call simple arbitrary functions when pressed or released, meaning that the keys on your keyboard can be mapped to standard keyboard keys, a function which SQUIRREL provides, but could theoretically do anything!
Unique challenges
Communication
I²C is one-way. Data is sent from master to slave, or data is read from the slave to the master. The slave never transmits to the master. This is a problem, as we need boards to share data bi-directionally! A solution to this is to have the master constantly request data from the slave (polling), which uses some I²C bus time every cycle of the main loop, but is OK for this usage (transmitting small amounts of data quickly).
Because there is no single board controlling all of the others, I initially differentiated the boards based on which one had a USB plugged into it, commanding all other boards to send data towards that board. This worked initially, but was fundamentally incompatible with other features, like sharing the currently active keyboard layers across all boards.
I re-wrote the entire communication protocol to be based on the leftmost and rightmost boards in a chain of boards strung together.
Boards detect whether they are leftmost, rightmost, or somewhere in the middle by first attempting to read from their slave board (to the left). If the read is not acknowledged, the keyboard assumes there are no other keyboard on its left, and 'leftmost' is true. To detect if boards are in the rightmost position, each board sends 'alive' packets (single bytes) to the slave to its left every ~25ms. If a slave detects it hasn't recieved any 'alive' packets in 100ms, it assumes it has no boards on its right, so 'rightmost' is true. If non of these contitions are true, the board is somewhere between other boards.
Now that we know which board is the leftmost, and which is the rightmost, the rightmost board can start a communication chain.
- First, the rightmost board sends an 'accumulation' packet leftward (down the chain), that contains the rightmost board's keyboard data (keys pressed, modifiers, layers, etc.).
- Then, the next board in the chain adds its own keyboard data to the packet and forwards it another step down the chain. (hence 'accumulation')
- This repeats until the accumulation packet has reached the leftmost (bottom) board.
- Instead of forwarding the accumulation packet, the leftmost board stores it into the keyboard system, where it is then read by the board before it. (up the chain)
- The board before the leftmost board stores the packet, where it is then read by the next one up the chain, and so on.
- This repeats until the accumulated packet reaches the rightmost board, which stores it, meaning all boards now share the same data, so the rightmost board can restart the whole process again.
Building + CI
I chose Nix for building all of the Slab project's files, as I am familliar with it, it creates a convinient way of exactly reproducing a commit's firmware file for support and debugging, and helps to keep builds consistent between CI, maintainers, contributers, forks, etc.
I made a fork of a popular SSD1306 (OLED display) driver for the RP2040 to add a Nix flake, allowing me to pin the exact versions of libraries the project uses per-commit, which Git's submodules feature struggles to do reliably.
Software
There is no current software for the V3 Slab keyboard. I created a fork of OpenRGB for controlling V2's LEDs, however that now needs to be overhauled as the keyboard can change size while plugged in, which is difficult to manage.
I plan to add OpenRGB support in the future, but the most important thing right now is to get the keyboard stable, and to flesh-out other aspects of the firmware, for example rotary encoder and slider support.