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
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.
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.
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.
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.
acceptedmeans admitted,acknowledgedmeans every packet was acknowledged (not optical proof),superseded,rejected,indeterminateafter a transport loss (never blindly replayed),cancelledat 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 → closewith 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.