keygrid · Rust · C ABI · JSONL · Keychron C100 8K

Keygrid

Keygrid turns the Keychron C100 8K, a 10×10 grid of switches with a light under each, into a programmable deck. It reports which physical key went down and up, and lights any key in any colour, through a JSONL pipe, a C library or a terminal prototype. It works with the stock firmware, and leaves what the keys mean to the program using it.

keygrid --device sim demo          # the prototype, simulated
keygrid --json devices
keygrid --device c100 probe        # read-only: capabilities, LED map
state | keygrid --device c100 io | actions
The Keygrid simulator in a terminal: a 10 by 10 grid of keys numbered 00 to 99 shaded in a rainbow gradient, key 01 highlighted, and a Press panel reading key 01, row 0, column 1, down, sequence 1, observed 161 milliseconds, source simulator, highlight 3 seconds.
The terminal prototype on the simulator: a press pops up its index, row, column and sequence and lights the key for three seconds.

Physical keys

Out of the box, most of the C100's keys type the digit 1, so as far as the operating system knows they are all the same key. Keygrid goes underneath the keymap: the stock firmware answers VIA's switch-matrix query over its vendor Raw HID interface, and Keygrid turns the row and column bits into press and release events.

Reading switches, not keycodes Why Keygrid reads the switch matrix. On the keyboard's stock factory keymap, most of the hundred keys send the same keycode, 1, so ordinary keyboard events cannot tell them apart, and virtual input injection cannot recover which switch was pressed. Keygrid instead asks the stock firmware for the VIA switch matrix over the vendor Raw HID interface: row and column bits taken before the firmware applies its keymap. It polls the matrix, at 125 Hz by default, and diffs successive snapshots into down and up events with the key's index, row and column. No custom firmware is needed. 100 switchesa 10 × 10 matrix factory keymapmost keys → 1 keyboard events1, 1, 1, 1 … VIA matrix queryRaw HID · before the keymap diff snapshots125 Hz poll key 42 downrow 4 · column 2 indistinguishable row/col bits
No custom firmware. A probe that reads the matrix with nothing held reports matrix_edges_verified: false until it has actually seen a transition.

Reading the matrix doesn't stop the keys typing. For a deck, a VIA keymap profile makes them silent; that writes the keyboard's persistent keymap, so it is a separate, explicit step that refuses to run without a new backup file, and a restore command brings the original back.

The deck60-type40 profile The deck60-type40 keymap profile drawn on the 10 by 10 grid. Rows 0 to 5, sixty keys, are assigned no keycode on every layer, so they type nothing and only Keygrid sees them: they are the deck, numbered 0 to 59 in row-major order. Rows 6 to 9 keep a forty-key typing block: Q W E R T Y U I O P, A S D F G H J K L semicolon, Z X C V B N M comma period slash, and Ctrl, GUI, Alt, Shift, Tab, Space, Fn, Enter, Backspace and Escape, with Fn giving digits, function keys, punctuation and navigation. 0 00 01 02 03 04 05 06 07 08 09 1 10 11 12 13 14 15 16 17 18 19 2 20 21 22 23 24 25 26 27 28 29 3 30 31 32 33 34 35 36 37 38 39 4 40 41 42 43 44 45 46 47 48 49 5 50 51 52 53 54 55 56 57 58 59 6 Q W E R T Y U I O P 7 A S D F G H J K L ; 8 Z X C V B N M , . / 9 Ctrl GUI Alt Shift Tab Space Fn Enter Bksp Esc deck silent · 60 typing normal · 40
Sixty for the deck, forty for typing. deck60-type40 keeps a small Planck-inspired typing block; deck100 silences all hundred keys.

Lighting

Lighting is a scene, not a stream of writes. A base frame holds the colours; overlays highlight keys for a while and expire back to whatever the base is now; Keygrid works out what changed and sends only that.

Painting the keys How Keygrid lights the keys. Clients send a complete base frame or patches to it, each with a strictly increasing revision, and temporary overlays with a time to live. The composite is the latest base with unexpired overlays on top, so when an overlay expires the latest base shows through. The composite is diffed against what the keyboard already shows, converted to hue and saturation at one global brightness (the firmware ignores per-key brightness), and sent as volatile RAM updates in packets of at most nine LEDs, one packet per tick, interleaved with input polling. A read-only check every 500 ms notices if a lighting button on the keyboard changed the mode and redraws. Lighting is never saved to the keyboard's EEPROM, and the original lighting is restored on exit. base framerevision 1, 2, 3 … overlaysttl 1–60000 ms compositelatest base + live overlays diff → HSVone global brightness RAM packets≤ 9 LEDs · 1 per tick 500 ms checkmode changed? redraw
Volatile, verified, restored. Lighting goes to the keyboard's RAM, never its EEPROM; a recovery file written before the first render puts the original lighting back after a crash or unplug.

The hardware has limits Keygrid states instead of hiding: brightness is global, so per-key dimming isn't possible and black becomes grey rather than off under uniform brightness; frames go out in several packets, so there is no atomic swap; and the colours reported back are a quantised approximation, not an optical measurement.

Interfaces

Every frontend (the CLI, the C library, the prototype, the Omni bridge) drives the same single session worker, so input and lighting share one device owner instead of competing HID readers. The JSONL pipe is the simplest way in: requests on stdin, replies and key events on stdout. Here it is against the simulator:

# requests on stdin
{"v":1,"id":"sub","op":"input.subscribe","region":"all"}
{"v":1,"id":"paint","op":"frame.patch","base_revision":1,"cells":[{"index":42,"rgb":[0,96,0]},{"index":43,"rgb":[96,0,0]}]}
{"v":1,"id":"press","op":"sim.pulse","index":42}
{"v":1,"id":"hi","op":"overlay.set","overlay_id":"pressed","cells":[{"index":42,"rgb":[96,96,0]}],"ttl_ms":3000}
{"v":1,"id":"bad","op":"frame.patch","base_revision":1,"cells":[{"index":7,"rgb":[0,0,96]}]}
# replies and events on stdout (keygrid --device sim io)
{"type":"reply","id":"sub","status":"ok","data":{"baseline":true,"connection":1,"pressed":[],"region":"all","seq":0}}
{"type":"reply","id":"paint","status":"accepted","data":{"revision":1}}
{"type":"reply","id":"press","status":"ok","data":{}}
{"type":"reply","id":"paint","status":"superseded","data":{"pending":100,"revision":1}}
{"type":"reply","id":"hi","status":"accepted","data":{}}
{"type":"reply","id":"bad","status":"rejected","data":{"code":"invalid","message":"base revision must increase"}}
{"type":"key","device":"sim:c100","connection":1,"seq":1,"index":42,"row":4,"column":2,"state":"down","observed_ms":8,"source":"simulator"}
{"type":"key","device":"sim:c100","connection":1,"seq":2,"index":42,"row":4,"column":2,"state":"up","observed_ms":88,"source":"simulator"}
{"type":"reply","id":"hi","status":"acknowledged","data":{"pending":0,"revision":1}}
{"type":"closed","restoration":{"Ok":"restored"},"recovery_file":null}

A real session, fields reordered for reading. The patch was superseded by the overlay before it settled; the overlay was acknowledged by the (simulated) firmware.

  • Settlement is spelled out. accepted means admitted, acknowledged means every packet was acknowledged (not optical proof), superseded, rejected, indeterminate after a transport loss (never blindly replayed), cancelled at shutdown.
  • Retries are safe. The same ID and content returns the cached reply; the same ID with different content is rejected; IDs from before a reconnect are stale.
  • A C ABI for everything else. keygrid_open → submit → poll → close with length-delimited UTF-8 buffers, usable from C, Python ctypes, C#, Go or anything with an FFI. No Rust types cross it.
  • Agent-free by design. Keygrid reports index, row and column and paints what it's told. What a key means belongs to whoever is listening, for example over Omni network events named c100_grid_3_4.

Polling targets 125 Hz, so a tap shorter than one poll can be missed, and timestamps are when the host saw a change, not when the switch moved. Reconnects re-probe but never repaint or resubscribe on their own, so nothing stale is replayed onto a new connection.