How to Reverse-Engineer Almost Any Keyboard Matrix With Raspberry Pi Pico

by thanishurs31 in Circuits > Raspberry Pi

690 Views, 9 Favorites, 0 Comments

How to Reverse-Engineer Almost Any Keyboard Matrix With Raspberry Pi Pico

Untitled design (4).png

So you have a keyboard. Maybe you yanked it out of a broken laptop, saved it from a bin, or bought a random membrane thing at a thrift store for 50 cents. And now you're staring at a ribbon cable with 20-something pins going absolutely nowhere, wondering what the heck you're supposed to do with it.

The old way to tackle this is the continuity-meter dance — you probe every possible pin pair, draw a grid, cross things out, wonder if that reading was real or just a ghost, and eventually end up with a hand-cramp and a half-correct diagram. It works. Eventually. But it's also the kind of experience that makes people give up and buy a new keyboard.

This guide does it differently. We use a Raspberry Pi Pico running CircuitPython to scan the entire matrix for us — it figures out which pins are rows, which are columns, handles diode-protected N-key rollover boards and old simple membrane boards, flags shared power lines, and spits out a clean JSON map at the end. Then we take that map and turn the keyboard into a proper USB HID device you can actually type on, with layers, Fn keys, and everything.

This isn't a one-keyboard tutorial — the method works on most salvaged boards, custom keypads, mystery matrices, and whatever else you've got. Once you've done it once, you'll do it again.

What You're Actually Getting Out Of This

  1. A full wiring map of your unknown keyboard matrix
  2. Detection of whether your board has diodes (N-key rollover) or not
  3. Ghost key filtering for no-diode boards
  4. A working CircuitPython USB keyboard firmware with shift, Fn layers, and media keys
  5. A QMK-compatible info.json stub if you want to go further with custom firmware

Supplies

Hiring! (3).png
WhatsApp Image 2026-04-30 at 11.04.00 AM (1).jpeg
WhatsApp Image 2026-04-30 at 11.04.01 AM.jpeg

Hardware

The essentials:

  1. Raspberry Pi Pico (the plain RP2040 one — not the W, you don't need WiFi for this)
  2. A micro USB cable — and please, use one you know transfers data. Charge-only cables will waste 20 minutes of your life
  3. Your keyboard with its ribbon cable

For connecting the ribbon cable:

This is where most people get confused. The flat cable coming out of your keyboard is an FPC (Flexible Printed Circuit) cable, and you need a breakout board that matches its pitch — the spacing between the pins. The two most common pitches are:

  1. 0.5mm pitch — the fine one, found on most laptop keyboards built after 2010ish
  2. 1.0mm pitch — a bit chunkier, older laptops, some standalone keyboards

Count the pins and measure the pitch (or just search your keyboard model). Breakout boards for both are dirt cheap on AliExpress, Amazon, or wherever you get your Pico. Search for "FPC breakout 0.5mm [pin count]" or "FPC to DIP adapter."

BTB connectors: Some keyboards — especially from tablets or unusual laptops — use board-to-board (BTB) connectors instead of a ribbon cable. These are the stubby little things that plug directly onto a PCB. Good luck finding a breakout for those. If you're in that situation, you're probably better off desoldering the connector pads directly. That's a different rabbit hole.

The rest of the list:

  1. Male header pins (for both the Pico and the breakout board)
  2. Breadboard
  3. Jumper wires (female-to-male)
  4. Optional: A perfboard if you want to make this permanent and not have a wiring nest

Software

  1. Thonny — free Python IDE, works on Windows, Mac, Linux. Download from thonny.org
  2. CircuitPython — we'll install this through Thonny. No separate download needed.
  3. The two scripts in this guide (scanning script + keyboard firmware)

Get CircuitPython Onto the Pico

Hiring! (2).png
Hiring! (4).png
Screenshot 2026-04-28 172241.png
Hiring! (5).png

Before anything else, the Pico needs CircuitPython on it. This takes about two minutes.

1. Grab your Pico and your data USB cable (seriously, data cable).

2. Hold down the BOOTSEL button on the Pico — it's the small white button on the board — and while holding it, plug the USB cable into your computer.

3. Release BOOTSEL. Your computer should see a new drive pop up called RPI-RP2. If nothing appears, you've probably got a charge-only cable. Swap it.

4. Open Thonny. In the bottom-right corner, you'll see the current interpreter — click it and select "Install CircuitPython".

5. In the dialog box that appears pick your board variant:

  1. Regular Pico → select Raspberry Pi Pico / Pico H
  2. Pico W → select Raspberry Pi Pico W / Pico WH

(It will automatically select the drive of the rpi if connected as well as the latest version)

6. Click Install. It'll take a few seconds. When it's done, the RPI-RP2 drive disappears and a new one called CIRCUITPY takes its place. That's your Pico, ready to go.

Confirm it worked: In Thonny's shell at the bottom, you should see a >>> prompt. Type print("hello") and hit enter. If it prints hello back at you, you're in business.

Wire Up the Keyboard

WhatsApp Image 2026-04-28 at 9.59.19 AM.jpeg
7.jpeg
5.jpeg

Now for the physical part — connecting that ribbon cable to the Pico.

1. Plug the FPC ribbon cable into your breakout board. Make sure the lock/actuator is lifted before inserting, then push it down to lock. The cable should sit snugly with the exposed contacts facing the right direction for your breakout (check which side the contacts face — top-contact vs bottom-contact matters).

2. Solder male header pins onto the breakout board's output pads, then do the same for the Pico's GPIO pins if you haven't already.

Don't place the breakout on the breadboard. The breakout's pads will short against the breadboard rails. Put the breakout somewhere off to the side and run jumper wires to the Pico.

3. Wire the breakout outputs to the Pico's GPIO pins. The order doesn't matter — really. The scanning script will figure out which pin is which, so you can just connect them sequentially.

On the Pico, use GPIOs 2 through 22, then 26, 27, 28. That gives you 24 pins, which covers most keyboards. If yours has fewer — say 16 pins — just wire those 16 and leave the rest floating. If yours has more than 24, you're probably looking at a keyboard that uses an I/O expander chip (MCP23017, 74HC595, etc.), which is a different situation entirely.

4. That's it for wiring. Once you've confirmed everything works, you can optionally transfer the whole setup to a perfboard for a permanent, rigid result. But breadboard-and-jumpers is fine for getting started.

Scan the Matrix

Hiring! (6).png

This is where the magic happens. The script below will:

  1. Find all the keyboard's matrix pins and separate them from power/ground pins
  2. Detect whether your board uses diodes (N-key rollover boards do; old membranes don't)
  3. Walk you through pressing each key and labeling it
  4. Print a finished matrix map and QMK JSON stub at the end

How to run it:

  1. Open Thonny
  2. Make sure the bottom-right shows your Pico / CircuitPython — not your desktop Python
  3. Paste the code below into a new file
  4. Make sure no keys are pressed on the keyboard you're scanning
  5. Hit the green Run button
"""
Keyboard Matrix Prober
Handles:
• Diode matrices (1N4148 per switch — N-key rollover keyboards)
• Diode-free matrices (simple membrane)
• Bidirectional pair deduplication (7,17) == (17,7) in no-diode mode
• Shared / always-connected lines (GND, VCC, chassis)
• Ghost key detection in live scan
• I/O expander warning when > 22 matrix pins found
"""

import board
import digitalio
import time
import sys
import json
import supervisor

# ─────────────────────────────────────────────────────────────────────────────
# CONFIGURATION
# ─────────────────────────────────────────────────────────────────────────────

# GP0–GP22 + GP26/27/28 — all safe user GPIOs on the Pico
# (GP23=SMPS ctrl, GP24=VBUS sense, GP25=LED — skipped intentionally)
PROBE_PINS = list(range(23)) + [26, 27, 28]

SETTLE_S = 80 / 1_000_000 # 80 µs settle after driving a pin
DEBOUNCE_S = 8 / 1_000 # 8 ms debounce
SCAN_PERIOD_S = 4 / 1_000 # 4 ms between full scans in live mode

# ─────────────────────────────────────────────────────────────────────────────
# PIN MANAGEMENT
# CircuitPython uses digitalio, not machine.Pin.
# We cache one DigitalInOut per GPIO number — creating a second object on the
# same pin raises an error in CircuitPython.
# ─────────────────────────────────────────────────────────────────────────────

_pins = {}

def _get(num):
"""Return the cached DigitalInOut for GP<num>, creating it if needed."""
if num not in _pins:
try:
_pins[num] = digitalio.DigitalInOut(getattr(board, f"GP{num}"))
except Exception as e:
print(f" [WARN] Could not init GP{num}: {e}")
return None
return _pins[num]

def all_input(pin_nums):
for n in pin_nums:
p = _get(n)
if p:
p.direction = digitalio.Direction.INPUT
p.pull = digitalio.Pull.UP

def drive_low(num):
p = _get(num)
if p:
p.direction = digitalio.Direction.OUTPUT
p.value = False

def release(num):
p = _get(num)
if p:
p.direction = digitalio.Direction.INPUT
p.pull = digitalio.Pull.UP

def read_pin(num):
"""
Returns 0 when pin is LOW, 1 when HIGH — same contract as MicroPython's
machine.Pin.value(). digitalio pin.value is False(LOW) / True(HIGH).
"""
p = _get(num)
if p is None:
return 1 # treat missing pin as HIGH (not pressed)
# Safety: if it was left in OUTPUT mode, switch it back before reading
if p.direction != digitalio.Direction.INPUT:
p.direction = digitalio.Direction.INPUT
p.pull = digitalio.Pull.UP
return 0 if not p.value else 1

# ─────────────────────────────────────────────────────────────────────────────
# STDIN HELPERS
# CircuitPython has no select.poll() on stdin.
# supervisor.runtime.serial_bytes_available is the correct non-blocking check.
# ─────────────────────────────────────────────────────────────────────────────

def stdin_has_data():
"""True if at least one byte is waiting in the serial buffer."""
return supervisor.runtime.serial_bytes_available > 0

def wait_for_enter():
"""Block until the user presses Enter — no input required, just any Enter."""
while True:
if supervisor.runtime.serial_bytes_available > 0:
ch = sys.stdin.read(1)
if ch in ('\r', '\n'):
return
time.sleep(0.001)

def stdin_readline():
"""Block until the user types something and presses Enter, return the line."""
buf = ""
while True:
if supervisor.runtime.serial_bytes_available > 0:
ch = sys.stdin.read(1)
if ch in ('\r', '\n'):
# Return whatever we have — even empty string
# Drain any paired \r\n so the next call starts clean
time.sleep(0.01)
while supervisor.runtime.serial_bytes_available > 0:
sys.stdin.read(1)
return buf
elif ch in ('\x08', '\x7f'): # backspace
buf = buf[:-1]
else:
buf += ch
time.sleep(0.001)

def input_prompt(prompt):
sys.stdout.write(prompt)
return stdin_readline()

# ─────────────────────────────────────────────────────────────────────────────
# MATRIX SCAN
# ─────────────────────────────────────────────────────────────────────────────

def raw_scan(candidates, baseline):
"""
Drive each candidate pin LOW, read all others.
Returns set of (driver, reader) pairs newly pulled LOW
that are not in the always-connected baseline.
"""
active = set()
for driver in candidates:
drive_low(driver)
time.sleep(SETTLE_S)
for reader in candidates:
if reader == driver:
continue
if read_pin(reader) == 0:
if reader not in baseline.get(driver, set()):
active.add((driver, reader))
release(driver)
return active


def normalize_pairs(raw_pairs, has_diodes):
"""
Collapse bidirectional echoes for no-diode boards.
(7,17) and (17,7) are the same physical closure → keep (min, max) only.
Diode boards: direction matters, keep as-is, just deduplicate.
"""
if not has_diodes:
return {(min(a, b), max(a, b)) for a, b in raw_pairs}
return set(raw_pairs)

# ─────────────────────────────────────────────────────────────────────────────
# PHASE 1 — STATIC BASELINE SCAN
# ─────────────────────────────────────────────────────────────────────────────

def phase1_baseline(pins):
"""
With no keys pressed, find which pin pairs are always connected.
Classifies pins as: matrix candidates | GND/VCC | floating.
"""
print("\n══════════════════════════════════════════")
print(" Phase 1: Static baseline (no keys)")
print("══════════════════════════════════════════")
print(" Ensure NO keys are pressed, then press Enter.")
wait_for_enter()

all_input(pins)
baseline = {p: set() for p in pins}

for driver in pins:
drive_low(driver)
time.sleep(SETTLE_S)
for reader in pins:
if reader == driver:
continue
if read_pin(reader) == 0:
baseline[driver].add(reader)
release(driver)

n = len(pins)

degree = {p: len(baseline[p]) for p in pins}
pulled_by = {p: 0 for p in pins}
for driver, readers in baseline.items():
for r in readers:
pulled_by[r] = pulled_by.get(r, 0) + 1

gnd_candidates = {
p for p in pins
if degree[p] > n // 3 or pulled_by.get(p, 0) > n // 2
}

matrix_candidates = sorted(
p for p in pins
if p not in gnd_candidates
and degree[p] == 0
and pulled_by.get(p, 0) <= 2
)

always_on = [(d, sorted(r)) for d, r in baseline.items() if r]
if always_on:
print("\n Always-connected pairs (GND/VCC/shorts):")
for d, r in always_on:
tag = " <- GND/VCC" if d in gnd_candidates else ""
print(f" Pin {d:2d} -> {r}{tag}")
else:
print(" No always-connected pairs found.")

print(f"\n Skipped (GND/VCC): {sorted(gnd_candidates)}")
print(f" Matrix candidates: {matrix_candidates}")

if len(matrix_candidates) < 4:
print("\n ERROR: Too few matrix candidates. Check wiring / PROBE_PINS.")
return None, None, None

return baseline, matrix_candidates, gnd_candidates

# ─────────────────────────────────────────────────────────────────────────────
# DIODE DIRECTION DETECTION
# ─────────────────────────────────────────────────────────────────────────────

def detect_diode_direction(pin_a, pin_b):
"""
With the key at (pin_a, pin_b) currently held, test both drive directions.
Returns: 'bidirectional' | 'a_to_b' | 'b_to_a' | 'open'
"""
drive_low(pin_a)
time.sleep(SETTLE_S)
ab = read_pin(pin_b)
release(pin_a)
time.sleep(SETTLE_S)

drive_low(pin_b)
time.sleep(SETTLE_S)
ba = read_pin(pin_a)
release(pin_b)

if ab == 0 and ba == 0:
return 'bidirectional'
elif ab == 0:
return 'a_to_b'
elif ba == 0:
return 'b_to_a'
return 'open'

# ─────────────────────────────────────────────────────────────────────────────
# PHASE 2 — CONTINUOUS SCAN MAPPING
# Flow: hold key → auto-detected → type label → release → repeat
# ─────────────────────────────────────────────────────────────────────────────

def phase2_map_keys(candidates, baseline):
print("\n══════════════════════════════════════════")
print(" Phase 2: Key mapping")
print("══════════════════════════════════════════")
print(" Hold a key -> detected -> type label -> release -> repeat.")
print(" Type 'done' + Enter at any time to finish.\n")

keymap = []
mapped_pairs = {}
has_diodes = None
diode_conv = 'unknown'
key_id = 0

all_input(candidates)

while True:
sys.stdout.write(f"\r [{key_id} mapped] Hold a key (or 'done' + Enter)... ")

detected_pair = None
while detected_pair is None:

# Non-blocking stdin check — supervisor is CircuitPython's way
if stdin_has_data():
line = stdin_readline()
if line.lower() == 'done':
print()
return keymap, has_diodes, diode_conv

raw = raw_scan(candidates, baseline)
if not raw:
continue

# Debounce: resample after a short wait
time.sleep(DEBOUNCE_S)
raw2 = raw_scan(candidates, baseline)
if not raw2:
continue

norm = normalize_pairs(raw2, False)
new = {pair for pair in norm if pair not in mapped_pairs}

if not new:
held = [mapped_pairs[pair] for pair in norm if pair in mapped_pairs]
sys.stdout.write(f"\r Already mapped: {held}. Release + try another. ")
while raw_scan(candidates, baseline):
time.sleep(0.005)
sys.stdout.write(f"\r [{key_id} mapped] Hold a key (or 'done' + Enter)... ")
continue

if len(new) > 1:
sys.stdout.write(f"\r Multiple keys {sorted(new)} - press ONE at a time. ")
while raw_scan(candidates, baseline):
time.sleep(0.005)
sys.stdout.write(f"\r [{key_id} mapped] Hold a key (or 'done' + Enter)... ")
continue

detected_pair = next(iter(new))

# Key is held — run diode test before prompting for label
pin_a, pin_b = detected_pair
direction = detect_diode_direction(pin_a, pin_b)

if has_diodes is None:
has_diodes = (direction != 'bidirectional')
diode_conv = direction
if has_diodes:
print(f"\n [AUTO] Diodes detected - direction: {direction}")
else:
print("\n [AUTO] No diodes - simple bidirectional matrix.")

if has_diodes:
if direction == 'a_to_b':
row_pin, col_pin = pin_a, pin_b
elif direction == 'b_to_a':
row_pin, col_pin = pin_b, pin_a
else: # unexpected mixed result
row_pin, col_pin = pin_a, pin_b
else:
row_pin, col_pin = pin_a, pin_b # no-diode: canonical = (min,max)

canonical = (row_pin, col_pin)

print(f"\n Detected: Pin {pin_a} <-> Pin {pin_b} [{direction}]")
label = input_prompt(" Label: ").strip()

if not label or label.lower() == 'skip':
print(" Skipped.")
while raw_scan(candidates, baseline):
time.sleep(0.005)
continue

mapped_pairs[canonical] = label
mapped_pairs[(col_pin, row_pin)] = label # reverse too for detection

keymap.append({
'key_id' : key_id,
'label' : label,
'row_pin' : row_pin,
'col_pin' : col_pin,
'direction': direction,
})
print(f" OK '{label}' -> row_pin={row_pin} col_pin={col_pin}")
key_id += 1

sys.stdout.write(" (release key...)")
while raw_scan(candidates, baseline):
time.sleep(0.005)
sys.stdout.write("\r \r")

# ─────────────────────────────────────────────────────────────────────────────
# STRUCTURE EXTRACTION
# ─────────────────────────────────────────────────────────────────────────────

def extract_structure(keymap):
rows = sorted(set(k['row_pin'] for k in keymap))
cols = sorted(set(k['col_pin'] for k in keymap))
grid = {(k['row_pin'], k['col_pin']): k['label'] for k in keymap}

print("\n══════════════════════════════════════════")
print(" Matrix structure")
print("══════════════════════════════════════════")
print(f" Row pins : {rows} ({len(rows)} rows)")
print(f" Col pins : {cols} ({len(cols)} cols)")
print(f" Keys : {len(keymap)}")

if len(rows) + len(cols) > 22:
print("\n WARNING: >22 matrix lines - likely uses an I/O expander.")

w = max((len(k['label']) for k in keymap), default=4) + 1
w = max(w, 5)
print("\n " + "".join(f"C{c:<{w-1}}" for c in cols))
for r in rows:
row_str = f"R{r:2d}: "
for c in cols:
row_str += f"{grid.get((r, c), '----'):<{w}}"
print(row_str)

return rows, cols, grid

# ─────────────────────────────────────────────────────────────────────────────
# PHASE 3 — LIVE SCAN
# ─────────────────────────────────────────────────────────────────────────────

def phase3_live(rows, cols, grid, has_diodes):
print("\n══════════════════════════════════════════")
print(" Phase 3: Live scan (Ctrl-C to stop)")
print("══════════════════════════════════════════\n")

prev = set()

def ghost_filter(raw):
"""
Remove ghost keys from a no-diode matrix scan.
A ghost appears when two real keys share a row and a column,
creating a phantom current path through the matrix.
"""
confirmed = set()
for (r, c) in sorted(raw):
row_mates = {k for k in confirmed if k[0] == r}
col_mates = {k for k in confirmed if k[1] == c}
is_ghost = any(
any(km2[1] == km1[1] for km2 in col_mates)
for km1 in row_mates
)
if not is_ghost:
confirmed.add((r, c))
return confirmed

try:
while True:
t0 = time.monotonic() # CircuitPython equivalent of ticks_ms()

raw = set()
for row in rows:
drive_low(row)
time.sleep(SETTLE_S)
for col in cols:
if read_pin(col) == 0:
raw.add((row, col))
release(row)

pressed = raw if has_diodes else ghost_filter(raw)

for k in pressed - prev:
lbl = grid.get(k, f"?r{k[0]}c{k[1]}")
print(f" DOWN {lbl:<14} row={k[0]} col={k[1]}")
for k in prev - pressed:
lbl = grid.get(k, f"?r{k[0]}c{k[1]}")
print(f" UP {lbl:<14}")

prev = pressed

# Pace the loop — equivalent of ticks_diff sleep
elapsed = time.monotonic() - t0
remaining = SCAN_PERIOD_S - elapsed
if remaining > 0:
time.sleep(remaining)

except KeyboardInterrupt:
all_input(rows + cols)
print("\n Stopped.")

# ─────────────────────────────────────────────────────────────────────────────
# EXPORT
# ─────────────────────────────────────────────────────────────────────────────

def export(rows, cols, grid, has_diodes, diode_conv):
diode_dir = "ROW2COL" if diode_conv == 'a_to_b' else "COL2ROW"

result = {
"has_diodes" : has_diodes,
"diode_dir" : diode_dir,
"row_pins" : rows,
"col_pins" : cols,
"matrix_size" : [len(rows), len(cols)],
"layout" : [
{
"label" : lbl,
"row" : rows.index(r),
"col" : cols.index(c),
"gpio_row": r,
"gpio_col": c,
}
for (r, c), lbl in grid.items()
],
}

print("\n== JSON ============================")
print(json.dumps(result))

print("\n== QMK info.json stub ==============")
layout_lines = ",\n".join(
f' {{"label": "{lbl}", "matrix": [{rows.index(r)}, {cols.index(c)}]}}'
for (r, c), lbl in grid.items()
)
print("{")
print(f' "diode_direction": "{diode_dir}",')
print(f' "matrix_pins": {{"rows": {rows}, "cols": {cols}}},')
print( ' "layouts": {"LAYOUT": {"layout": [')
print(layout_lines)
print(" ]}}")
print("}")

# ─────────────────────────────────────────────────────────────────────────────
# MAIN
# ─────────────────────────────────────────────────────────────────────────────

def main():
print("\n" + "=" * 44)
print(" Keyboard Matrix Prober - CircuitPython")
print(f" Probing {len(PROBE_PINS)} GPIOs: {PROBE_PINS}")
print("=" * 44)

# Pre-init all pins upfront — avoids mid-scan allocation errors
print(" Initialising pins...")
all_input(PROBE_PINS)
print(" Ready.\n")

baseline, candidates, gnd = phase1_baseline(PROBE_PINS)
if candidates is None:
return

keymap, has_diodes, diode_conv = phase2_map_keys(candidates, baseline)
if not keymap:
print("No keys mapped.")
return

rows, cols, grid = extract_structure(keymap)
export(rows, cols, grid, has_diodes, diode_conv)

ans = input_prompt("\n Start live scan? (y/n): ")
if ans.lower() == 'y':
phase3_live(rows, cols, grid, has_diodes)

print("\nDone.")


main()

What happens after you run it:

The script runs in three phases automatically:

Phase 1 — Baseline scan. With no keys pressed, it drives each pin low and checks what else goes low. Pins that are always connected (GND, VCC, backlight lines) get filtered out automatically. You'll see a list of "matrix candidates" — these are the actual row and column pins of your keyboard.

Phase 2 — Key mapping. This is the interactive part. Hold down a key — just hold it, don't tap — and within a second or two the serial terminal will print something like:

Detected: Pin 7 ↔ Pin 17 [bidirectional]
Label:

Type whatever name makes sense for that key and hit enter. Then release the key. Repeat for every key you want to map.

A few tips that'll save you time:

  1. For Shift/Alt/Ctrl: These are just keys like any other — press and label them.
  2. For special characters on shifted keys: When labeling, you can include the shifted character too. So if the key has 5 and %, just label it 5 — you can add the shifted mapping later in the firmware. Or, if you want to be thorough while you're at it, label it 5_PERCENT or whatever makes sense to you.
  3. For function keys activated by Fn combos: Label the key by its base function (e.g. F5). The Fn layer gets defined separately in the firmware anyway.
  4. Type done and press Enter when you've finished all the keys.

Phase 3 — Live scan. After mapping, it'll ask if you want to do a live test. Say yes. Press keys and you'll see ↓ keyname and ↑ keyname printed in real-time. This confirms everything got mapped correctly before you move on. Press Ctrl-C when done.

Before moving to the next step: Copy the entire serial terminal output. All of it. You'll need it.

Turn It Into a USB Keyboard

Hiring! (7).png

Now you have a map. Time to make it do something.

The firmware below takes that pin map and makes the Pico show up as a real USB HID keyboard to whatever computer it's plugged into — including layers for Shift, Fn, and consumer control keys like brightness and volume.

The quick approach: Copy your terminal output from Step 3 alongside the example BASE_MAP and FN_MAP below, and paste both to an AI (Claude, ChatGPT, whatever you prefer). Ask it to generate the BASE_MAP dictionary for your specific keyboard, matching your labeled keys to their GPIO pairs. It'll take two minutes and the result will be correct. There's no shame in it — the interesting part was the matrix scan, not retyping pin numbers.

The manual approach: Find each key in your terminal output, note the two pin numbers, and fill in the BASE_MAP accordingly. The p(a, b) function just creates a canonical sorted tuple so direction doesn't matter.

The example below is my actual 24-pin laptop keyboard — use it as a reference for structure, then replace the BASE_MAP and FN_MAP with your own data:

import time
import board
import digitalio
import usb_hid

from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode

try:
from adafruit_hid.consumer_control import ConsumerControl
from adafruit_hid.consumer_control_code import ConsumerControlCode
HAVE_CC = True
except Exception:
HAVE_CC = False


# ─────────────────────────────
# CONFIG
# ─────────────────────────────

PINS = [2,3,4,5,6,7,8,9,10,11,12,13,14,15,16,18,17,19,20,21,22,26,27,28]
SETTLE_US = 80
SCAN_SLEEP = 0.005

def p(a, b):
return (a, b) if a < b else (b, a)


# ─────────────────────────────
# SAFE LOOKUP HELPERS
# ─────────────────────────────

def kc(*names):
for name in names:
if hasattr(Keycode, name):
return getattr(Keycode, name)
return None

def cc_code(*names):
if not HAVE_CC:
return None
for name in names:
if hasattr(ConsumerControlCode, name):
return getattr(ConsumerControlCode, name)
return None


# ─────────────────────────────
# HARDWARE MAP ← replace this with your keyboard's data
# ─────────────────────────────

BASE_MAP = {
p(7,17): "ESC",

p(18,20): "F1",
p(18,19): "F2",
p(14,19): "F3",
p(7,19): "F4",
p(7,28): "F5",
p(7,22): "F6",
p(14,21): "F7",
p(18,21): "F8",
p(18,28): "F9",
p(12,28): "F10",
p(4,12): "F11",
p(3,12): "F12",

p(4,18): "INSERT",
p(12,27): "DELETE",
p(5,12): "PRINT_SCREEN",
p(5,18): "PAUSE",

p(17,18): "GRAVE",
p(12,17): "1",
p(12,20): "2",
p(12,19): "3",
p(8,12): "4",
p(8,18): "5",
p(6,18): "6",
p(6,12): "7",
p(12,22): "8",
p(12,21): "9",
p(9,12): "0",
p(9,18): "MINUS",
p(18,22): "EQUALS",

p(14,28): "BACKSPACE",
p(14,17): "TAB",

p(13,17): "q",
p(13,20): "w",
p(13,19): "e",
p(8,13): "r",
p(8,14): "t",
p(6,14): "y",
p(6,13): "u",
p(13,22): "i",
p(13,21): "o",
p(9,13): "p",
p(9,14): "LEFT_BRACKET",
p(14,22): "RIGHT_BRACKET",
p(16,28): "BACKSLASH",

p(14,20): "CAPS_LOCK",

p(16,17): "a",
p(16,20): "s",
p(16,19): "d",
p(8,16): "f",
p(7,8): "g",
p(6,7): "h",
p(6,16): "j",
p(16,22): "k",
p(16,21): "l",
p(9,16): "SEMICOLON",
p(7,9): "QUOTE",

p(15,28): "ENTER",
p(11,14): "LSHIFT",
p(11,15): "RSHIFT",
p(16,27): "FN",

p(15,17): "z",
p(15,20): "x",
p(15,19): "c",
p(8,15): "v",
p(8,10): "b",
p(6,10): "n",
p(6,15): "m",
p(15,22): "COMMA",
p(15,21): "PERIOD",
p(9,10): "FORWARD_SLASH",

p(2,18): "LCTRL",
p(2,15): "RCTRL",
p(10,26): "LALT",
p(5,13): "LGUI",
p(5,16): "APPLICATION",
p(10,28): "SPACE",

p(5,15): "PGUP",
p(7,27): "UP",
p(5,10): "PGDN",
p(4,10): "DOWN",
p(3,10): "RIGHT",
}

FN_MAP = {
p(4,18): "KEYPAD_NUMLOCK",
p(12,27): "SCROLL_LOCK",
p(5,12): "PRINT_SCREEN",
p(5,18): "PAUSE",

p(5,15): "HOME",
p(7,27): "BRIGHTNESS_INCREMENT",
p(5,10): "END",
p(4,10): "BRIGHTNESS_DECREMENT",
p(3,10): "VOLUME_INCREMENT",
}

# ─────────────────────────────
# KEYCODE RESOLUTION
# ─────────────────────────────

KEYCODES = {
"ESC": kc("ESCAPE"),
"TAB": kc("TAB"),
"ENTER": kc("ENTER", "RETURN"),
"BACKSPACE": kc("BACKSPACE"),
"SPACE": kc("SPACEBAR", "SPACE"),
"CAPS_LOCK": kc("CAPS_LOCK"),

"INSERT": kc("INSERT"),
"DELETE": kc("DELETE"),
"HOME": kc("HOME"),
"END": kc("END"),
"PGUP": kc("PAGE_UP"),
"PGDN": kc("PAGE_DOWN"),

"UP": kc("UP_ARROW"),
"DOWN": kc("DOWN_ARROW"),
"LEFT": kc("LEFT_ARROW"),
"RIGHT": kc("RIGHT_ARROW"),

"LCTRL": kc("LEFT_CONTROL", "CONTROL"),
"RCTRL": kc("RIGHT_CONTROL"),
"LALT": kc("LEFT_ALT", "ALT", "OPTION"),
"LGUI": kc("LEFT_GUI", "GUI", "WINDOWS", "COMMAND"),
"APPLICATION": kc("APPLICATION"),
"LSHIFT": kc("LEFT_SHIFT", "SHIFT"),
"RSHIFT": kc("RIGHT_SHIFT"),

"PRINT_SCREEN": kc("PRINT_SCREEN"),
"SCROLL_LOCK": kc("SCROLL_LOCK"),
"PAUSE": kc("PAUSE"),
"KEYPAD_NUMLOCK": kc("KEYPAD_NUMLOCK"),
}

for c in "abcdefghijklmnopqrstuvwxyz":
KEYCODES[c] = kc(c.upper())

KEYCODES["1"] = kc("ONE")
KEYCODES["2"] = kc("TWO")
KEYCODES["3"] = kc("THREE")
KEYCODES["4"] = kc("FOUR")
KEYCODES["5"] = kc("FIVE")
KEYCODES["6"] = kc("SIX")
KEYCODES["7"] = kc("SEVEN")
KEYCODES["8"] = kc("EIGHT")
KEYCODES["9"] = kc("NINE")
KEYCODES["0"] = kc("ZERO")

KEYCODES["GRAVE"] = kc("GRAVE_ACCENT")
KEYCODES["MINUS"] = kc("MINUS")
KEYCODES["EQUALS"] = kc("EQUALS")
KEYCODES["LEFT_BRACKET"] = kc("LEFT_BRACKET")
KEYCODES["RIGHT_BRACKET"] = kc("RIGHT_BRACKET")
KEYCODES["BACKSLASH"] = kc("BACKSLASH")
KEYCODES["SEMICOLON"] = kc("SEMICOLON")
KEYCODES["QUOTE"] = kc("QUOTE")
KEYCODES["COMMA"] = kc("COMMA")
KEYCODES["PERIOD"] = kc("PERIOD")
KEYCODES["FORWARD_SLASH"] = kc("FORWARD_SLASH")

for i in range(1, 13):
KEYCODES[f"F{i}"] = kc(f"F{i}")

MEDIA = {
"BRIGHTNESS_INCREMENT": cc_code("BRIGHTNESS_INCREMENT"),
"BRIGHTNESS_DECREMENT": cc_code("BRIGHTNESS_DECREMENT"),
"VOLUME_INCREMENT": cc_code("VOLUME_INCREMENT"),
"VOLUME_DECREMENT": cc_code("VOLUME_DECREMENT"),
}

# ─────────────────────────────
# GPIO SETUP
# ─────────────────────────────

def gp(n):
return getattr(board, f"GP{n}")

io = [digitalio.DigitalInOut(gp(pin)) for pin in PINS]

def all_input():
for pin in io:
pin.direction = digitalio.Direction.INPUT
pin.pull = digitalio.Pull.UP

def drive_low(i):
io[i].direction = digitalio.Direction.OUTPUT
io[i].value = False

def release(i):
io[i].direction = digitalio.Direction.INPUT
io[i].pull = digitalio.Pull.UP

def read(i):
return io[i].value


# ─────────────────────────────
# BASELINE + SCAN
# ─────────────────────────────

def build_baseline():
baseline = {pin: set() for pin in PINS}
all_input()

for di, d in enumerate(PINS):
drive_low(di)
time.sleep(SETTLE_US / 1_000_000)

for ri, r in enumerate(PINS):
if ri == di:
continue
if not read(ri):
baseline[d].add(r)

release(di)

return baseline

def scan_raw(baseline):
active = set()

for di, d in enumerate(PINS):
drive_low(di)
time.sleep(SETTLE_US / 1_000_000)

for ri, r in enumerate(PINS):
if ri == di:
continue
if not read(ri) and r not in baseline[d]:
active.add(p(d, r))

release(di)

return active


# ─────────────────────────────
# ACTION HELPERS
# ─────────────────────────────

kbd = Keyboard(usb_hid.devices)
cc = ConsumerControl(usb_hid.devices) if HAVE_CC else None

def tap_key(code):
if code is None:
return
kbd.press(code)
kbd.release(code)

def tap_media(code):
if cc is None or code is None:
return
try:
cc.send(code)
except Exception:
pass

def handle_action(action):
if action is None:
return
if action in MEDIA:
tap_media(MEDIA[action])
return
code = KEYCODES.get(action)
if code is not None:
tap_key(code)


# ─────────────────────────────
# MAIN LOOP
# ─────────────────────────────

print("Building baseline...")
baseline = build_baseline()
print("Keyboard ready")

pressed = set()

while True:
keys = scan_raw(baseline)
new = keys - pressed
gone = pressed - keys

for k in gone:
action = BASE_MAP.get(k)
if action == "LSHIFT":
code = KEYCODES.get("LSHIFT")
if code is not None:
kbd.release(code)
elif action == "RSHIFT":
code = KEYCODES.get("RSHIFT")
if code is not None:
kbd.release(code)

for k in new:
action = BASE_MAP.get(k)
if action == "LSHIFT":
code = KEYCODES.get("LSHIFT")
if code is not None:
kbd.press(code)
elif action == "RSHIFT":
code = KEYCODES.get("RSHIFT")
if code is not None:
kbd.press(code)

fn_active = any(BASE_MAP.get(k) == "FN" for k in keys)

for k in new:
action = BASE_MAP.get(k)

if action in ("LSHIFT", "RSHIFT", "FN"):
continue

if action == "CAPS_LOCK":
handle_action(action)
continue

if fn_active and k in FN_MAP:
handle_action(FN_MAP[k])
else:
handle_action(action)

pressed = keys
time.sleep(SCAN_SLEEP)

How the layers work:

  1. Shift works naturally — the host OS handles it. Just map LSHIFT and RSHIFT to their pins and the firmware holds the shift modifier while they're pressed.
  2. Fn layer — any key in FN_MAP gets its alternate action when FN is held. Fn isn't sent to the host as a keypress, it just switches which map the firmware reads from.
  3. Consumer control (brightness, volume, media) — these use a separate HID descriptor from the keyboard, which is why there's a ConsumerControl object. The MEDIA dict maps action names to their codes.
  4. Keys not in the map — they're silently ignored. No crashes, no weirdness.

To deploy: Save this as code.py on the Pico's CIRCUITPY drive. Unplug from your computer and plug into a different USB port (or the same one after a moment). The Pico will enumerate as a keyboard and you're done.

Troubleshooting

"Too few matrix candidates" error in Phase 1 The most common cause is a charge-only USB cable that's also being used for Thonny communication — unlikely but check. More often: a wiring issue, or your keyboard has a GND pin that's pulling down more pins than the script expects. Try narrowing PROBE_PINS at the top of the scanner to only the pins you actually wired up.

Some keys aren't detected at all Check the jumper wire for that pin. Also worth verifying with a multimeter that pressing the key produces continuity between those two ribbon cable pins.

Keys type wrong characters The keyboard layout on the host OS matters here. If your OS is set to a different layout than what you mapped (e.g., you mapped for QWERTY but your OS is AZERTY), things will be off. The firmware sends raw keycodes, not characters. Match your BASE_MAP labels to the physical positions, not the characters printed on the keycaps.

Ghost keys when pressing multiple keys If your board has no diodes (the scanner will tell you — it'll say "simple bidirectional matrix"), ghost keys are a physical limitation of the matrix design. The ghost_filter in the live scan handles basic cases, but the real solution is to not rely on N-key rollover for gaming or anything where you need many simultaneous keypresses.

Pico shows up as CIRCUITPY but doesn't appear as a keyboard The firmware uses usb_hid — make sure this module is available in your CircuitPython version. It's included by default in recent versions. Also double-check you saved the file as code.py, not code.py.txt or anything else.

Where to Go From Here

Once this is working, you've got a fully editable firmware sitting in a text file on a flash drive. A few directions you could take it:

  1. Macropad — drop half the keys, remap the Fn layer to macros, make a dedicated coding shortcuts board
  2. QMK — the JSON output from the scanner is a valid stub for a QMK info.json. If you want more advanced features (tap-dance, per-key RGB, rotary encoders), QMK on a compatible microcontroller is the next step
  3. Multiple keyboards — the Pico is cheap. One for each salvaged keyboard you want to experiment with
  4. Permanent build — solder everything to a perfboard, add a nice enclosure, use it as your daily driver

The method scales. That's the point.