Firmware
The firmware is written in Rust, built four different ways:
master/slave x left/right, via
Cargo feature flags. It talks to the RP2040's dual Cortex-M0+ cores directly through
rp2040-hal, no RTOS, no keyboard firmware framework. Matrix scanning,
debouncing-by-event, the keymap, USB HID, RGB, and the OLED driver are all
hand-written for this board.
Every Hyperboard half runs the exact same binary shape, compiled with a different combination of features:
| Feature pair | Role |
|---|---|
master + left | Owns USB, resolves the keymap, drives HID reports |
master + right | Same, mirrored for the right-hand board |
slave + left | Scans its own matrix only, relays raw events over UART |
slave + right | Same, mirrored for the right-hand board |
Which physical half is master is just a build-time choice: whichever half plugs
into the host over USB is the one flashed with
master. The other is flashed slave and only ever talks to
its sibling over UART.
Each RP2040 runs both its cores from boot. Core0 and core1 split the work differently depending on role:
Core0's loop is kept deliberately lean. On master it does nothing but
usb_dev.poll(), drain the keymap's HID report, and tick the LED
animation. Matrix scanning, UART framing, and display flushing all live on
core1. Piling extra work onto core0's loop delays USB polling enough to
break enumeration on some hosts.
The two cores talk over the RP2040's inter-core FIFO (sio.fifo),
passing the same 32-bit
packed event format used for UART, so a key event from core1's
matrix scanner, an LED command, and a layer-sync broadcast all flow through one queue
with no separate message types to maintain.
On the master half, one further hop happens per keystroke:
Each half scans its own 5x6 matrix (ROWS = 5,
COLS = 6) entirely in PIO, not the CPU. A single state machine
drives the six column pins and samples all row inputs in one shot per column, so
the scan runs independently of whatever core1's Rust code happens to be doing at
that instant.
MatrixScanner::poll reads the raw 32-bit GPIO snapshot back out of the
PIO RX FIFO, decodes which column is currently being driven and which rows read low,
and XORs against the last-seen state for that column, so only changed keys
ever produce an event. There's no separate debounce timer: a key's state is exactly
what the last scan of its column said it was.
pub fn poll<const N: usize>(&mut self, outbox: &mut EventQueue<N>) {
if let Some(raw_snapshot) = self.pio_rx_fifo.read() {
let current_col = self.col_decoder.decode(raw_snapshot);
let decoded_row = self.row_decoder.decode(raw_snapshot);
let changed = decoded_row ^ self.last_row_state[current_col as usize];
// ...emit a Press/Release event for each bit that changed
}
} Left and right boards use mirrored pin sets and column ordering (the right half's columns are decoded in reverse) so that both halves report positions in the same logical row/column space before the keymap ever sees them.
Every message that moves between cores and between the two halves over UART
is the same 32-bit PackedEvent. One bit distinguishes a plain
matrix keystroke from a broadcast/action packet; three more subtype bits, only
meaningful on the latter, pick between an LED command, a layer-sync broadcast,
or a resolved keycode pushed to the OLED:
| Type | Carries |
|---|---|
| Keystroke | Source (left/right), press/release, raw row & column |
| LED command | An RGB/brightness/display-toggle keycode, relayed so both halves' strips stay in sync |
| Layer sync | The active layer index, broadcast from master so a slave's OLED can reflect it |
| Key display | A resolved keycode + its glyph, pre-encoded by master so the receiving OLED never needs a keycode → glyph lookup |
On UART, each packet is framed with a two-byte 0xAA 0x55
preamble followed by the 4 little-endian payload bytes.
Comms resyncs on the preamble byte-by-byte, so a dropped or corrupted
byte only ever costs the one in-flight packet.
KeyMap::map resolves a raw (row, col) plus which half it
came from into a KeyCode, walking the layer stack from the
currently active layer down to the base layer and stopping at the first
non-transparent (Trns) entry. The keyboard has NUM_LAYERS = 2, toggled with momentary layer keys (MO0/MO1); hold,
don't lock.
This resolution only ever runs on the master half; a slave never touches the keymap at all; it just forwards raw matrix events upstream. Layer-key presses on master additionally broadcast a layer-sync packet so the slave half's OLED can show the current layer without re-deriving it.
The master half exposes two HID interfaces at once: a fixed-format
6-key Boot interface for BIOS/UEFI and other pre-driver environments, and a 128-bit
bitmap NKRO Report interface for everything else. Which one gets written is decided
per-report by whatever the host actually negotiated via SET_PROTOCOL:
match hid.protocol() {
Protocol::Boot => hid.write_boot(&boot_report),
Protocol::Report => hid.write_nkro(&nkro_report),
} Writing both endpoints unconditionally used to cause duplicate keystrokes once a full OS HID driver bound both interfaces; writing only the Report endpoint left BIOS/UEFI screens (which read the boot interface exclusively) with no input at all. Watching the negotiated protocol fixes both.
A separate vendor-class reset interface lets
picotool load --force reboot a running board straight into the bootloader
over its own USB port, without touching the physical BOOTSEL button. See Flashing.
29 SK6812 addressable LEDs per side are driven by a second PIO state machine,
clocked for the LED's 800 kHz bit timing and fed 24-bit GRB words with autopull.
Four effects (Solid,
Breathe, Wave, Rainbow) are rendered
per-frame on a wall-clock schedule (not a frame counter), so a momentarily busy
core1 catches up on timing rather than drifting or stalling.
Effect, color, and brightness are all keycode-driven and relayed as LED command packets, so cycling effects on one half keeps both sides in sync.
Each half drives its own I²C OLED. Rendering is push-based: master broadcasts
key-display, layer-sync, and LED-state packets as they happen, and each half's DisplayState re-renders locally only when something actually changed. A screensaver animation
ticks independently on a fixed interval once the display's been idle.
Flushing a full frame to the panel over I²C is comparatively slow, so it's
chunked: flush_step pushes one bounded chunk per main loop iteration
instead of blocking until the whole framebuffer is out, which keeps matrix scanning
and UART servicing on core1 from ever stalling behind a display update.
cargo run is wired to a wrapper around
picotool that works around a quirk where
picotool load -f silently skips force-rebooting a running board unless
invoked in a specific two-step sequence: force the target into BOOTSEL mode first,
poll until it's actually there, then hand off to a plain picotool load.
| Command | Produces |
|---|---|
cargo build --no-default-features --features master,left | Left half, master |
cargo build --no-default-features --features master,right | Right half, master |
cargo build --no-default-features --features slave,left | Left half, slave |
cargo build --no-default-features --features slave,right | Right half, slave |
Recovery, if a half won't enumerate normally, goes through that same USB
reset/vendor interface. It works whenever the board's own firmware is still
running, no BOOTSEL button or jumper needed. On Linux that needs udev access to
the board's application-mode USB descriptor, granted once via udev/71-hyperboard.rules:
sudo cp udev/71-hyperboard.rules /etc/udev/rules.d/
sudo udevadm control --reload-rules
sudo udevadm trigger