Skip to content

Commit 9aa4001

Browse files
authored
Refactor the codebase to fit a signals/slots pattern (#35)
1 parent ec7decf commit 9aa4001

36 files changed

+1200
-276
lines changed
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
name: Automated Testing
2+
3+
on:
4+
pull_request:
5+
types: [ opened, synchronize, reopened, ready_for_review ]
6+
branches: [ main ]
7+
8+
jobs:
9+
unittest:
10+
if: '! github.event.pull_request.draft'
11+
runs-on: ubuntu-latest
12+
permissions:
13+
id-token: write
14+
contents: read
15+
steps:
16+
- uses: actions/checkout@v4
17+
- run: python3 -m unittest discover

.github/workflows/release-package.yml

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
package:
1212
runs-on: ubuntu-latest
1313
steps:
14-
- uses: actions/checkout@v2
14+
- uses: actions/checkout@v4
1515
- name: Adafruit Library Cache
1616
id: cache-mpy
1717
uses: actions/cache@v4
@@ -29,6 +29,11 @@ jobs:
2929
rmdir adafruit-circuitpython-bundle-10.x-mpy-${{ env.adafruit-bundle }}
3030
- name: Copy Adafruit Libraries
3131
run: |
32+
rm -r -f \
33+
./lib/adafruit_display_shapes \
34+
./lib/adafruit_display_text \
35+
./lib/adafruit_hid \
36+
./lib/*.py
3237
cp -a \
3338
~/.adafruit-circuitpython-bundle/lib/adafruit_debouncer.mpy \
3439
~/.adafruit-circuitpython-bundle/lib/adafruit_display_shapes \

.gitignore

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ downloads/
1515
eggs/
1616
.eggs/
1717
lib/**/*.mpy
18-
lib/**/*.py
1918
lib64/
2019
parts/
2120
sdist/

.vscode/settings.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
{
2+
"python.analysis.extraPaths": [
3+
"./lib"
4+
]
5+
}

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,3 +106,15 @@ Macropad, and then after releasing the button immediately hold down the
106106
blinking top-left key on the keypad (KEY1). You should see the text
107107
"Mounting Read/Write" quickly appear on the screen, and then the CIRCUITPY
108108
drive will mount in read/write mode.
109+
110+
111+
## Building
112+
113+
I've attempted to build mocks of all necessary libraries so things can be tested locally.
114+
115+
### Running tests
116+
117+
Tests can be run with the native Python unittest module, as in:
118+
```sh
119+
python3 -m unittest discover
120+
```

__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import sys
2+
import commands
3+
4+
# backwards compatibility for the 2.x series:
5+
sys.modules['keyboard'] = commands
6+
sys.modules['mouse'] = commands
7+
sys.modules['pause'] = commands
8+
sys.modules['sleep'] = commands
9+
sys.modules['midi'] = commands
10+
sys.modules['consumer'] = commands

code.py

Lines changed: 74 additions & 116 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,39 @@
11
import time
2-
import keyfactory
32
from adafruit_macropad import MacroPad
43
from app import App
5-
from display import Display
6-
from pixels import Pixels
7-
from sleep import Sleep
4+
from keys import Keys
5+
from screen import ScreenListener
6+
from pixels import PixelListener
7+
from hid import InputDeviceListener
8+
from commands import Sleep
9+
10+
## DEPRECATED
11+
# Ensure backwards compatibility for the 2.x series
12+
# So we don't have to change import statements in old macros
13+
import sys
14+
import commands
15+
sys.modules['keyboard'] = commands
16+
sys.modules['mouse'] = commands
17+
sys.modules['pause'] = commands
18+
sys.modules['sleep'] = commands
19+
sys.modules['midi'] = commands
20+
sys.modules['consumer'] = commands
821

9-
KEY_LAUNCH = -1
10-
KEY_ENC_BUTTON = 12
11-
KEY_ENC_LEFT = 13
12-
KEY_ENC_RIGHT = 14
13-
MAX_KEYS = 14
14-
MAX_LEDS = 12
1522
MACRO_FOLDER = '/macros'
1623

24+
# Core objects
1725
macropad = MacroPad()
18-
last_position = macropad.encoder
26+
screen = ScreenListener(macropad)
27+
pixels = PixelListener(macropad)
28+
hid = InputDeviceListener(macropad)
29+
30+
# State variables
1931
last_time_seconds = time.monotonic()
32+
last_position = macropad.encoder
2033
sleep_remaining = None
34+
keys = None
2135
macro_changed = False
2236
app_index = 0
23-
app_current = None
24-
25-
# The application state that is shared with the key events
26-
state = {
27-
"macropad": macropad,
28-
"screen": Display(macropad),
29-
"pixels": Pixels(macropad),
30-
"sleeping": False,
31-
}
3237

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

4146
# Set the macro page (app) at the given index
4247
def set_app(index):
43-
global app_current, app_index, sleep_remaining
48+
global app_index, keys, screen, sleep_remaining
4449
app_index = index
45-
app_current = apps[app_index]
46-
sleep_remaining = app_current.timeout
47-
state["macropad"].keyboard.release_all()
48-
state["screen"].setApp(app_current)
49-
state["pixels"].setApp(app_current)
5050

51-
# Get the macro sequence to execute for a given key
52-
def get_sequence(key):
53-
global app_current
54-
if key == KEY_LAUNCH:
55-
return app_current.launch[2] if app_current.launch else None
56-
try: # No such sequence for this key
57-
return app_current.macros[key][2] if key <= MAX_KEYS else []
58-
except (IndexError) as err:
59-
print("Couldn't find sequence for key number ", key)
60-
return None
51+
sleep_remaining = apps[app_index].timeout
52+
screen.setTitle(apps[app_index].name)
53+
macropad.keyboard.release_all()
54+
55+
keys = Keys(apps[app_index])
56+
keys.addListener(hid)
57+
keys.addListener(screen)
58+
keys.addListener(pixels)
6159

6260
# Load available macros
63-
state["screen"].initialize()
61+
screen.initialize()
6462
apps = App.load_all(MACRO_FOLDER)
6563
if not apps:
66-
state["screen"].setTitle('NO MACRO FILES FOUND')
64+
screen.setError('NO MACRO FILES FOUND')
6765
while True:
68-
pass
66+
time.sleep(60.0)
67+
set_app(app_index)
6968

7069
try: # Test the USB HID connection
71-
state["macropad"].keyboard.release_all()
70+
macropad.keyboard.release_all()
7271
except OSError as err:
7372
print(err)
74-
state["screen"].setTitle('NO USB CONNECTION')
73+
screen.setError('NO USB CONNECTION')
7574
while True:
76-
pass
75+
time.sleep(60.0)
7776

7877
# Prep before the run loop
79-
set_app(0)
8078

81-
while True: # Event loop
82-
state["macropad"].encoder_switch_debounced.update()
83-
position = state["macropad"].encoder
84-
pressed = False
85-
rotated = False
8679

87-
# Do we need to press the "sleep" button?
80+
while True: # Input event loop
81+
macropad.encoder_switch_debounced.update()
8882
sleep_remaining -= elapsed_seconds()
89-
if not state["sleeping"] and sleep_remaining <= 0:
90-
Sleep().press(state)
91-
continue
92-
93-
if position != last_position: # Did we rotate the encoder?
94-
key_number = KEY_ENC_LEFT if position < last_position else KEY_ENC_RIGHT
95-
last_position = position
96-
rotated = True
97-
98-
# Do we need to change macro pages?
99-
if rotated and state["macropad"].encoder_switch:
100-
macro_changed = True
101-
app_next = app_index - 1 if key_number is KEY_ENC_LEFT else app_index + 1
102-
set_app(app_next % len(apps))
103-
continue # Changing macros, not a keypress event
104-
# We are now switching to a new macro page
105-
elif macro_changed and state["macropad"].encoder_switch_debounced.released:
106-
macro_changed = False
107-
key_number = KEY_LAUNCH
108-
rotated = True
109-
# We only clicked the encoder button (not switching to a new page)
110-
elif state["macropad"].encoder_switch_debounced.released:
111-
key_number = KEY_ENC_BUTTON
112-
pressed = state["macropad"].encoder_switch_debounced.released
113-
else: # Was there a keypress event on the keypad?
114-
event = state["macropad"].keys.events.get()
115-
if not event or event.key_number >= len(app_current.macros):
116-
if state["sleeping"]: time.sleep(1.0) # Low power mode
117-
continue # No key events, or no corresponding macro, resume loop
118-
key_number = event.key_number
119-
pressed = event.pressed
120-
121-
# Wake up if there is a key event while sleeping
122-
sleep_remaining = app_current.timeout
123-
if state["sleeping"] and (pressed or rotated):
124-
Sleep().press(state)
125-
continue
126-
127-
sequence = get_sequence(key_number)
128-
if sequence and (rotated or pressed): # Key Down Event
129-
if not state["sleeping"] and (0 <= key_number < MAX_LEDS):
130-
state["pixels"].highlight(key_number, 0xFFFFFF)
131-
state["screen"].highlight(key_number)
132-
133-
if type(sequence) is list:
134-
for item in sequence:
135-
if type(item) is list: # We have a macro to execute
136-
for subitem in item: # Press the key combination
137-
keyfactory.get(subitem).press(state)
138-
for subitem in item: # Immediately release the key combo
139-
keyfactory.get(subitem).release(state)
140-
else: # We have a key combination to press
141-
keyfactory.get(item).press(state)
142-
else: # We just have a single command to execute
143-
keyfactory.get(sequence).press(state)
144-
145-
if sequence and (rotated or not pressed): # Key Up Event
146-
if type(sequence) is list:
147-
for item in sequence:
148-
if type(item) is not list: # Release any still-pressed key combinations
149-
keyfactory.get(item).release(state)
150-
# Macro key cobinations should already have been released
151-
else: # Release any still-pressed single commands
152-
keyfactory.get(sequence).release(state)
153-
if not state["sleeping"] and (0 <= key_number < MAX_LEDS): # No pixel for encoder button
154-
state["pixels"].reset(key_number)
155-
state["screen"].reset(key_number)
83+
event = macropad.keys.events.get()
84+
85+
if event or last_position != macropad.encoder or macropad.encoder_switch_debounced.released:
86+
keys.release(Keys.KEY_SLEEP) # Don't go to sleep!
87+
sleep_remaining = apps[app_index].timeout
88+
if sleep_remaining <= 0: # Go to sleep and slow down
89+
keys.press(Keys.KEY_SLEEP)
90+
time.sleep(1.0)
91+
elif event and event.pressed: # Key was pressed
92+
keys.press(event.key_number)
93+
elif event and event.released: # Key was released
94+
keys.release(event.key_number)
95+
elif macropad.encoder_switch and macropad.encoder < last_position:
96+
last_position = macropad.encoder # Push down and turn (left)
97+
set_app((app_index - 1) % len(apps))
98+
macro_changed = True
99+
elif macropad.encoder_switch and macropad.encoder > last_position:
100+
last_position = macropad.encoder # Push down and turn (right)
101+
set_app((app_index + 1) % len(apps))
102+
macro_changed = True
103+
elif macropad.encoder < last_position: # Rotary counter-clockwise
104+
last_position = macropad.encoder
105+
keys.press(Keys.KEY_ENC_LEFT)
106+
keys.release(Keys.KEY_ENC_LEFT)
107+
elif macropad.encoder > last_position: # Rotary clockwise
108+
last_position = macropad.encoder
109+
keys.press(Keys.KEY_ENC_RIGHT)
110+
keys.release(Keys.KEY_ENC_RIGHT)
111+
elif macropad.encoder_switch_debounced.released:
112+
if macro_changed: macro_changed = False # Land on the selected macro page
113+
else: keys.press(Keys.KEY_ENC_BUTTON) # Encoder button "pressed"

commands.py

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
import time
2+
3+
class Commands:
4+
def __init__(self, macro):
5+
self.commands = []
6+
if isinstance(macro, list):
7+
for item in macro:
8+
self.commands += [Commands.build(item)]
9+
else:
10+
self.commands += [Commands.build(macro)]
11+
12+
def __del__(self):
13+
self.commands.clear()
14+
15+
def __bool__(self):
16+
return len(self.commands) > 0
17+
18+
def __iter__(self):
19+
return iter(self.commands)
20+
21+
def __getitem__(self, index):
22+
return self.commands[index]
23+
24+
def __len__(self):
25+
return len(self.commands)
26+
27+
@staticmethod
28+
def build(item):
29+
if isinstance(item, Toolbar):
30+
return item
31+
elif isinstance(item, Mouse):
32+
return item
33+
elif isinstance(item, Midi):
34+
return item
35+
elif isinstance(item, Sleep):
36+
return item
37+
elif isinstance(item, float):
38+
return Pause(item)
39+
elif isinstance(item, list):
40+
return Sequence(item)
41+
else:
42+
return Keyboard(item)
43+
44+
class Command:
45+
def __init__(self, keycode):
46+
self.keycode = keycode
47+
48+
class Sequence(Command):
49+
def __init__(self, keycodes):
50+
self.commands = []
51+
for keycode in keycodes:
52+
self.commands += [Commands.build(keycode)]
53+
54+
def __del__(self):
55+
self.commands.clear()
56+
57+
def __bool__(self):
58+
return len(self.commands) > 0
59+
60+
def __iter__(self):
61+
return iter(self.commands)
62+
63+
def __getitem__(self, index):
64+
return self.commands[index]
65+
66+
def __len__(self):
67+
return len(self.commands)
68+
69+
class Keyboard(Command):
70+
def __init__(self, key):
71+
self.keycode = key
72+
73+
class Toolbar(Command):
74+
def __init__(self, keycode):
75+
self.keycode = keycode
76+
77+
class Mouse(Command):
78+
def __init__(self, keycode):
79+
self.keycode = keycode
80+
81+
class Midi(Command):
82+
def __init__(self, note):
83+
self.keycode = note
84+
85+
class Pause(Command):
86+
def __init__(self, seconds):
87+
self.keycode = seconds
88+
89+
class Sleep(Command):
90+
def __init__(self):
91+
pass

0 commit comments

Comments
 (0)