MakroKeyboard
This is hopefully the last guide you will need to create a basic macrokeyboard with CircuitPython. I hope this guide helps
- CircuitPython
- Prototype - Fritzing Diagram
- Prototype - Code
- Prototype Code Explanation
- Importing Libraries
- Setting Up Consumer Control and Keyboard
- OLED Display Setup
- I2C Configuration
- Creating the Display Context
- Background Setup:
- Drawing a Label on the Display
- Button Setup
- Mode Change Button
- Rotary Encoder Setup
- Rotary Encoder Variables
- Mode Names
- Define Macros for Different Modes
- Define Rotary Encoder Actions for Different Modes
- Function to Update the Macro Label on the OLED Screen
- Main Loop
- Handle Rotary Encoder
- Handle Rotary Encoder Switch
- Handle Mode Change Button
- Final Sleep
On this page
- CircuitPython
- Prototype - Fritzing Diagram
- Prototype - Code
- Prototype Code Explanation
- Importing Libraries
- Setting Up Consumer Control and Keyboard
- OLED Display Setup
- I2C Configuration
- Creating the Display Context
- Background Setup:
- Drawing a Label on the Display
- Button Setup
- Mode Change Button
- Rotary Encoder Setup
- Rotary Encoder Variables
- Mode Names
- Define Macros for Different Modes
- Define Rotary Encoder Actions for Different Modes
- Function to Update the Macro Label on the OLED Screen
- Main Loop
- Handle Rotary Encoder
- Handle Rotary Encoder Switch
- Handle Mode Change Button
- Final Sleep
CircuitPython
Here you can download the CircuitPython firmware used in this course video
Prototype - Fritzing Diagram
Prototype - Code
Button Test
import board
import digitalio
import time
# Define the GPIO pins for the buttons (GP0 to GP8)
button_pins = [board.GP0, board.GP1, board.GP2, board.GP3, board.GP4, board.GP5, board.GP6, board.GP7, board.GP8]
# Create a list to hold the button objects
buttons = []
# Initialize each button pin with pull-up resistors
for pin in button_pins:
button = digitalio.DigitalInOut(pin)
button.direction = digitalio.Direction.INPUT
button.pull = digitalio.Pull.UP # Pull-up resistor
buttons.append(button)
print("Button test started. Press any button.")
try:
while True:
for index, button in enumerate(buttons):
if not button.value: # Button is pressed (pulled to ground)
print(f"Button {index + 1} pressed!")
time.sleep(0.2) # Debounce delay
time.sleep(0.1) # Slight delay to reduce CPU usage
except KeyboardInterrupt:
print("Button test stopped.")
Libraries
Download the libraries for the project here:
OLED display Test
import board
import busio
import displayio
import terminalio
from adafruit_display_text import label
import adafruit_displayio_ssd1306
# Release any displays that may be in use
displayio.release_displays()
# I2C setup for the OLED display
sda, scl = board.GP16, board.GP17
i2c = busio.I2C(scl, sda)
display_bus = displayio.I2CDisplay(i2c, device_address=0x3C)
# Initialize the OLED display
display = adafruit_displayio_ssd1306.SSD1306(display_bus, width=128, height=64)
# Make the display context
splash = displayio.Group()
display.show(splash)
# Draw a label with the text "Hello, World!"
text_area = label.Label(terminalio.FONT, text="Hello, World!", color=0xFFFFFF, x=35, y=28)
splash.append(text_area)
# Keep the display on
while True:
pass
Rotary Encoder Test
import board
import digitalio
import time
# Define GPIO pins for the rotary encoder
CLK_PIN = board.GP20
DT_PIN = board.GP19
SW_PIN = board.GP18
# Set up the rotary encoder pins
clk = digitalio.DigitalInOut(CLK_PIN)
clk.direction = digitalio.Direction.INPUT
clk.pull = digitalio.Pull.UP # Pull-up resistor
dt = digitalio.DigitalInOut(DT_PIN)
dt.direction = digitalio.Direction.INPUT
dt.pull = digitalio.Pull.UP # Pull-up resistor
# Set up the switch pin
sw = digitalio.DigitalInOut(SW_PIN)
sw.direction = digitalio.Direction.INPUT
sw.pull = digitalio.Pull.UP # Pull-up resistor
# Initialize variables
previous_clk_value = clk.value
counter = 0
print("Rotary Encoder Test. Turn the encoder or press the switch.")
while True:
# Read the current state of the CLK pin
current_clk_value = clk.value
# Check if the encoder is turned
if current_clk_value != previous_clk_value:
if current_clk_value == 0: # Clockwise turn
if dt.value == 0: # If DT is low, it's a clockwise turn
counter += 1
print(f"Counter: {counter} (Clockwise)")
else: # If DT is high, it's a counterclockwise turn
counter -= 1
print(f"Counter: {counter} (Counterclockwise)")
# Update previous CLK value
previous_clk_value = current_clk_value
# Check if the switch is pressed
if not sw.value: # Switch is pressed
print("Switch pressed!")
time.sleep(0.5) # Debounce delay
time.sleep(0.01) # Small delay to reduce CPU usage
Prototype Code
import board
import busio
import displayio
import terminalio
from adafruit_display_text import label
import adafruit_displayio_ssd1306
import usb_hid
import digitalio
import time
from adafruit_hid.keycode import Keycode
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS
from adafruit_hid.consumer_control import ConsumerControl
from adafruit_hid.consumer_control_code import ConsumerControlCode
# Set up Consumer Control and Keyboard
cc = ConsumerControl(usb_hid.devices)
keyboard = Keyboard(usb_hid.devices)
write_text = KeyboardLayoutUS(keyboard)
# OLED Display Setup
displayio.release_displays()
# Change I2C pins to GP15 and GP14
sda, scl = board.GP16, board.GP17
i2c = busio.I2C(scl, sda)
display_bus = displayio.I2CDisplay(i2c, device_address=0x3C)
display = adafruit_displayio_ssd1306.SSD1306(display_bus, width=128, height=64)
# Make the display context
splash = displayio.Group()
display.show(splash)
# Setup display background
color_bitmap = displayio.Bitmap(128, 64, 1)
color_palette = displayio.Palette(1)
bg_sprite = displayio.TileGrid(color_bitmap, pixel_shader=color_palette, x=0, y=0)
splash.append(bg_sprite)
# Draw a label
text = "NerdCave!"
text_area = label.Label(terminalio.FONT, text=text, color=0xFFFF00, x=35, y=28)
splash.append(text_area)
# Button Setup
buttons = [board.GP0, board.GP1, board.GP2, board.GP3, board.GP4, board.GP5, board.GP6, board.GP7]
key = [digitalio.DigitalInOut(pin_name) for pin_name in buttons]
for button in key:
button.direction = digitalio.Direction.INPUT
button.pull = digitalio.Pull.UP # Use pull-up resistors
modeChangeButton = digitalio.DigitalInOut(board.GP8)
modeChangeButton.direction = digitalio.Direction.INPUT
modeChangeButton.pull = digitalio.Pull.UP
# Rotary Encoder Setup
CLK_PIN = board.GP20
DT_PIN = board.GP19
SW_PIN = board.GP18
# Set up the rotary encoder pins
clk = digitalio.DigitalInOut(CLK_PIN)
clk.direction = digitalio.Direction.INPUT
clk.pull = digitalio.Pull.UP # Pull-up resistor
dt = digitalio.DigitalInOut(DT_PIN)
dt.direction = digitalio.Direction.INPUT
dt.pull = digitalio.Pull.UP # Pull-up resistor
# Set up the switch pin
sw = digitalio.DigitalInOut(SW_PIN)
sw.direction = digitalio.Direction.INPUT
sw.pull = digitalio.Pull.UP # Pull-up resistor
# Rotary Encoder Variables
previous_clk_value = clk.value
counter = 0
last_rotary_time = 0 # Variable to track the last time the rotary encoder was turned
# Mode Names
mode_names = {1: 'Blender', 2: 'Windows', 3: 'Premier Pro', 4: "After Effects", 5: "Fusion360"}
mode = 1
print(mode_names[mode])
# Define macros for different modes
macros = {
1: [
lambda: cc.send(ConsumerControlCode.VOLUME_DECREMENT), # Button 1
lambda: cc.send(ConsumerControlCode.PLAY_PAUSE), # Button 2
lambda: (keyboard.send(Keycode.GUI), write_text.write('chrome\n')), # Button 3
lambda: cc.send(ConsumerControlCode.VOLUME_INCREMENT), # Button 4
lambda: keyboard.send(Keycode.N) # Button 5
],
2: [
lambda: keyboard.send(Keycode.A), # Button 1
lambda: keyboard.send(Keycode.B), # Button 2
lambda: keyboard.send(Keycode.C), # Button 3
lambda: keyboard.send(Keycode.D), # Button 4
lambda: keyboard.send(Keycode.E) # Button 5
],
3: [
lambda: keyboard.send(Keycode.F), # Button 1
lambda: keyboard.send(Keycode.G), # Button 2
lambda: keyboard.send(Keycode.H), # Button 3
lambda: keyboard.send(Keycode.I), # Button 4
lambda: keyboard.send(Keycode.J) # Button 5
],
4: [
lambda: keyboard.send(Keycode.K), # Button 1
lambda: keyboard.send(Keycode.L), # Button 2
lambda: keyboard.send(Keycode.M), # Button 3
lambda: keyboard.send(Keycode.N), # Button 4
lambda: keyboard.send(Keycode.O) # Button 5
],
5: [
lambda: keyboard.send(Keycode.P), # Button 1
lambda: keyboard.send(Keycode.Q), # Button 2
lambda: keyboard.send(Keycode.R), # Button 3
lambda: keyboard.send(Keycode.S), # Button 4
lambda: keyboard.send(Keycode.T) # Button 5
]
}
# Define rotary encoder actions for different modes
rotary_actions = {
1: {
'clockwise': lambda: cc.send(ConsumerControlCode.VOLUME_DECREMENT),
'counterclockwise': lambda: cc.send(ConsumerControlCode.VOLUME_INCREMENT),
'switch': lambda: cc.send(ConsumerControlCode.VOLUME_INCREMENT) # Example action for mode 1
},
2: {
'clockwise': lambda: keyboard.send(Keycode.UP_ARROW), # Example action for mode 2
'counterclockwise': lambda: keyboard.send(Keycode.DOWN_ARROW),
'switch': lambda: print("Rotary Switch Pressed in Mode 2") # Example action for mode 2
},
3: {
'clockwise': lambda: keyboard.send(Keycode.LEFT_ARROW), # Example action for mode 3
'counterclockwise': lambda: keyboard.send(Keycode.RIGHT_ARROW),
'switch': lambda: print("Rotary Switch Pressed in Mode 3") # Example action for mode 3
},
4: {
'clockwise': lambda: cc.send(ConsumerControlCode.PLAY_PAUSE), # Example action for mode 4
'counterclockwise': lambda: cc.send(ConsumerControlCode.VOLUME_INCREMENT),
'switch': lambda: print("Rotary Switch Pressed in Mode 4") # Example action for mode 4
},
5: {
'clockwise': lambda: keyboard.send(Keycode.SPACE), # Example action for mode 5
'counterclockwise': lambda: keyboard.send(Keycode.ESCAPE),
'switch': lambda: print("Rotary Switch Pressed in Mode 5") # Example action for mode 5
}
}
# Function to update the macro label on the OLED screen
def update_macro_label(macro_name):
macro_label = label.Label(terminalio.FONT, text=macro_name, color=0xFFFF00, x=0, y=55)
splash.append(macro_label)
display.refresh()
time.sleep(3)
splash.remove(macro_label)
display.refresh()
# Main loop
while True:
# Handle button presses
for i, button in enumerate(key):
if not button.value: # Button is pressed
print(f"Button {i + 1} pressed!") # Debug statement
if i < len(macros[mode]):
macros[mode][i]() # Call the corresponding macro
time.sleep(0.2) # Short delay after button press
# Handle rotary encoder
current_clk_value = clk.value
current_time = time.monotonic() # Get the current time
# Check if the encoder is turned
if current_clk_value != previous_clk_value and (current_time - last_rotary_time) > 0.2:
if current_clk_value == 0: # Clockwise turn
if dt.value == 0: # If DT is low, it's a clockwise turn
counter += 1
if mode in rotary_actions and 'clockwise' in rotary_actions[mode]:
rotary_actions[mode]['clockwise']() # Call the clockwise action for the current mode
print(f"Counter: {counter} (Clockwise)")
else: # If DT is high, it's a counterclockwise turn
counter -= 1
if mode in rotary_actions and 'counterclockwise' in rotary_actions[mode]:
rotary_actions[mode]['counterclockwise']() # Call the counterclockwise action for the current mode
print(f"Counter: {counter} (Counterclockwise)")
# Update previous CLK value and last rotary time
previous_clk_value = current_clk_value
last_rotary_time = current_time
# Handle rotary encoder switch
if not sw.value: # Switch is pressed
print("Rotary Button pressed")
if mode in rotary_actions and 'switch' in rotary_actions[mode]:
rotary_actions[mode]['switch']() # Call the switch action for the current mode
time.sleep(0.2) # Short delay after switch press
# Handle mode change button
if not modeChangeButton.value: # Button is pressed
print("Mode Change Button Pressed") # Debug statement
mode += 1
if mode > 5:
mode = 1
time.sleep(0.2) # Short delay for button press
# Update display
splash = displayio.Group()
display.show(splash)
bg_sprite = displayio.TileGrid(color_bitmap, pixel_shader=color_palette, x=0, y=0)
splash.append(bg_sprite)
text = mode_names[mode]
center_x = (128 - len(text) * 6) // 2
text_area = label.Label(terminalio.FONT, text=text, color=0xFFFF00, x=center_x, y=28)
splash.append(text_area)
time.sleep(0.01) # Small delay to reduce CPU usage
Prototype Code Explanation
Importing Libraries
import board
import busio
import displayio
import terminalio
from adafruit_display_text import label
import adafruit_displayio_ssd1306
import usb_hid
import digitalio
import time
from adafruit_hid.keycode import Keycode
from adafruit_hid.keyboard import Keyboard
from adafruit_hid.keyboard_layout_us import KeyboardLayoutUS
from adafruit_hid.consumer_control import ConsumerControl
from adafruit_hid.consumer_control_code import ConsumerControlCode
board
: Provides access to the pins on the microcontroller.busio
: Used for I2C and SPI communication.displayio
: A library for managing displays in CircuitPython.terminalio
: Provides a basic terminal font for text display.adafruit_display_text.label
: Used to create text labels on the display.adafruit_displayio_ssd1306
: A specific driver for the SSD1306 OLED display.usb_hid
: Allows the microcontroller to act as a USB Human Interface Device (HID), enabling keyboard and consumer control functionality.digitalio
: Used to control digital input and output pins.time
: Provides time-related functions.adafruit_hid.keycode
: Contains key codes for keyboard actions.adafruit_hid.keyboard
: Allows the microcontroller to send keyboard inputs.adafruit_hid.keyboard_layout_us
: Provides US keyboard layout support.adafruit_hid.consumer_control
: Allows control of media functions like volume and play/pause.
Setting Up Consumer Control and Keyboard
cc = ConsumerControl(usb_hid.devices)
keyboard = Keyboard(usb_hid.devices)
write_text = KeyboardLayoutUS(keyboard)
ConsumerControl
: This creates an instance that allows you to send media control commands (like volume control).Keyboard
: This instance allows the microcontroller to send keyboard inputs.KeyboardLayoutUS
: This object helps in sending text in the US keyboard layout.
OLED Display Setup
displayio.release_displays()
sda, scl = board.GP16, board.GP17
i2c = busio.I2C(scl, sda)
display_bus = displayio.I2CDisplay(i2c, device_address=0x3C)
display = adafruit_displayio_ssd1306.SSD1306(display_bus, width=128, height=64)
- `displayio.release_displays()’ : Releases any previously used display resources.
I2C Configuration
sda
andscl
: These variables define the I2C data and clock pins on the microcontroller.busio.I2C(scl, sda)
: Initializes the I2C bus with the specified pins.I2CDisplay
: Creates an I2C display object for the SSD1306 OLED display.SSD1306(...)
: Initializes the display with a width of 128 pixels and a height of 64 pixels.
Creating the Display Context
splash = displayio.Group()
display.show(splash)
color_bitmap = displayio.Bitmap(128, 64, 1)
color_palette = displayio.Palette(1)
bg_sprite = displayio.TileGrid(color_bitmap, pixel_shader=color_palette, x=0, y=0)
splash.append(bg_sprite)
displayio.Group()
: Creates a new display group that can contain multiple display elements.display.show(splash)
: Shows the created group on the display.
Background Setup:
Bitmap
: Creates a bitmap with the specified width, height, and color depth (1 bit here).Palette
: Defines a color palette for the bitmap.TileGrid
: A grid that displays the bitmap on the screen. This is used to add the background.
Drawing a Label on the Display
text = "NerdCave!"
text_area = label.Label(terminalio.FONT, text=text, color=0xFFFF00, x=35, y=28)
splash.append(text_area)
Label Creation:
label.Label(...)
: Creates a text label with specified font, text content, color (yellow), and position (x=35, y=28).splash.append(text_area)
: Adds the text label to the display group.
Button Setup
buttons = [board.GP0, board.GP1, board.GP2, board.GP3, board.GP4, board.GP5, board.GP6, board.GP7]
key = [digitalio.DigitalInOut(pin_name) for pin_name in buttons]
for button in key:
button.direction = digitalio.Direction.INPUT
button.pull = digitalio.Pull.UP # Use pull-up resistors
Button Definition
: A list of GPIO pins is created for the buttons.DigitalInOut
: Each button pin is set up as a digital input.Pull-up Resistors
: The buttons are configured to use internal pull-up resistors, which means they will read HIGH when not pressed and LOW when pressed.
Mode Change Button
modeChangeButton = digitalio.DigitalInOut(board.GP8)
modeChangeButton.direction = digitalio.Direction.INPUT
modeChangeButton.pull = digitalio.Pull.UP
- Similar to the button setup, this sets up a dedicated button for changing modes.
Rotary Encoder Setup
CLK_PIN = board.GP20
DT_PIN = board.GP19
SW_PIN = board.GP18
clk = digitalio.DigitalInOut(CLK_PIN)
clk.direction = digitalio.Direction.INPUT
clk.pull = digitalio.Pull.UP # Pull-up resistor
dt = digitalio.DigitalInOut(DT_PIN)
dt.direction = digitalio.Direction.INPUT
dt.pull = digitalio.Pull.UP # Pull-up resistor
sw = digitalio.DigitalInOut(SW_PIN)
sw.direction = digitalio.Direction.INPUT
sw.pull = digitalio.Pull.UP # Pull-up resistor
Rotary Encoder Pins
: The CLK (clock), DT (data), and switch pins are defined and set up similarly to the buttons.- This allows the microcontroller to detect both the rotation and the pressing of the rotary encoder switch.
Rotary Encoder Variables
previous_clk_value = clk.value
counter = 0
last_rotary_time = 0 # Variable to track the last time the rotary encoder was turned
previous_clk_value
: Stores the last state of the CLK pin to detect changes.counter
: A variable to keep track of how many steps the rotary encoder has moved.last_rotary_time
: Records the last time the rotary encoder was turned to manage timing.
Mode Names
mode_names = {1: 'Blender', 2: 'Windows', 3: 'Premier Pro', 4: "After Effects", 5: "Fusion360"}
mode = 1
print(mode_names[mode])
mode_names
: A dictionary mapping mode numbers to their respective application names.mode
: Initializes the current mode to 1 (Blender).
Define Macros for Different Modes
macros = {
1: [
lambda: cc.send(ConsumerControlCode.VOLUME_DECREMENT), # Button 1
lambda: cc.send(ConsumerControlCode.PLAY_PAUSE), # Button 2
lambda: (keyboard.send(Keycode.GUI), write_text.write('chrome\n')), # Button 3
lambda: cc.send(ConsumerControlCode.VOLUME_INCREMENT), # Button 4
lambda: keyboard.send(Keycode.N) # Button 5
],
# Other modes...
}
Macros
: Each mode has a list of lambda functions that define actions for button presses. When a button is pressed, the corresponding function is executed.
Define Rotary Encoder Actions for Different Modes
rotary_actions = {
1: {
'clockwise': lambda: cc.send(ConsumerControlCode.VOLUME_DECREMENT),
'counterclockwise': lambda: cc.send(ConsumerControlCode.VOLUME_INCREMENT),
'switch': lambda: cc.send(ConsumerControlCode.VOLUME_INCREMENT) # Example action for mode 1
},
# Other modes...
}
Rotary Actions
: Similar to macros, each mode has actions defined for clockwise and counterclockwise rotations, as well as for the switch press.
Function to Update the Macro Label on the OLED Screen
def update_macro_label(macro_name):
macro_label = label.Label(terminalio.FONT, text=macro_name, color=0xFFFF00, x=0, y=55)
splash.append(macro_label)
display.refresh()
time.sleep(3)
splash.remove(macro_label)
display.refresh()
Update Function
: This function creates a label with the name of the current macro, displays it for 3 seconds, and then removes it from the screen.
Main Loop
while True:
# Handle button presses
for i, button in enumerate(key):
if not button.value: # Button is pressed
print(f"Button {i + 1} pressed!") # Debug statement
if i < len(macros[mode]):
macros[mode][i]() # Call the corresponding macro
time.sleep(0.2) # Short delay after button press
Button Handling
: The main loop continuously checks for button presses. When a button is pressed, it executes the corresponding macro for the current mode.
Handle Rotary Encoder
current_clk_value = clk.value
current_time = time.monotonic() # Get the current time
if current_clk_value != previous_clk_value and (current_time - last_rotary_time) > 0.2:
if current_clk_value == 0: # Clockwise turn
if dt.value == 0: # If DT is low, it's a clockwise turn
counter += 1
if mode in rotary_actions and 'clockwise' in rotary_actions[mode]:
rotary_actions[mode]['clockwise']() # Call the clockwise action for the current mode
print(f"Counter: {counter} (Clockwise)")
else: # If DT is high, it's a counterclockwise turn
counter -= 1
if mode in rotary_actions and 'counterclockwise' in rotary_actions[mode]:
rotary_actions[mode]['counterclockwise']() # Call the counterclockwise action for the current mode
print(f"Counter: {counter} (Counterclockwise)")
previous_clk_value = current_clk_value
last_rotary_time = current_time
Rotary Encoder Handling
: The current state of the rotary encoder is checked. If it has been turned, the code determines the direction of the turn and executes the corresponding action based on the current mode.
Handle Rotary Encoder Switch
if not sw.value: # Switch is pressed
print("Rotary Button pressed")
if mode in rotary_actions and 'switch' in rotary_actions[mode]:
rotary_actions[mode]['switch']() # Call the switch action for the current mode
time.sleep(0.2) # Short delay after switch press
Switch Handling
: If the rotary encoder switch is pressed, the corresponding action for the current mode is executed.
Handle Mode Change Button
if not modeChangeButton.value: # Button is pressed
print("Mode Change Button Pressed") # Debug statement
mode += 1
if mode > 5:
mode = 1
time.sleep(0.2) # Short delay for button press
splash = displayio.Group()
display.show(splash)
bg_sprite = displayio.TileGrid(color_bitmap, pixel_shader=color_palette, x=0, y=0)
splash.append(bg_sprite)
text = mode_names[mode]
center_x = (128 - len(text) * 6) // 2
text_area = label.Label(terminalio.FONT, text=text, color=0xFFFF00, x=center_x, y=28)
splash.append(text_area)
Mode Change Logic
: If the mode change button is pressed, the mode is incremented, and if it exceeds the defined modes, it wraps around to the first mode. The display is updated to reflect the new mode.
Final Sleep
time.sleep(0.01) # Small delay to reduce CPU usage
Delay
: A small delay is added to reduce CPU usage and prevent the loop from running too quickly.