Keyboard workflow · Tutorial
Build a GPIO matrix keyboard with a custom keymap
Connect sixteen switches to eight ESP32 GPIOs and turn them into a small keyboard with letters, arrows, modifiers, and an Fn layer. This tutorial uses a 4×4 matrix on the ESP32-S3-DevKitC-1-N16R8, with a complete JSON keymap you can adapt to your own layout.
Use a SolarOS firmware build that lists gpio-matrix in expansion drivers. The driver is included in the full and classic flavors. This example uses full so Wi-Fi and curl are also available for transferring the keymap. The quick start covers installing or building SolarOS.
From switches to characters
Each switch connects one row to one column. The gpio-matrix driver scans these connections through native ESP32 pins and reports which positions are pressed or released. A separate source keymap assigns those positions to HID keyboard usages, modifiers, or direct character bytes. SolarOS then handles repeat and sends the resulting input to shells and applications.
The attachment defines the wiring and matrix dimensions. The keymap defines what the switches do. Changing setterm keyboard us or setterm keyboard de selects the language layout used to translate HID usages into characters; it does not move the physical switches or load a source keymap.
A matrix with R rows and C columns needs R + C free GPIOs. The driver supports up to eight rows and ten columns. Its bindings accept native GPIOs; expander lines such as gpiox0:4 are not supported. For a keyboard using an I2C matrix controller, follow the TCA8418 tutorial.
Wire the 4×4 prototype
Use sixteen normally open switches, or a passive 4×4 keypad whose row and column connections you have identified. A keypad's connector order depends on the particular keypad; identify its four row wires and four column wires before assigning them to the table below.
Power the board off while making connections. On the DevKitC profile, use:
| Matrix line | DevKitC GPIO | Attachment binding |
|---|---|---|
| Row 0 | GPIO4 | row0=gpio4 |
| Row 1 | GPIO5 | row1=gpio5 |
| Row 2 | GPIO6 | row2=gpio6 |
| Row 3 | GPIO7 | row3=gpio7 |
| Column 0 | GPIO15 | col0=gpio15 |
| Column 1 | GPIO16 | col1=gpio16 |
| Column 2 | GPIO17 | col2=gpio17 |
| Column 3 | GPIO18 | col3=gpio18 |
The switch contacts join rows to columns; the ESP32 supplies the input pull-ups. This passive matrix needs no controller or GPIO expander. For another board, run gpio status and choose enough available pins from that board's connectors. Pins used by a display, storage, USB, or another device may already have an owner.
The example works without per-key diodes. Ghost detection suppresses new presses in ambiguous rectangles, so arbitrary combinations of several keys cannot always be distinguished. The two-key Shift and Fn exercises below fit this setup. For unrestricted combinations, each switch needs a correctly oriented isolation diode and the attachment must use diodes=1.
Attach the matrix and identify its positions
On the headless DevKitC, connect the USB-to-UART port to a desktop serial terminal at 115200 baud. Use your desktop keyboard to enter commands while you inspect the matrix. See input and navigation for routing input to a display shell on boards with a screen.
Run these commands in the SolarOS shell. The attachment command is one line:
gpio status
expansion attach gpio-matrix keys0 row0=gpio4 row1=gpio5 row2=gpio6 row3=gpio7 col0=gpio15 col1=gpio16 col2=gpio17 col3=gpio18
input keyboard
input keymap keys0 show
The source keys0 should be ready and report a 4×4 matrix. The driver infers the dimensions from contiguous bindings: start at row0 and col0, and include every index up to the last row and column. The pins remain claimed until the keyboard is detached.
At first, every position reports raw physical events. There are no assumed letters or keycap positions. Hold a switch and run:
input test keys0
This prints a diagnostic snapshot; repeat the command after another key action. Raw events have a physical ID but no assigned character. The IDs are 1 + row * column_count + col, with row and column indices starting at zero:
| Row | Column 0 | Column 1 | Column 2 | Column 3 |
|---|---|---|---|---|
| 0 | 1 | 2 | 3 | 4 |
| 1 | 5 | 6 | 7 | 8 |
| 2 | 9 | 10 | 11 | 12 |
| 3 | 13 | 14 | 15 | 16 |
For example, the switch between GPIO5 and GPIO15 is row 1, column 0: physical ID 5. GPIO matrix IDs use the actual number of columns. TCA8418 IDs use a fixed stride of ten, so its physical-ID maps are not interchangeable with this four-column matrix.
Create the source keymap
We will assign this layout:
| Row | Column 0 | Column 1 | Column 2 | Column 3 |
|---|---|---|---|---|
| 0 | A | B | C | Enter |
| 1 | Left | Up | Down | Right |
| 2 | Shift | Ctrl | Alt | Backspace |
| 3 | Fn / Space | Tab | Escape | Caps Lock |
Save the following as my-matrix.json in a desktop directory named gpio-matrix-demo, or download the complete keymap and save it under that name. The row and col selectors keep each assignment connected to its wiring position:
{
"schema": 1,
"keys": [
{"row": 0, "col": 0, "usage": 4},
{"row": 0, "col": 1, "usage": 5},
{"row": 0, "col": 2, "usage": 6},
{"row": 0, "col": 3, "usage": 40},
{"row": 1, "col": 0, "usage": 80},
{"row": 1, "col": 1, "usage": 82},
{"row": 1, "col": 2, "usage": 81},
{"row": 1, "col": 3, "usage": 79},
{"row": 2, "col": 0, "usage": 225},
{"row": 2, "col": 1, "usage": 224},
{"row": 2, "col": 2, "usage": 226},
{"row": 2, "col": 3, "usage": 42},
{"row": 3, "col": 0, "usage": 44, "layer_tap": true},
{"row": 3, "col": 1, "usage": 43},
{"row": 3, "col": 2, "usage": 41},
{"row": 3, "col": 3, "usage": 57}
],
"symbols": [
{"row": 0, "col": 0, "key": 33},
{"row": 0, "col": 1, "key": 64},
{"row": 0, "col": 2, "key": 35}
]
}
usage is a decimal HID keyboard usage: 4 means A, 40 Enter, 44 Space, and 225 Left Shift. HID A produces a normally and A with Shift. A direct key value is a character byte: 33 is !, 64 is @, and 35 is #. The source keymap reference lists the format and additional output fields.
layer_tap makes row 3, column 0 a dual-purpose switch. Tap and release it without using another key to type Space; hold it while pressing A, B, or C to type !, @, or #. Symbol positions without an override use their base-layer assignment. A map can contain one layer selector.
The desktop keymap CLI and website Keyboard Configurator currently provide the TCA8418, Inputronic, and Pager profiles. For gpio-matrix, use this JSON workflow and validate the file by loading it into the matching source.
Transfer and load the map
Connect the board and desktop to the same reachable network. In SolarOS, create temporary storage for the first experiment:
wifi connect "YOUR_SSID" "YOUR_PASSWORD"
wifi status
ramfs mount /matrix-demo 64k
In a desktop terminal, serve the directory containing my-matrix.json:
python3 -m http.server 8000 --bind 0.0.0.0 --directory gpio-matrix-demo
Keep the server running during the transfer. Replace <DESKTOP_IP> below with the desktop's LAN address; allow the board to reach its TCP port 8000. In SolarOS:
curl -o /matrix-demo/my-matrix.json http://<DESKTOP_IP>:8000/my-matrix.json
input keymap keys0 load /matrix-demo/my-matrix.json
input keymap keys0 show
setterm keyboard us
Loading validates the file before applying it. Release any held switches after a successful load, then press them again. Each load starts from the driver's raw default profile and applies the entries in that file; it does not accumulate changes from previous runtime loads. Keep all of the assignments you want in the saved JSON.
Try letters, modifiers, and the Fn layer
Begin with Caps Lock off. Hold each switch or chord and run input test keys0 from the serial shell to inspect its latest event. A focused display shell can also show the resulting text directly.
| Matrix action | Expected behavior |
|---|---|
| Row 0, column 0 | A usage, producing a |
| Shift + row 0, column 0 | Produces A |
| Ctrl + row 0, column 0 | Control-A, byte 1 |
| Row 0, column 3 | Enter |
| Row 1, column 1 | Up arrow |
| Tap and release row 3, column 0 | Space |
| Hold row 3, column 0 and press row 0, column 0 | Produces ! |
| Hold row 3, column 0 and press row 0, column 1 | Produces @ |
The Fn switch emits its Space tap when released, rather than on the initial press. Its physical key event and the resulting character are different parts of the input path; use text in a display shell to check the Space tap.
Edit my-matrix.json, download it again, and repeat input keymap keys0 load to try another assignment. A keys entry containing only row and col disables that position; adding "raw": true restores physical-only events. The attachment must keep the same dimensions as the map's coordinates.
To return every position to its raw default:
input keymap keys0 reset
Keep the setup after reboot
The RAM file and runtime mapping disappear at reboot. On this headless DevKitC setup, / is internal flash. Create a persistent directory there and transfer the map while the desktop server is still running:
mkdir /keymaps
curl -o /keymaps/my-matrix.json http://<DESKTOP_IP>:8000/my-matrix.json
input keymap keys0 load /keymaps/my-matrix.json
setterm startup flash
setterm startup
If /keymaps already exists, reuse it. setterm startup shows the startup script path. For this flash-root board the path is /.shell/startup; create /.shell with mkdir /.shell if needed, then open the script with edit /.shell/startup. Preserve any existing commands and add these lines in this order:
expansion attach gpio-matrix keys0 row0=gpio4 row1=gpio5 row2=gpio6 row3=gpio7 col0=gpio15 col1=gpio16 col2=gpio17 col3=gpio18
input keymap keys0 load /keymaps/my-matrix.json
Save the script. On the next boot it attaches the keyboard before loading the map. If you already saved the attachment with the expansion TUI's Save for startup action, add the load command after its existing attach line. Store only one attachment command for keys0.
On boards with SD storage, check setterm startup and the storage reference for the selected script and volume. Keep the JSON on storage mounted before the load command runs. GPIO matrix custom maps are restored through this runtime load; the SOLAR_OS_KEYMAP_PROFILE build selections apply to the predefined keyboard profiles, not to this driver.
Stop the desktop HTTP server with Ctrl+C after transferring the files. To remove the live keyboard and free its pins:
expansion detach keys0
Detaching also removes its source keymap. A later attachment starts with raw events until you load the JSON again.
Tune scanning and troubleshoot
| Setting | Default | Use |
|---|---|---|
drive | 0 | Drive rows and sense columns. Use 1 to drive columns and sense rows. |
debounce-ms | 10 | Require stable switches for 1–100 ms; increase it if a press bounces. |
diodes | 0 | Detect ambiguous rectangles. Use 1 only with a diode at every switch. |
Set these options on the attachment command. To change a live configuration, detach the keyboard, attach it with the revised options, then reload its map. Scanning uses active-low open-drain outputs, releases inactive drive lines, and enables pull-ups on the sensed lines. With diodes, current must be able to flow from a sensed line towards a driven line; choose drive to suit their orientation. Classic ESP32 GPIO34–39 cannot provide the required internal pull-ups and are not supported by this driver.
| Symptom | Check |
|---|---|
gpio-matrix is missing | Install a firmware build containing the driver; check expansion drivers. |
| Attachment rejects a pin | Inspect gpio status for its board role and owner. Check for duplicate pins and missing row or column indices. |
| Key events appear but no characters | Load the source keymap; the initial profile is raw. |
| A key has the wrong physical ID | Check the row/column wires and the actual column count. |
| Loading the JSON fails | Check JSON syntax, schema 1, coordinate ranges, and duplicate assignments. |
| Some combinations are suppressed | A matrix without isolation diodes can produce ambiguous rectangles. |
| Input is raw again after reboot | Check that startup attaches keys0 before loading a persistent JSON file. |
For the full attachment and mapping interfaces, see expansion devices and source keymaps.