LapBoard
This project turns a laptop keyboard into a USB keyboard for other devices. It is handy when setting up a Raspberry Pi if you cannot find a spare keyboard or are simply too lazy to get one.
What you need
- 2 × Seeed XIAO RP2040 boards (or any RP2040-based microcontrollers)
- Both boards flashed with CircuitPython
- CircuitPython 10.2.1
Note: Currently only terminal input is forwarded. The keyboard does not work system-wide, and the touchpad is not supported.

Credits: This project was developed together with Árni.
Spiral 1 - Get the Keyboard Working
The first step was to get the keyboard working. One microcontroller captures the USB serial data from the laptop and forwards it over UART to a second microcontroller. The second microcontroller acts as a USB HID keyboard using the adafruit_hid library.
%%{init: {
"look": "handDrawn",
"theme": "base",
"themeVariables": {
"primaryColor": "transparent",
"secondaryColor": "transparent",
"tertiaryColor": "transparent",
"primaryTextColor": "#888888",
"lineColor": "#888888",
"edgeLabelBackground": "transparent",
"fontSize": "16px"
}
}}%%
flowchart LR
L[Laptop]
T["screen (terminal)"]
C["RP2040<br/>USB CDC"]
H["RP2040<br/>USB HID"]
D[Target Device]
L --> T
T -->|USB CDC| C
C -->|UART| H
H -->|USB HID| D
classDef software fill:transparent,color:#888888,stroke:#1683ff,stroke-width:2px;
classDef hardware fill:transparent,color:#888888,stroke:#00a84f,stroke-width:2px;
classDef device fill:transparent,color:#888888,stroke:#888888,stroke-width:2px;
class T software
class C,H hardware
class L,D device

Hardware setup. One RP2040 acts as a USB CDC to UART bridge, while the second presents itself as a USB HID keyboard to the target device.
Setting up the Serial Microcontroller
First, create a boot.py file on the microcontroller and add the following code:
Then add the following code to code.py:
import usb_cdc
import time
import busio
import board
uart = busio.UART(board.TX, board.RX, baudrate=115200)
time.sleep(0.5)
serial = usb_cdc.data
while True:
if serial.in_waiting > 0:
data = serial.read(serial.in_waiting)
print("Sending", len(data), "bytes:", data)
try:
uart.write(data)
except OSError as e:
print("UART write error:", e)
Setting up the HID Microcontroller
On the second microcontroller, use the following code in code.py:
import busio
import board
import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode
from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS
uart = busio.UART(board.TX, board.RX, baudrate=115200, timeout=0.01)
kbd = Keyboard(usb_hid.devices)
layout = KeyboardLayoutUS(kbd)
def tap(keycode):
kbd.press(keycode)
kbd.release(keycode)
while True:
data = uart.read(1)
if not data:
continue
b = data[0]
if b == 0x1b:
seq = uart.read(2)
if seq and len(seq) == 2 and seq[0] == 0x5b:
arrow = {
0x41: Keycode.UP_ARROW,
0x42: Keycode.DOWN_ARROW,
0x43: Keycode.RIGHT_ARROW,
0x44: Keycode.LEFT_ARROW,
}.get(seq[1])
if arrow:
tap(arrow)
else:
tap(Keycode.ESCAPE)
elif b in (0x0d, 0x0a):
tap(Keycode.ENTER)
elif b in (0x7f, 0x08):
tap(Keycode.BACKSPACE)
elif b == 0x09:
tap(Keycode.TAB)
elif 0x20 <= b <= 0x7e:
layout.write(chr(b))
Using the Device
To start capturing keyboard input and forwarding it to the HID microcontroller, run:
To stop forwarding input, press Ctrl+C.

Testing LapBoard during Fab26. Keyboard input from the ThinkPad (left) is forwarded through the RP2040 bridge and appears on the target laptop (right).
Spiral 2 - System-wide keyboard capture
Step 1
First I needed to understand how the laptop keyboard worked. I started by running:
This showed all the input devices connected to the laptop, including the power button, lid switch, touchpad, and keyboard.
The keyboard was listed as /dev/input/event3
Next I installed and ran evtest to see what data the keyboard was producing.
I could now see the raw key events being generated. Unlike the first prototype, this was not ASCII data but Linux input events.
Next I created a small Python program to experiment with reading the keyboard directly. I created a virtual environment and installed evdev.
I then wrote the following program:
from evdev import InputDevice, categorize, ecodes
kbd = InputDevice("/dev/input/event3")
print(kbd)
for event in kbd.read_loop():
if event.type == ecodes.EV_KEY:
print(categorize(event))
To run it:
The program successfully detected key presses and releases, including modifier keys such as Shift, Ctrl, and Alt, as well as function and arrow keys. This confirmed that I could capture keyboard events system-wide, which forms the foundation for forwarding them to the RP2040 in the next step.
Step 2
Next I installed pyserial:
I then changed keyboard.py so it would send the Linux key events over USB CDC to the first RP2040.
from evdev import InputDevice, ecodes
import serial
kbd = InputDevice("/dev/input/event3")
ser = serial.Serial("/dev/ttyACM1", 115200, timeout=1)
print(kbd)
print("Sending to", ser.port)
try:
for event in kbd.read_loop():
if event.type != ecodes.EV_KEY:
continue
key = ecodes.KEY.get(event.code, str(event.code))
message = f"{event.value},{key}\n"
ser.write(message.encode())
print(message, end="")
except KeyboardInterrupt:
print("\nStopped.")
finally:
ser.close()
To verify that the first RP2040 was receiving the data, I opened its CircuitPython console:
I could now see the key events arriving on the RP2040 over USB CDC.
Step 3
The next step was to make the second RP2040 receive the key events over UART and convert them into USB HID keyboard events.
I changed the code on the HID RP2040 to:
import busio
import board
import usb_hid
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keycode import Keycode
uart = busio.UART(
board.TX,
board.RX,
baudrate=115200,
timeout=0.01)
kbd = Keyboard(usb_hid.devices)
KEYMAP = {
"KEY_A": Keycode.A,
"KEY_B": Keycode.B,
"KEY_C": Keycode.C,
"KEY_D": Keycode.D,
"KEY_E": Keycode.E,
"KEY_F": Keycode.F,
"KEY_G": Keycode.G,
"KEY_H": Keycode.H,
"KEY_I": Keycode.I,
"KEY_J": Keycode.J,
"KEY_K": Keycode.K,
"KEY_L": Keycode.L,
"KEY_M": Keycode.M,
"KEY_N": Keycode.N,
"KEY_O": Keycode.O,
"KEY_P": Keycode.P,
"KEY_Q": Keycode.Q,
"KEY_R": Keycode.R,
"KEY_S": Keycode.S,
"KEY_T": Keycode.T,
"KEY_U": Keycode.U,
"KEY_V": Keycode.V,
"KEY_W": Keycode.W,
"KEY_X": Keycode.X,
"KEY_Y": Keycode.Y,
"KEY_Z": Keycode.Z,
"KEY_SPACE": Keycode.SPACE,
"KEY_ENTER": Keycode.ENTER,
"KEY_TAB": Keycode.TAB,
"KEY_BACKSPACE": Keycode.BACKSPACE,
"KEY_ESC": Keycode.ESCAPE,
"KEY_LEFTSHIFT": Keycode.LEFT_SHIFT,
"KEY_RIGHTSHIFT": Keycode.RIGHT_SHIFT,
"KEY_LEFTCTRL": Keycode.LEFT_CONTROL,
"KEY_RIGHTCTRL": Keycode.RIGHT_CONTROL,
"KEY_LEFTALT": Keycode.LEFT_ALT,
"KEY_RIGHTALT": Keycode.RIGHT_ALT,
"KEY_UP": Keycode.UP_ARROW,
"KEY_DOWN": Keycode.DOWN_ARROW,
"KEY_LEFT": Keycode.LEFT_ARROW,
"KEY_RIGHT": Keycode.RIGHT_ARROW,
}
buffer = b""
while True:
data = uart.read(32)
if not data:
continue
buffer += data
while b"\n" in buffer:
line, buffer = buffer.split(b"\n", 1)
try:
decoded = line.decode("utf-8")
value_text, key_name = decoded.split(",", 1)
value = int(value_text)
except (ValueError, UnicodeError):
continue
keycode = KEYMAP.get(key_name)
if keycode is None:
print("Unknown key:", key_name)
continue
if value == 1:
kbd.press(keycode)
elif value == 0:
kbd.release(keycode)
At this point I tested LapBoard with a Windows computer and it worked. The laptop keyboard was being forwarded through the two RP2040 boards and appeared as a normal USB keyboard on the target computer.
Spiral 2.5 - Grab the keyboard exclusively
LapBoard was working pretty well at this point, except the keyboard on my own laptop was still active.
This meant that if I pressed Alt+Tab, the target computer changed application, but so did my laptop. The same thing happened with other shortcuts.
The fix was easy. I changed keyboard.py so it would grab the keyboard exclusively using evdev.
I also added Ctrl+Alt+Esc as an escape sequence to stop LapBoard and give control of the keyboard back to the laptop.
from evdev import InputDevice, ecodes
import serial
kbd = InputDevice("/dev/input/event3")
ser = serial.Serial("/dev/ttyACM1", 115200, timeout=1)
print(kbd)
print("Sending to", ser.port)
print("Press Ctrl+Alt+Esc to stop LapBoard")
pressed = set()
kbd.grab()
try:
for event in kbd.read_loop():
if event.type != ecodes.EV_KEY:
continue
if event.value == 1:
pressed.add(event.code)
elif event.value == 0:
pressed.discard(event.code)
# Ctrl + Alt + Esc exits LapBoard
ctrl_pressed = (
ecodes.KEY_LEFTCTRL in pressed or
ecodes.KEY_RIGHTCTRL in pressed
)
alt_pressed = (
ecodes.KEY_LEFTALT in pressed or
ecodes.KEY_RIGHTALT in pressed
)
if (
ctrl_pressed
and alt_pressed
and event.code == ecodes.KEY_ESC
and event.value == 1
):
print("\nCtrl+Alt+Esc detected. Stopping LapBoard.")
break
key = ecodes.KEY.get(event.code, str(event.code))
message = f"{event.value},{key}\n"
ser.write(message.encode())
finally:
ser.write(b"0,KEY_LEFTCTRL\n")
ser.write(b"0,KEY_RIGHTCTRL\n")
ser.write(b"0,KEY_LEFTALT\n")
ser.write(b"0,KEY_RIGHTALT\n")
ser.write(b"0,KEY_LEFTSHIFT\n")
ser.write(b"0,KEY_RIGHTSHIFT\n")
kbd.ungrab()
ser.close()
print("Keyboard released.")
There were also some missing punctuation keys that I had not mapped yet. These were easy to add to the KEYMAP on the HID RP2040.
"KEY_DOT": Keycode.PERIOD,
"KEY_COMMA": Keycode.COMMA,
"KEY_SLASH": Keycode.FORWARD_SLASH,
"KEY_SEMICOLON": Keycode.SEMICOLON,
"KEY_APOSTROPHE": Keycode.QUOTE,
"KEY_MINUS": Keycode.MINUS,
"KEY_EQUAL": Keycode.EQUALS,
"KEY_LEFTBRACE": Keycode.LEFT_BRACKET,
"KEY_RIGHTBRACE": Keycode.RIGHT_BRACKET,
"KEY_BACKSLASH": Keycode.BACKSLASH,
"KEY_GRAVE": Keycode.GRAVE_ACCENT,
At this point LapBoard became a functional tool that should be very handy while traveling. I can use my laptop as a keyboard for another computer or Raspberry Pi without having to carry or search for a spare keyboard.
Spiral 3 - Touchpad support and packaging
not implemented yet
Spiral 4 - Bluetooth or wireless bridge
not implemented yet