A while ago I’ve made a MacroPad and I recently improved the code! In this post I’ll briefly show some advanced code to create a MacroPad class which allows custom functions to be added to key presses using a decorator. The original code as well as instructions how to build one yourself can be found in the original post.

Completed MacroPad

Event based library

I got the idea for implementing this library from the KeyBow 2040 that implements this specifically for their hardware. This makes the code to actually program their keypad a lot less complex. You can see in the example below, that you simply write the function you want and add @keybow.on_press(key) above the function you want to run when the key is pressed. Let’s see if this can be ported to my MacroPad.

# Example code from the KeyBow 2040 GitHub Repo

@keybow.on_press(key)
def press_handler(key):
    key.led_on()

To achieve this a new library needs to be created that can set up the MacroPad, handle keystrokes and allow you to attach new functions to each key pressed. This is an advanced bit of code, and I won’t go through each part step by step, but it is a great example how you can create a library with a class that allows users to mix in their own functions that are triggered at specific point in the class’ code. It will also handle the light effects so this doesn’t need to be handled by the user.

import board
import digitalio
import pwmio
import time

# Configuration, which LED pins are used, which buttons, how buttons map to macros
led_pins = [board.GP18,board.GP17,board.GP16,board.GP21,board.GP20,board.GP19, board.GP27, board.GP26,board.GP22]
button_pins = [board.GP13,board.GP14,board.GP15, board.GP10,board.GP11,board.GP12,board.GP7,board.GP8,board.GP9]

class Button(object):
    def __init__(self, button_index, button_pin, led_pin, repeat=True, repeat_time=0.075, first_repeat_time=0.5):
        self.number = button_index
        
        self.button = digitalio.DigitalInOut(button_pin)
        self.button.direction = digitalio.Direction.INPUT
        self.button.pull = digitalio.Pull.UP
        
        self.led = pwmio.PWMOut(led_pin, frequency=1000, duty_cycle=0)
        self.last_pressed = 0
        
        self.triggered = False
        
        self.on_press = None
        self.on_release = None
        
        self.repeat = repeat
        self.repeat_time = repeat_time
        self.first_repeat_time = first_repeat_time
        self.first_repeat = True
        
        self.time_of_last_press = time.monotonic()

    
    @property
    def pressed(self):
        return not self.button.value
    
    def set_duty_cycle(self, value):
        self.led.duty_cycle = value
    
    def fade(self, value=900):
        self.led.duty_cycle = max(self.led.duty_cycle - value, 0)
    
    def update(self):
        self.time_since_last_press = time.monotonic() - self.time_of_last_press
        
        if self.pressed and (not self.triggered or
                             (self.time_since_last_press > self.repeat_time and self.repeat and not self.first_repeat) or
                             (self.time_since_last_press > self.first_repeat_time and self.first_repeat and self.repeat)):
             
             self.time_of_last_press = time.monotonic()
             
             if self.time_since_last_press > self.first_repeat_time and self.first_repeat and self.triggered:
                 self.first_repeat = False
                 
             self.triggered = True
            
             self.set_duty_cycle(65025)
                 
             if self.on_press is not None:
                 self.on_press(self)
                
        elif self.triggered and not self.pressed:
            self.first_repeat = True
            self.triggered = False
            
            if self.on_release is not None:
                self.on_release(self)
                
        self.fade()

class Macropad(object):
    def __init__(self):
        print("Init Macropad")
        
        # Set up buttons
        self.buttons = []
        for ix, (bp, lp) in enumerate(zip(button_pins, led_pins)):
            self.buttons.append(Button(ix, bp, lp))
    
    def on_press(self, button, handler=None):
        if button is None:
            return
        
        def attach_handler(handler):
            button.on_press = handler

        if handler is not None:
            attach_handler(handler)
        else:
            return attach_handler
    
    def on_release(self, button, handler=None):
        if button is None:
            return
        
        def attach_handler(handler):
            button.on_release = handler

        if handler is not None:
            attach_handler(handler)
        else:
            return attach_handler
    
    def update(self):
        for btn in self.buttons:
            btn.update()
            
        time.sleep(0.01)

This file needs to be saved as macropad.py (download it here) and stored in the root directory of the Pi Pico powering the pad.

Much cleaner code

With the library in place, we just need to create the code.py file that specifies what each button does. Like in the previous version, we’ll emulate a keyboard and attach some shortcuts to each key.

from macropad import Macropad
macropad = Macropad()
buttons = macropad.buttons

@macropad.on_press(buttons[0])
def press_first_button(button):
    print(f"pressed the first button")

@macropad.on_press(buttons[1])
def press_second_button(button):
    print(f"pressed the second button")

Or in combination with the Adafruit HID library to emulate a keyboard. One caveat, using the decorator in a loop can be tricky.

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

from macropad import Macropad

keyboard = Keyboard(usb_hid.devices)

macropad = Macropad()
buttons = macropad.buttons

button_mapping = [
    [Keycode.LEFT_CONTROL, Keycode.WINDOWS, Keycode.LEFT_ARROW],
    [Keycode.WINDOWS, Keycode.TAB],
    [Keycode.LEFT_CONTROL, Keycode.WINDOWS, Keycode.RIGHT_ARROW],
    [Keycode.LEFT_CONTROL, Keycode.F4],
    [Keycode.LEFT_CONTROL, Keycode.F5],
    [Keycode.LEFT_CONTROL, Keycode.F6],
    [Keycode.LEFT_CONTROL, Keycode.F7],
    [Keycode.LEFT_CONTROL, Keycode.F8],
    [Keycode.LEFT_CONTROL, Keycode.F9]]

for btn in buttons:
    @macropad.on_press(btn)
    def press_button(button):
        print(f"pressed {button.number}")
        keyboard.press(*button_mapping[button.number])

    @macropad.on_release(btn)
    def release_button(button):
        print(f"released {button.number}")
        keyboard.release(*button_mapping[button.number])
    
while True:
    macropad.update()

My shortcuts

I assigned three buttons to shift between different virtual desktops and show the overview. As I currently have a single screen (albeit a rather big one), being able to flip to another desktop with a single button provides an experience similar to having a dual-monitor setup. Potentially even better, as you can have distracting apps like your mail open in another virtual desktop and only switch when you feel like it. Incoming mail isn’t screaming for attention from the secondary screen. Ctrl + F4 closes a browser tab, while ctrl + F5 is a hard refresh of a web page (comes in useful when developing).

The four other buttons I’m still trying to find a good purpose for, any suggestions ?

Conclusion

While you might have worked with libraries that use decorators (e.g. Flask), setting this up yourself takes a bit of thinking/tinkering. Though in this case going the extra mile to create a library that handles the key presses, so you can focus on the code that needs to run when a button is pressed pays off.