Text Editor

Adafruit recommends using the Mu editor for editing your CircuitPython code. You can get more info in this guide.

Alternatively, you can use any text editor that saves simple text files.

Download the Project Bundle

Your project will use a specific set of CircuitPython libraries and the code.py file. To get everything you need, click on the Download Project Bundle link below, and uncompress the .zip file.

Hook your QT Py/board to your computer via a known good USB data+power cable. It should show up as a thumb drive named CIRCUITPY.

Using File Explorer/Finder (depending on your Operating System), drag the contents of the uncompressed bundle directory onto your board's CIRCUITPY drive, replacing any existing files or directories with the same names, and adding any new ones that are necessary.

Continue below the program listing for a breakdown of how the program functions, section by section.

# SPDX-FileCopyrightText: 2022 Jeff Epler for Adafruit Industries
# SPDX-License-Identifier: MIT
import array
import time

import board
import rp2pio
import usb_hid
from keypad import Keys
from adafruit_hid.consumer_control import ConsumerControl
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keyboard import Keycode
from adafruit_hid.mouse import Mouse
from adafruit_pioasm import Program
from adafruit_ticks import ticks_add, ticks_less, ticks_ms
from next_keycode import (
    cc_value,
    is_cc,
    next_modifiers,
    next_scancodes,
    shifted_codes,
    shift_modifiers,
)


# Compared to a modern mouse, the DPI of the NeXT mouse is low. Increasing this
# number makes the pointer move further faster, but it also makes moves chunky.
# Customize this number according to the trade-off you want, but also check
# whether your operating system can assign a higher "sensitivity" or
# "acceleration" for the mouse.
MOUSE_SCALE = 8

# Customize the power key's keycode. You can change it to `Keycode.POWER` if
# you really want to accidentally power off your computer!
POWER_KEY_SENDS = Keycode.F1

# according to https://journal.spencerwnelson.com/entries/nextkb.html the
# keyboard's timing source is a 455MHz crystal, and the serial data rate is
# 1/24 the crystal frequency. This differs by a few percent from the "50us" bit
# time reported in other sources.
NEXT_SERIAL_BUS_FREQUENCY = round(455_000 / 24)

pio_program = Program(
    """
top:
    set pins, 1
    pull block             ; wait for send request
    out x, 1               ; trigger receive?
    out y, 7               ; get count of bits to transmit (minus 1)

bitloop:
    out pins, 1        [7] ; send next bit
    jmp y--, bitloop   [7] ; loop if bits left to send

    set pins, 1            ; idle the bus after last bit
    jmp !x, top            ; to top if no scancode expected

    set pins, 1            ; mark bus as idle so keyboard will send
    set y, 19              ; 20 bits to receive

    wait 0, pin 0 [7]      ; wait for falling edge plus half bit time
recvloop:
    in pins, 1 [7]         ; sample in the middle of the bit
    jmp y--, recvloop [7]  ; loop until all bits read

    push                   ; send report to CircuitPython
"""
)


def pack_message(bitcount, data, trigger_receive=False):
    if bitcount > 24:
        raise ValueError("too many bits in message")
    trigger_receive = bool(trigger_receive)
    message = (
        (trigger_receive << 31) | ((bitcount - 1) << 24) | (data << (24 - bitcount))
    )
    return array.array("I", [message])


def pack_message_str(bitstring, trigger_receive=False):
    bitcount = len(bitstring)
    data = int(bitstring, 2)
    return pack_message(bitcount, data, trigger_receive=trigger_receive)


def set_leds(i):
    return pack_message_str(f"0000000001110{i:02b}0000000")


QUERY = pack_message_str("000001000", True)
MOUSEQUERY = pack_message_str("010001000", True)
RESET = pack_message_str("0111101111110000000000")

BIT_BREAK = 1 << 11
BIT_MOD = 1


def is_make(report):
    return not bool(report & BIT_BREAK)


def is_mod_report(report):
    return not bool(report & BIT_MOD)


def extract_bits(report, *positions):
    result = 0
    for p in positions:
        result = (result << 1)
        if report & (1 << p):
            result |= 1
        #result = (result << 1) | bool(report & (1<<p))
    return result

# keycode bits are backwards compared to other information sources
# (bit 0 is first)
def keycode(report):
    return extract_bits(report, 12, 13, 14, 15, 16, 17, 18)


def modifiers(report):
    return (report >> 1) & 0x7F


sm = rp2pio.StateMachine(
    pio_program.assembled,
    first_in_pin=board.MISO,
    pull_in_pin_up=1,
    first_set_pin=board.MOSI,
    set_pin_count=1,
    first_out_pin=board.MOSI,
    out_pin_count=1,
    frequency=16 * NEXT_SERIAL_BUS_FREQUENCY,
    in_shift_right=False,
    wait_for_txstall=False,
    out_shift_right=False,
    **pio_program.pio_kwargs,
)


def signfix(num, sign_pos):
    """Fix a signed number if the bit with weight `sign_pos` is actually the sign bit"""
    if num & sign_pos:
        return num - 2*sign_pos
    return num

class KeyboardHandler:
    def __init__(self):
        self.old_modifiers = 0
        self.cc = ConsumerControl(usb_hid.devices)
        self.kbd = Keyboard(usb_hid.devices)
        self.mouse = Mouse(usb_hid.devices)

    def set_key_state(self, key, state):
        if state:
            if isinstance(key, tuple):
                old_report_modifier = self.kbd.report_modifier[0]
                self.kbd.report_modifier[0] = 0
                self.kbd.press(*key)
                self.kbd.release_all()
                self.kbd.report_modifier[0] = old_report_modifier
            else:
                self.kbd.press(key)
        else:
            if isinstance(key, tuple):
                pass
            else:
                self.kbd.release(key)

    def handle_mouse_report(self, report):
        if report == 1536: # the "nothing happened" report
            return

        dx = extract_bits(report, 11,12,13,14,15,16,17)
        dx = -signfix(dx, 64)
        dy = extract_bits(report, 0,1,2,3,4,5,6)
        dy = -signfix(dy, 64)
        b1 = not extract_bits(report, 18)
        b2 = not extract_bits(report, 7)

        self.mouse.report[0] = (
            Mouse.MIDDLE_BUTTON if (b1 and b2) else
            Mouse.LEFT_BUTTON if b1 else
            Mouse.RIGHT_BUTTON if b2
            else 0)
        if dx or dy:
            self.mouse.move(dx * MOUSE_SCALE, dy * MOUSE_SCALE)
        else:
            self.mouse._send_no_move() # pylint: disable=protected-access

    def handle_report(self, report_value):
        if report_value == 1536: # the "nothing happened" report
            return

        # Handle modifier changes
        mods = modifiers(report_value)
        changes = self.old_modifiers ^ mods
        self.old_modifiers = mods
        for i in range(7):
            bit = 1 << i
            if changes & bit:  # Modifier key pressed or released
                self.set_key_state(next_modifiers[i], mods & bit)

        # Handle key press/release
        code = next_scancodes.get(keycode(report_value))
        if mods & shift_modifiers:
            code = shifted_codes.get(keycode(report_value), code)
        make = is_make(report_value)
        if code:
            if is_cc(code):
                if make:
                    self.cc.send(cc_value(code))
            else:
                self.set_key_state(code, make)

keys = Keys([board.SCK], value_when_pressed=False)

handler = KeyboardHandler()

recv_buf = array.array("I", [0])

time.sleep(0.1)
sm.write(RESET)
time.sleep(0.1)

for _ in range(4):
    sm.write(set_leds(3))
    time.sleep(0.1)
    sm.write(set_leds(0))
    time.sleep(0.1)

print("Keyboard ready!")

try:
    while True:
        if (event := keys.events.get()):
            handler.set_key_state(POWER_KEY_SENDS, event.pressed)

        sm.write(QUERY)
        deadline = ticks_add(ticks_ms(), 100)
        while ticks_less(ticks_ms(), deadline):
            if sm.in_waiting:
                sm.readinto(recv_buf)
                value = recv_buf[0]
                handler.handle_report(value)
                break
        else:
            print("keyboard did not respond - resetting")
            sm.restart()
            sm.write(RESET)
            time.sleep(0.1)

        sm.write(MOUSEQUERY)
        deadline = ticks_add(ticks_ms(), 100)
        while ticks_less(ticks_ms(), deadline):
            if sm.in_waiting:
                sm.readinto(recv_buf)
                value = recv_buf[0]
                handler.handle_mouse_report(value)
                break
        else:
            print("keyboard did not respond - resetting")
            sm.restart()
            sm.write(RESET)
            time.sleep(0.1)
finally:  # Release all keys before e.g., code is reloaded
    handler.kbd.release_all()
Folder

How it works

Preliminaries

Note how one of the import lines imports from next_keycode. This file, included alongside code.py in the project bundle, has the long list of correspondences between NeXT keyboard codes and USB keyboard codes.

After the required import lines, there is a line where you can customize the USB HID code sent by the power button. The guide defaults it to F1, because unexpectedly powering off a computer is not so great. Note that the button does not work to power on the computer, either, no matter how you configure it:

# SPDX-FileCopyrightText: 2022 Jeff Epler for Adafruit Industries
# SPDX-License-Identifier: MIT
import array
import time

import board
import rp2pio
import usb_hid
from keypad import Keys
from adafruit_hid.consumer_control import ConsumerControl
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keyboard import Keycode
from adafruit_pioasm import Program
from adafruit_ticks import ticks_add, ticks_less, ticks_ms
from next_keycode import (
    cc_value,
    is_cc,
    next_modifiers,
    next_scancodes,
    shifted_codes,
    shift_modifiers,
)
                                                                                
# Customize the power key's keycode. You can change it to `Keycode.POWER` if    
# you really want to accidentally power off your computer!                      
POWER_KEY_SENDS = Keycode.F1

Next, the code defines a program for the PIO peripheral to actually communicate with the keyboard.

A message can be a varying number of bits, and it can optionally call for a response from the keyboard. The PIO program expects this number first, accompanied by the bits to be sent.

Then, if the "response" bit is set, it will await a response from the keyboard. A response is always treated as consisting of 20 bits.

# according to https://journal.spencerwnelson.com/entries/nextkb.html the       
# keyboard's timing source is a 455MHz crystal, and the serial data rate is     
# 1/24 the crystal frequency. This differs by a few percent from the "50us" bit 
# time reported in other sources.                                               
NEXT_SERIAL_BUS_FREQUENCY = round(455_000 / 24)                                 
                                                                                
pio_program = Program(                                                          
    """                                                                         
top:                                                                            
    set pins, 1                                                                 
    pull block             ; wait for send request                              
    out x, 1               ; trigger receive?                                   
    out y, 7               ; get count of bits to transmit (minus 1)            
                                                                                
bitloop:                                                                        
    out pins, 1        [7] ; send next bit                                      
    jmp y--, bitloop   [7] ; loop if bits left to send                          
                                                                                
    set pins, 1            ; idle the bus after last bit                        
    jmp !x, top            ; to top if no scancode expected                     
                                                                                
    set pins, 1            ; mark bus as idle so keyboard will send             
    set y, 19              ; 20 bits to receive                                 

    wait 0, pin 0 [7]      ; wait for falling edge plus half bit time
recvloop:
    in pins, 1 [7]         ; sample in the middle of the bit
    jmp y--, recvloop [7]  ; loop until all bits read

    push                   ; send report to CircuitPython
"""
)

The next block of code is concerned with constructing messages to send to the keyboard. QUERY and RESET messages are defined for use later, as well as a function to set the keyboard LEDs:

def pack_message(bitcount, data, trigger_receive=False):
    if bitcount > 24:
        raise ValueError("too many bits in message")
    trigger_receive = bool(trigger_receive)
    message = (
        (trigger_receive << 31) | ((bitcount - 1) << 24) | (data << (24 - bitcount))
    )
    return array.array("I", [message])


def pack_message_str(bitstring, trigger_receive=False):
    bitcount = len(bitstring)
    data = int(bitstring, 2)
    return pack_message(bitcount, data, trigger_receive=trigger_receive)


def set_leds(i):
    return pack_message_str(f"0000000001110{i:02b}0000000")


QUERY = pack_message_str("000001000", True)
MOUSEQUERY = pack_message_str("010001000", True)
RESET = pack_message_str("0111101111110000000000")

When a key event is sent back to CircuitPython, we need to decode it. These constants and functions help with that task, allowing the keycodes, modifiers (keys like CTRL and ALT which don't directly generate characters), and make/break state to be checked:

BIT_BREAK = 1 << 11
BIT_MOD = 1


def is_make(report):
    return not bool(report & BIT_BREAK)


def is_mod_report(report):
    return not bool(report & BIT_MOD)


def extract_bits(report, *positions): 
    result = 0
    for p in positions:
        result = (result << 1)
        if report & (1 << p):
            result |= 1
        #result = (result << 1) | bool(report & (1<<p))
    return result


# keycode bits are backwards compared to other information sources
# (bit 0 is first)  
def keycode(report):
    return extract_bits(report, 12, 13, 14, 15, 16, 17, 18)


def modifiers(report):
    return (report >> 1) & 0x7F

The power button is connected directly to one of the wires, so read it like a 1-key keypad instead:

keys = Keys([board.SCK], value_when_pressed=False)

There is also code concerned with decoding mouse messages, extracting the correct bits and assembling them into signed "delta" (movement) values for X and Y. It treats "left+right" button as middle button, which was a common thing on Linux computers with two button mice back in the 90s. Another useful thing to do when both buttons are pressed would be to look at the Y movement and send wheel motion instead of regular motion, which is pretty straightforward with the HID library.

def handle_mouse_report(self, report):
        if report == 1536: # the "nothing happened" report
            return

        dx = extract_bits(report, 11,12,13,14,15,16,17)
        dx = -signfix(dx, 64)
        dy = extract_bits(report, 0,1,2,3,4,5,6)
        dy = -signfix(dy, 64)
        b1 = not extract_bits(report, 18)
        b2 = not extract_bits(report, 7)

        self.mouse.report[0] = (
            Mouse.MIDDLE_BUTTON if (b1 and b2) else
            Mouse.LEFT_BUTTON if b1 else
            Mouse.RIGHT_BUTTON if b2
            else 0)
        if dx or dy:
            self.mouse.move(dx * MOUSE_SCALE, dy * MOUSE_SCALE)
        else:
            self.mouse._send_no_move() # pylint: disable=protected-access

Reset the keyboard, then blink the LEDs to show that everything's working:

time.sleep(0.1)
sm.write(RESET)
time.sleep(0.1)

for _ in range(4):
    sm.write(set_leds(3))
    time.sleep(0.1)
    sm.write(set_leds(0))
    time.sleep(0.1)

print("Keyboard ready!")

In the forever loop, check for keyboard messages as well as for the independent power key. Act on each value as appropriate. When the program is stopped (for instance, because you updated code.py), use a 'finally clause' to release all keys before actually exiting:

try:
    while True:
        if (event := keys.events.get()):
            handler.set_key_state(POWER_KEY_SENDS, event.pressed)

        sm.write(QUERY)
        deadline = ticks_add(ticks_ms(), 100)
        while ticks_less(ticks_ms(), deadline):
            if sm.in_waiting:
                sm.readinto(recv_buf)
                value = recv_buf[0]
                handler.handle_report(value)
                break
        else:
            print("keyboard did not respond - resetting")
            sm.restart()
            sm.write(RESET)
            time.sleep(0.1)

        sm.write(MOUSEQUERY)
        deadline = ticks_add(ticks_ms(), 100)
        while ticks_less(ticks_ms(), deadline):
            if sm.in_waiting:
                sm.readinto(recv_buf)
                value = recv_buf[0]
                handler.handle_mouse_report(value)
                break
        else:  
            print("keyboard did not respond - resetting")
            sm.restart()
            sm.write(RESET)
            time.sleep(0.1)
finally:  # Release all keys before e.g., code is reloaded
    handler.kbd.release_all()

Until you've wired up the keyboard, CircuitPython won't do anything but show the message "keyboard did not respond - resetting" in the Serial REPL. You'll need to actually connect your keyboard to the QT Py as detailed on the following page.

This guide was first published on Dec 07, 2022. It was last updated on Mar 28, 2024.

This page (Coding the Keyboard) was last updated on Mar 28, 2024.

Text editor powered by tinymce.