Unicomp Mini M with CircuitPython
Unicomp is the heir to the IBM Buckling Spring legacy. This style of keyboard has a distinctive sound & feel which some people just can't get enough of. I'm one of them, having used a crusty old Model M for at least a decade at work.
Their "Mini M" is an 87-key ("tenkeyless") layout, and in late summer 2023 the controller was replaced with a board that has a Raspberry Pi Pico soldered on to it.
It's too soon for me to have formed a strong opinion about this keyboard, though I will note that the cable seems to be in the category of "incredibly cursed": It is a USB A-A cable, which should not exist. If your computer is USB-C only you're entering dongle city, but, you're probably used to that.
In this mini-guide, you'll learn how to replace the factory firmware with a fully customizable CircuitPython firmware. Of course, once you have the controller in UF2 bootloader mode, you could also use Arduino or Pico-SDK to program it with the software of your choice.
Note that this involves opening up the keyboard enclosure and there is always the possibility that you will damage the keyboard while doing so.
Reverse Engineering
I don't know who all to credit for the reverse engineering, but all the information I needed was in a fork of the open source keyboard controller "Vial-QMK". Here are the basics:
- Keyboard is organized as a 16x12 matrix, with the columns controlled by a pair of 8-channel muxes. Together, pico pins GP0 through GP4 select one of 16 column channels, with just one of GP3 or GP4 selected by setting to a False pin value
- The 12 row inputs are GP11 through GP22
- The LEDs are on GP6 through 8, with a common anode on GP5 (so set GP5 True always, and GP6 through 8 to False to light the corresponding LED; GP6 is Num Lock)
- The matrix has no diodes, so anti-ghosting must be done in software
Installing CircuitPython & the keyboard code
Undo the two screws on the bottom rear of the keyboard and carefully remove the top cover.
Move the keyboard assembly out of the way so you can access the controller PCB. Take care not to damage the fragile flexible PCBs in the process.
While holding down the BOOTSEL button on the Pi Pico, plug the USB cable into your computer.
It will appear as an RPI-RP2 drive. If the RPI-RP2 drive is not recognized by your computer after a few seconds, unplug the keyboard and repeat the steps.
Need more help getting started with CircuitPython or Raspberry Pi Pico? Adafruit has it covered in this guide.
Now, make a back-up of your device's original firmware using picotool:
picotool save -a original_firmware_mini_m.uf2 -t uf2
If for any reason you don't want a custom firmware anymore, you can copy this back to the RPI-RP2 bootloader drive.
Next, copy a CircuitPython UF2 to the device (I used 8.x for this project).
From the bundle grab the adafruit_hid library and copy it to CIRCUITPY\lib.
Grab the raw version of code.py from the gist below, copy it to CIRCUITPY.
After a few moments, the board will re-start with your code. You can type in the keyboard just like normal!
If it doesn't work as expected, open a serial terminal to check for Python errors. Otherwise, congratulations! You now have a keyboard running CircuitPython!
Is your mind brimming with ideas about how to customize how your keyboard works? Read below for an overview of some key parts of the code.
Scanning the matrix with CircuitPython
Because this kind of matrix column select is not supported by CircuitPython's KeyMatrix, I had to write a keyboard scanner in pure Python. This isn't the fastest but worked well for my purposes.
The scanner is written as a generator; it will output a series of numbers, which combine the key_number
and an is_pressed
flag. Each time it has scanned the whole matrix it will output the special value None
which is a cue to the main loop to do "other things", such as updating the LEDs.
One step of the scanner is simply to capture the current state of every key into an array; this also tracks auxiliary information "counts_by_row" (the number of pressed keys in each row) and "counts_by_column" (the number of pressed key in each column), which will be used for ghost detection. This is done by the scan1 nested function.
ROWS = const(12) COLS = const(16) N = const(ROWS*COLS) def scanner(): def scan1(state): for row in range(ROWS): counts_by_row[row] = 0 for column in range(COLS): A0.value = column & 1 A1.value = column & 2 A2.value = column & 4 if column & 8: EN_U2.value = False else: EN_U1.value = False n = 0 for row in range(len(ROW)): pin = ROW[row] i = row + column * ROWS # never fill a matrix position with no associated key # this improves rollover for instance, A+F1+W cause a ghost # at matrix location that is otherwise unused. By not # recording a value at the ghost location, the ghost # suppression code below won't activate. if kc_unused[i]: continue value = not pin.value n += value counts_by_row[row] += value state[i] = value counts_by_column[column] = n if column & 8: EN_U2.value = True else: EN_U1.value = True
Continuing in the scan function, "scan1" is performed repeatedly until the output matches. Then, ghost key presses (ones which come from a row with at least two keys AND a column with at least two keys) are eliminated. Finally, the changes are "yield"ed.
def scanner(): def scan1(state): ... # as above counts_by_column = [0] * COLS counts_by_row = [0] * ROWS state = [0] * N latch = [0] * N old_state = [0] * N while True: yield None # at the start of a scan scan1(state) scan1(latch) while state != latch: # something changed state, latch = latch, state scan1(latch) for column in range(COLS): if counts_by_column[column] < 2: continue for row in range(ROWS): i = row + column * ROWS if not state[i]: continue if counts_by_row[row] < 2: continue state[i] = old_state[i] for i in range(N): if state[i] != old_state[i]: yield i | (state[i] << 15) old_state, state = state, old_state
A pair of long tables (the second for numlock mode) are used for converting key numbers into USB HID keycodes (the second table only has to include items that differ from the non-numlock mode):
keycodes = { 12: K.ESCAPE, 26: K.F1, 48: K.F2, 60: K.F3, ... } keycodes_l1 = { 89: K.KEYPAD_SEVEN, 94: K.KEYPAD_EIGHT, 93: K.KEYPAD_NINE, ... }
The program's forever loop just scans the keyboard, sends USB HID events, and reacts to keyboard LED changes. If the first LED is lit, then the "layer 1" (numlock) keycodes are used.
The code is written so that after the initial setup no additional memory is ever allocated. This avoids the possibility of a long delay between scans of the key matrix. Avoiding memory allocations is why internal functions like kbd._add_keycode_to_report
are called.
Whenever the code exits (e.g., because of an error or because code.py was edited), the finally block makes sure all keys are released so you aren't left with a key stubbornly still held down:
led = ~0 gc.disable() # So we never get GC pauses try: for ev in scanner(): if ev is None: # complete scan, poll LEDs leds = kbd.led_status if leds and leds[0] != led: led = leds[0] for i in range(3): LEDS[i].value = not (led & (1 << i)) continue key_number = ev & 0xfff pressed = ev >> 15 keycode = keycodes.get(key_number) if led & 1: keycode = keycodes_l1.get(key_number, keycode) # the normal methods aren't used so that gc allocations can be avoided if keycode is not None: if pressed: kbd._add_keycode_to_report(keycode) else: kbd._remove_keycode_from_report(keycode) kbd._keyboard_device.send_report(kbd.report) finally: kbd.release_all()
More on ghost detection
The algorithm used here for ghost detection is ad-hoc but seems to work well: It does not strictly limit the keyboard to 2KRO, though many combinations of 3 or more keys are not permitted. Exactly which ones work depend on the key matrix, which Unicomp says they have designed to effectively provide up to 10KRO.
One example of a set of keys that cause ghosting is a+s+space. Without ghost detection, this causes a spurious down-arrow key to also be seen.
This happens because of the locations of these keys in the matrix. Key "a" is at (column, row) = (11, 2), key "s" is at (0, 2), key "space" is at (0, 10) , and "cursor down" is at (11, 10).
When the switches at (11, 2), (0, 2), and (0, 10) are all closed, this creates a path from column 11 to row 10 through the 3 closed switches, even though the switch at (11, 10) is open, and makes the cursor down key appear as a ghost.
Here's what the algorithm does to prevent ghosting: Suppose A and S are held and space is pressed. Now, columns 0 and 11 have 2 keys pressed, and rows 2 and 10 have 2 keys pressed. This causes ghost detection to trigger.
For all 4 of those positions, the prior value at those positions is kept instead of being updated. This means that the result of the ghost detection is to continue pressing A and S, but to not start pressing space or cursor down. The ghost key (cursor down) is avoided, at the cost of not allowing the desired key (space) to be reported either.
If either A or S is released before space is released, then ghost detection will end and the desired key (space) will be reported at that time. Otherwise, if space is released first, it will never be reported.
There are also combinations of 3 keys where the 4th position on the matrix is unused. In this case, it's possible to treat the 3 keys unambiguously. The kc_unused
variable allows efficiently checking for this. An example combination that is enabled by this check is A+F1+W.
For sloppy typists like me, who let fingers linger on keys after pressing them, good ghost avoidance is key to a good keyboard experience.
For more information, see this article from geekdroids (the source for the below illustration). Admiral Shark hosts a key matrix emulator where you can see more about how various keys interact.
Overclocking
What's better than measuring performance and deciding whether it's good enough? Turning every "go faster" knob you have available. That's why the firmware overclocks the RP2040 microcontroller to 240MHz. This is not officially supported by Raspberry Pi and might damage your keyboard, so I recommend for greatest safety that you comment out the following lines in code.py:
print("overclocking") microcontroller.cpu.frequency = 240_000_000