Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
57fba54
Snapshot of current refactoring stage
deckerego Feb 18, 2026
0fc51b2
Baby steps into building the test harnass
deckerego Feb 21, 2026
5fd2226
Working unit tests
deckerego Feb 21, 2026
a65ac0a
Crappy test for keys
deckerego Feb 23, 2026
ec06897
Good enough on the keys testing
deckerego Feb 24, 2026
1d57769
Refactor so there are fewer files to copy over
deckerego Feb 24, 2026
dbe99f8
Label is now a key property
deckerego Feb 24, 2026
985c030
More refactoring around commands
deckerego Feb 24, 2026
fd4cd0a
Linter fixes
deckerego Feb 24, 2026
0d97d9d
Updated GitHub actions
deckerego Feb 24, 2026
3bc6b2b
Refactored Screen/Display
deckerego Feb 25, 2026
fd5f24e
Finished unit tests for screen
deckerego Feb 26, 2026
fa96de9
Checkpoint commit for slots/signals refactoring
deckerego Feb 27, 2026
4bec071
Fixing up unit tests after checkpoint
deckerego Feb 27, 2026
0e1f082
New screen listener implementation
deckerego Feb 27, 2026
1a7ebcc
LED listener implementation
deckerego Feb 27, 2026
0ea9a83
Added Sleep support
deckerego Feb 27, 2026
5c4fd9c
Added HID support
deckerego Feb 27, 2026
4f123e7
Initial HID tests
deckerego Feb 27, 2026
f815c54
Fixed up HID tests
deckerego Feb 27, 2026
fc4aab2
Unit test coverage for inputs
deckerego Feb 27, 2026
413301b
Simplify listener registration
deckerego Feb 27, 2026
a55d025
Working version
deckerego Feb 27, 2026
26cd198
Don't highlight out of bounds
deckerego Feb 28, 2026
d7e7e1b
Working page swapping, sleeping
deckerego Feb 28, 2026
e4236ad
Fixed macro changing issue
deckerego Feb 28, 2026
f8f2f02
Fixed issue with sleeping by button press
deckerego Feb 28, 2026
754511c
Tests for not repeatedly calling sleep
deckerego Feb 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/pullrequest-unittest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
name: Automated Testing

on:
pull_request:
types: [ opened, synchronize, reopened, ready_for_review ]
branches: [ main ]

jobs:
unittest:
if: '! github.event.pull_request.draft'
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v4
- run: python3 -m unittest discover
7 changes: 6 additions & 1 deletion .github/workflows/release-package.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ jobs:
package:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/checkout@v4
- name: Adafruit Library Cache
id: cache-mpy
uses: actions/cache@v4
Expand All @@ -29,6 +29,11 @@ jobs:
rmdir adafruit-circuitpython-bundle-10.x-mpy-${{ env.adafruit-bundle }}
- name: Copy Adafruit Libraries
run: |
rm -r -f \
./lib/adafruit_display_shapes \
./lib/adafruit_display_text \
./lib/adafruit_hid \
./lib/*.py
cp -a \
~/.adafruit-circuitpython-bundle/lib/adafruit_debouncer.mpy \
~/.adafruit-circuitpython-bundle/lib/adafruit_display_shapes \
Expand Down
1 change: 0 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ downloads/
eggs/
.eggs/
lib/**/*.mpy
lib/**/*.py
lib64/
parts/
sdist/
Expand Down
5 changes: 5 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
{
"python.analysis.extraPaths": [
"./lib"
]
}
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -106,3 +106,15 @@ Macropad, and then after releasing the button immediately hold down the
blinking top-left key on the keypad (KEY1). You should see the text
"Mounting Read/Write" quickly appear on the screen, and then the CIRCUITPY
drive will mount in read/write mode.


## Building

I've attempted to build mocks of all necessary libraries so things can be tested locally.

### Running tests

Tests can be run with the native Python unittest module, as in:
```sh
python3 -m unittest discover
```
10 changes: 10 additions & 0 deletions __init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import sys
import commands

# backwards compatibility for the 2.x series:
sys.modules['keyboard'] = commands
sys.modules['mouse'] = commands
sys.modules['pause'] = commands
sys.modules['sleep'] = commands
sys.modules['midi'] = commands
sys.modules['consumer'] = commands
190 changes: 74 additions & 116 deletions code.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,39 @@
import time
import keyfactory
from adafruit_macropad import MacroPad
from app import App
from display import Display
from pixels import Pixels
from sleep import Sleep
from keys import Keys
from screen import ScreenListener
from pixels import PixelListener
from hid import InputDeviceListener
from commands import Sleep

## DEPRECATED
# Ensure backwards compatibility for the 2.x series
# So we don't have to change import statements in old macros
import sys
import commands
sys.modules['keyboard'] = commands
sys.modules['mouse'] = commands
sys.modules['pause'] = commands
sys.modules['sleep'] = commands
sys.modules['midi'] = commands
sys.modules['consumer'] = commands

KEY_LAUNCH = -1
KEY_ENC_BUTTON = 12
KEY_ENC_LEFT = 13
KEY_ENC_RIGHT = 14
MAX_KEYS = 14
MAX_LEDS = 12
MACRO_FOLDER = '/macros'

# Core objects
macropad = MacroPad()
last_position = macropad.encoder
screen = ScreenListener(macropad)
pixels = PixelListener(macropad)
hid = InputDeviceListener(macropad)

# State variables
last_time_seconds = time.monotonic()
last_position = macropad.encoder
sleep_remaining = None
keys = None
macro_changed = False
app_index = 0
app_current = None

# The application state that is shared with the key events
state = {
"macropad": macropad,
"screen": Display(macropad),
"pixels": Pixels(macropad),
"sleeping": False,
}

# Fractions of a second that have elapsed since this method's last run
def elapsed_seconds():
Expand All @@ -40,116 +45,69 @@ def elapsed_seconds():

# Set the macro page (app) at the given index
def set_app(index):
global app_current, app_index, sleep_remaining
global app_index, keys, screen, sleep_remaining
app_index = index
app_current = apps[app_index]
sleep_remaining = app_current.timeout
state["macropad"].keyboard.release_all()
state["screen"].setApp(app_current)
state["pixels"].setApp(app_current)

# Get the macro sequence to execute for a given key
def get_sequence(key):
global app_current
if key == KEY_LAUNCH:
return app_current.launch[2] if app_current.launch else None
try: # No such sequence for this key
return app_current.macros[key][2] if key <= MAX_KEYS else []
except (IndexError) as err:
print("Couldn't find sequence for key number ", key)
return None
sleep_remaining = apps[app_index].timeout
screen.setTitle(apps[app_index].name)
macropad.keyboard.release_all()

keys = Keys(apps[app_index])
keys.addListener(hid)
keys.addListener(screen)
keys.addListener(pixels)

# Load available macros
state["screen"].initialize()
screen.initialize()
apps = App.load_all(MACRO_FOLDER)
if not apps:
state["screen"].setTitle('NO MACRO FILES FOUND')
screen.setError('NO MACRO FILES FOUND')
while True:
pass
time.sleep(60.0)
set_app(app_index)

try: # Test the USB HID connection
state["macropad"].keyboard.release_all()
macropad.keyboard.release_all()
except OSError as err:
print(err)
state["screen"].setTitle('NO USB CONNECTION')
screen.setError('NO USB CONNECTION')
while True:
pass
time.sleep(60.0)

# Prep before the run loop
set_app(0)

while True: # Event loop
state["macropad"].encoder_switch_debounced.update()
position = state["macropad"].encoder
pressed = False
rotated = False

# Do we need to press the "sleep" button?
while True: # Input event loop
macropad.encoder_switch_debounced.update()
sleep_remaining -= elapsed_seconds()
if not state["sleeping"] and sleep_remaining <= 0:
Sleep().press(state)
continue

if position != last_position: # Did we rotate the encoder?
key_number = KEY_ENC_LEFT if position < last_position else KEY_ENC_RIGHT
last_position = position
rotated = True

# Do we need to change macro pages?
if rotated and state["macropad"].encoder_switch:
macro_changed = True
app_next = app_index - 1 if key_number is KEY_ENC_LEFT else app_index + 1
set_app(app_next % len(apps))
continue # Changing macros, not a keypress event
# We are now switching to a new macro page
elif macro_changed and state["macropad"].encoder_switch_debounced.released:
macro_changed = False
key_number = KEY_LAUNCH
rotated = True
# We only clicked the encoder button (not switching to a new page)
elif state["macropad"].encoder_switch_debounced.released:
key_number = KEY_ENC_BUTTON
pressed = state["macropad"].encoder_switch_debounced.released
else: # Was there a keypress event on the keypad?
event = state["macropad"].keys.events.get()
if not event or event.key_number >= len(app_current.macros):
if state["sleeping"]: time.sleep(1.0) # Low power mode
continue # No key events, or no corresponding macro, resume loop
key_number = event.key_number
pressed = event.pressed

# Wake up if there is a key event while sleeping
sleep_remaining = app_current.timeout
if state["sleeping"] and (pressed or rotated):
Sleep().press(state)
continue

sequence = get_sequence(key_number)
if sequence and (rotated or pressed): # Key Down Event
if not state["sleeping"] and (0 <= key_number < MAX_LEDS):
state["pixels"].highlight(key_number, 0xFFFFFF)
state["screen"].highlight(key_number)

if type(sequence) is list:
for item in sequence:
if type(item) is list: # We have a macro to execute
for subitem in item: # Press the key combination
keyfactory.get(subitem).press(state)
for subitem in item: # Immediately release the key combo
keyfactory.get(subitem).release(state)
else: # We have a key combination to press
keyfactory.get(item).press(state)
else: # We just have a single command to execute
keyfactory.get(sequence).press(state)

if sequence and (rotated or not pressed): # Key Up Event
if type(sequence) is list:
for item in sequence:
if type(item) is not list: # Release any still-pressed key combinations
keyfactory.get(item).release(state)
# Macro key cobinations should already have been released
else: # Release any still-pressed single commands
keyfactory.get(sequence).release(state)
if not state["sleeping"] and (0 <= key_number < MAX_LEDS): # No pixel for encoder button
state["pixels"].reset(key_number)
state["screen"].reset(key_number)
event = macropad.keys.events.get()

if event or last_position != macropad.encoder or macropad.encoder_switch_debounced.released:
keys.release(Keys.KEY_SLEEP) # Don't go to sleep!
sleep_remaining = apps[app_index].timeout
if sleep_remaining <= 0: # Go to sleep and slow down
keys.press(Keys.KEY_SLEEP)
time.sleep(1.0)
elif event and event.pressed: # Key was pressed
keys.press(event.key_number)
elif event and event.released: # Key was released
keys.release(event.key_number)
elif macropad.encoder_switch and macropad.encoder < last_position:
last_position = macropad.encoder # Push down and turn (left)
set_app((app_index - 1) % len(apps))
macro_changed = True
elif macropad.encoder_switch and macropad.encoder > last_position:
last_position = macropad.encoder # Push down and turn (right)
set_app((app_index + 1) % len(apps))
macro_changed = True
elif macropad.encoder < last_position: # Rotary counter-clockwise
last_position = macropad.encoder
keys.press(Keys.KEY_ENC_LEFT)
keys.release(Keys.KEY_ENC_LEFT)
elif macropad.encoder > last_position: # Rotary clockwise
last_position = macropad.encoder
keys.press(Keys.KEY_ENC_RIGHT)
keys.release(Keys.KEY_ENC_RIGHT)
elif macropad.encoder_switch_debounced.released:
if macro_changed: macro_changed = False # Land on the selected macro page
else: keys.press(Keys.KEY_ENC_BUTTON) # Encoder button "pressed"
91 changes: 91 additions & 0 deletions commands.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import time

class Commands:
def __init__(self, macro):
self.commands = []
if isinstance(macro, list):
for item in macro:
self.commands += [Commands.build(item)]
else:
self.commands += [Commands.build(macro)]

def __del__(self):
self.commands.clear()

def __bool__(self):
return len(self.commands) > 0

def __iter__(self):
return iter(self.commands)

def __getitem__(self, index):
return self.commands[index]

def __len__(self):
return len(self.commands)

@staticmethod
def build(item):
if isinstance(item, Toolbar):
return item
elif isinstance(item, Mouse):
return item
elif isinstance(item, Midi):
return item
elif isinstance(item, Sleep):
return item
elif isinstance(item, float):
return Pause(item)
elif isinstance(item, list):
return Sequence(item)
else:
return Keyboard(item)

class Command:
def __init__(self, keycode):
self.keycode = keycode

class Sequence(Command):
def __init__(self, keycodes):
self.commands = []
for keycode in keycodes:
self.commands += [Commands.build(keycode)]

def __del__(self):
self.commands.clear()

def __bool__(self):
return len(self.commands) > 0

def __iter__(self):
return iter(self.commands)

def __getitem__(self, index):
return self.commands[index]

def __len__(self):
return len(self.commands)

class Keyboard(Command):
def __init__(self, key):
self.keycode = key

class Toolbar(Command):
def __init__(self, keycode):
self.keycode = keycode

class Mouse(Command):
def __init__(self, keycode):
self.keycode = keycode

class Midi(Command):
def __init__(self, note):
self.keycode = note

class Pause(Command):
def __init__(self, seconds):
self.keycode = seconds

class Sleep(Command):
def __init__(self):
pass
Loading