Firmware

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.

01 Overview

Every Hyperboard half runs the exact same binary shape, compiled with a different combination of features:

Feature pairRole
master + leftOwns USB, resolves the keymap, drives HID reports
master + rightSame, mirrored for the right-hand board
slave + leftScans its own matrix only, relays raw events over UART
slave + rightSame, 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.

02 Split architecture

Each RP2040 runs both its cores from boot. Core0 and core1 split the work differently depending on role:

  • Core0 (both roles): on master, polls the USB device and turns keymap output into HID reports; on slave, polls its own USB reset interface (so it can still be flashed directly) and forwards inter-core FIFO traffic to UART.
  • Core1 (both roles): owns the PIO matrix scanner, the UART link to the other half, and the OLED, none of which core0 ever touches directly.

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:

  1. Core1 scans the matrix (or reads an event forwarded from the slave half over UART) and pushes it onto the FIFO.
  2. Core0 reads the FIFO, resolves the keycode through the keymap and active layer, and updates the pending HID report.
  3. Core0's main loop notices the report is dirty and writes it to whichever USB endpoint the host actually negotiated.

03 Matrix scanning

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.

04 Wire protocol

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:

TypeCarries
KeystrokeSource (left/right), press/release, raw row & column
LED commandAn RGB/brightness/display-toggle keycode, relayed so both halves' strips stay in sync
Layer syncThe active layer index, broadcast from master so a slave's OLED can reflect it
Key displayA 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.

05 Keymap & layers

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.

06 USB HID

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.

07 RGB underglow

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.

08 OLED display

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.

09 Flashing & bootloader recovery

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.

CommandProduces
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