Skip to content

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.


lapboard

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

setup

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:

import usb_cdc
usb_cdc.enable(console=True, data=True)

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:

screen /dev/ttyACM1 115200

To stop forwarding input, press Ctrl+C.

lapboard test

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:

cat /proc/bus/input/devices

This showed all the input devices connected to the laptop, including the power button, lid switch, touchpad, and keyboard.

AT Translated Set 2 keyboard
Handlers=sysrq kbd event3 leds

The keyboard was listed as /dev/input/event3

Next I installed and ran evtest to see what data the keyboard was producing.

sudo apt install evtest
sudo evtest

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.

python3 -m venv .venv
source .venv/bin/activate
pip install evdev
pip freeze > requirements.txt

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:

sudo .venv/bin/python keyboard.py

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:

pip install 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:

screen /dev/ttyACM0 115200

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