Merge pull request #12 from klardotsh/topic-hid

Implement a basic HID keyboard on a PyBoard!
This commit is contained in:
Josh Klar 2018-09-16 23:25:49 -07:00 committed by GitHub
commit 712b0e4888
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 456 additions and 15 deletions

View File

@ -50,14 +50,14 @@ vendor/circuitpython/ports/nrf/freeze/.kmk_frozen: upy-freeze.txt
vendor/micropython/ports/teensy/freeze/.kmk_frozen: upy-freeze.txt
@echo "===> Preparing vendored dependencies for bundling"
@mkdir vendor/micropython/ports/teensy/freeze/
@mkdir -p vendor/micropython/ports/teensy/freeze/
@rm -rf vendor/micropython/ports/teensy/freeze/*
@cat $< | xargs -I '{}' cp -a {} vendor/micropython/ports/teensy/freeze/
@touch $@
vendor/micropython/ports/stm32/freeze/.kmk_frozen: upy-freeze.txt
@echo "===> Preparing vendored dependencies for bundling"
@mkdir vendor/micropython/ports/stm32/freeze/
@mkdir -p vendor/micropython/ports/stm32/freeze/
@rm -rf vendor/micropython/ports/stm32/freeze/*
@cat $< | xargs -I '{}' cp -a {} vendor/micropython/ports/stm32/freeze/
@touch $@
@ -86,7 +86,6 @@ micropython-flash-teensy3.1:
@make -C vendor/micropython/ports/teensy/ BOARD=TEENSY_3.1 deploy
micropython-flash-pyboard:
@make -j4 -C vendor/micropython/ports/stm32/ BOARD=PYBV11 clean
@make -j4 -C vendor/micropython/ports/stm32/ BOARD=PYBV11 FROZEN_MPY_DIR=freeze deploy
micropython-flash-pyboard-entrypoint:
@ -96,7 +95,6 @@ micropython-flash-pyboard-entrypoint:
@-timeout -k 5s 10s pipenv run ampy -p ${AMPY_PORT} -d ${AMPY_DELAY} -b ${AMPY_BAUD} rm /flash/boot.py 2>/dev/null
@-timeout -k 5s 10s pipenv run ampy -p ${AMPY_PORT} -d ${AMPY_DELAY} -b ${AMPY_BAUD} put entrypoints/pyboard.py /flash/main.py
@-timeout -k 5s 10s pipenv run ampy -p ${AMPY_PORT} -d ${AMPY_DELAY} -b ${AMPY_BAUD} put entrypoints/pyboard_boot.py /flash/boot.py
@-timeout -k 5s 10s pipenv run ampy -p ${AMPY_PORT} -d ${AMPY_DELAY} -b ${AMPY_BAUD} reset
@echo "===> Flashed keyboard successfully!"
circuitpy-flash-nrf-entrypoint:

View File

@ -1,19 +1,25 @@
from logging import DEBUG
import machine
from kmk.common.consts import DiodeOrientation
from kmk.common.keycodes import KC
from kmk.firmware import Firmware
from kmk.micropython.pyb_hid import HIDHelper
def main():
cols = ('X10', 'X11', 'X12')
rows = ('X1', 'X2', 'X3')
p = machine.Pin.board
cols = (p.X10, p.X11, p.X12)
rows = (p.X1, p.X2, p.X3)
diode_orientation = DiodeOrientation.COLUMNS
keymap = [
['A', 'B', 'C'],
['D', 'E', 'F'],
['G', 'H', 'I'],
[KC.ESC, KC.H, KC.BACKSPACE],
[KC.TAB, KC.I, KC.ENTER],
[KC.CTRL, KC.SPACE, KC.SHIFT],
]
firmware = Firmware(
@ -21,6 +27,7 @@ def main():
row_pins=rows,
col_pins=cols,
diode_orientation=diode_orientation,
hid=HIDHelper,
log_level=DEBUG,
)

View File

@ -1,3 +1,3 @@
import pyb
pyb.usb_mode('VCP+HID') # act as a serial device and a mouse
pyb.usb_mode('VCP+HID', hid=pyb.hid_keyboard) # act as a serial device and a mouse

View File

@ -19,7 +19,7 @@ class ReduxStore:
self.state = self.reducer(self.state, action)
self.logger.debug('Dispatching complete: {}'.format(action))
self.logger.debug('Calling subscriptions')
self.logger.debug('New state: {}'.format(self.state))
for cb in self.callbacks:
if cb is not None:
@ -29,8 +29,6 @@ class ReduxStore:
self.logger.error('Callback failed, moving on')
print(sys.print_exception(e), file=sys.stderr)
self.logger.debug('Callbacks complete')
def get_state(self):
return self.state

234
kmk/common/keycodes.py Normal file
View File

@ -0,0 +1,234 @@
try:
from collections import namedtuple
except ImportError:
# This is handled by micropython-lib/collections, but on local runs of
# MicroPython, it doesn't exist
from ucollections import namedtuple
from kmk.common.types import AttrDict
from kmk.common.util import flatten_dict
Keycode = namedtuple('Keycode', ('code', 'is_modifier'))
class KeycodeCategory(type):
@classmethod
def to_dict(cls):
'''
MicroPython, for whatever reason (probably performance/memory) makes
__dict__ optional for ports. Unfortunately, at least the STM32
(Pyboard) port is one such port. This reimplements a subset of
__dict__, limited to just keys we're likely to care about (though this
could be opened up further later).
'''
hidden = ('to_dict', 'recursive_dict', 'contains')
return AttrDict({
key: getattr(cls, key)
for key in dir(cls)
if not key.startswith('_') and key not in hidden
})
@classmethod
def recursive_dict(cls):
'''
to_dict() executed recursively all the way down a tree
'''
ret = cls.to_dict()
for key, val in ret.items():
try:
nested_ret = val.recursive_dict()
except (AttributeError, NameError):
continue
ret[key] = nested_ret
return ret
@classmethod
def contains(cls, kc):
'''
Emulates the 'in' operator for keycode groupings, given MicroPython's
lack of support for metaclasses (meaning implementing 'in' for
uninstantiated classes, such as these, is largely not possible). Not
super useful in most cases, but does allow for sanity checks like
```python
assert Keycodes.Modifiers.contains(requested_key)
```
This is not bulletproof due to how HID codes are defined (there is
overlap). Keycodes.Common.KC_A, for example, is equal in value to
Keycodes.Modifiers.KC_LALT, but it can still prevent silly mistakes
like trying to use, say, Keycodes.Common.KC_Q as a modifier.
This is recursive across subgroups, enabling stuff like:
```python
assert Keycodes.contains(requested_key)
```
To ensure that a valid keycode has been requested to begin with. Again,
not bulletproof, but adds at least some cushion to stuff that would
otherwise cause AttributeErrors and crash the keyboard.
'''
subcategories = (
category for category in cls.to_dict().values()
# Disgusting, but since `cls.__bases__` isn't implemented in MicroPython,
# I resort to a less foolproof inheritance check that should still ignore
# strings and other stupid stuff (we don't want to iterate over __doc__,
# for example), but include nested classes.
#
# One huge lesson in this project is that uninstantiated classes are hard...
# and four times harder when the implementation of Python is half-baked.
if isinstance(category, type)
)
if any(
kc == _kc
for name, _kc in cls.to_dict().items()
if name.startswith('KC_')
):
return True
return any(sc.contains(kc) for sc in subcategories)
class Keycodes(KeycodeCategory):
'''
A massive grouping of keycodes
'''
class Modifiers(KeycodeCategory):
KC_CTRL = KC_LEFT_CTRL = Keycode(0x01, True)
KC_SHIFT = KC_LEFT_SHIFT = Keycode(0x02, True)
KC_ALT = KC_LALT = Keycode(0x04, True)
KC_GUI = KC_LGUI = Keycode(0x08, True)
KC_RCTRL = Keycode(0x10, True)
KC_RSHIFT = Keycode(0x20, True)
KC_RALT = Keycode(0x40, True)
KC_RGUI = Keycode(0x80, True)
class Common(KeycodeCategory):
KC_A = Keycode(4, False)
KC_B = Keycode(5, False)
KC_C = Keycode(6, False)
KC_D = Keycode(7, False)
KC_E = Keycode(8, False)
KC_F = Keycode(9, False)
KC_G = Keycode(10, False)
KC_H = Keycode(11, False)
KC_I = Keycode(12, False)
KC_J = Keycode(13, False)
KC_K = Keycode(14, False)
KC_L = Keycode(15, False)
KC_M = Keycode(16, False)
KC_N = Keycode(17, False)
KC_O = Keycode(18, False)
KC_P = Keycode(19, False)
KC_Q = Keycode(20, False)
KC_R = Keycode(21, False)
KC_S = Keycode(22, False)
KC_T = Keycode(23, False)
KC_U = Keycode(24, False)
KC_V = Keycode(25, False)
KC_W = Keycode(26, False)
KC_X = Keycode(27, False)
KC_Y = Keycode(28, False)
KC_Z = Keycode(29, False)
# Aliases to play nicely with AttrDict, since KC.1 isn't a valid
# attribute key in Python, but KC.N1 is
KC_1 = KC_N1 = Keycode(30, False)
KC_2 = KC_N2 = Keycode(31, False)
KC_3 = KC_N3 = Keycode(32, False)
KC_4 = KC_N4 = Keycode(33, False)
KC_5 = KC_N5 = Keycode(34, False)
KC_6 = KC_N6 = Keycode(35, False)
KC_7 = KC_N7 = Keycode(36, False)
KC_8 = KC_N8 = Keycode(37, False)
KC_9 = KC_N9 = Keycode(38, False)
KC_0 = KC_N0 = Keycode(39, False)
KC_ENTER = Keycode(40, False)
KC_ESC = Keycode(41, False)
KC_BACKSPACE = Keycode(42, False)
KC_TAB = Keycode(43, False)
KC_SPACE = Keycode(44, False)
KC_MINUS = Keycode(45, False)
KC_EQUAL = Keycode(46, False)
KC_LBRC = Keycode(47, False)
KC_RBRC = Keycode(48, False)
KC_BACKSLASH = Keycode(49, False)
KC_NUMBER = Keycode(50, False)
KC_SEMICOLON = Keycode(51, False)
KC_QUOTE = Keycode(52, False)
KC_TILDE = Keycode(53, False)
KC_COMMA = Keycode(54, False)
KC_PERIOD = Keycode(55, False)
KC_SLASH = Keycode(56, False)
KC_CAPS_LOCK = Keycode(57, False)
class FunctionKeys(KeycodeCategory):
KC_F1 = Keycode(58, False)
KC_F2 = Keycode(59, False)
KC_F3 = Keycode(60, False)
KC_F4 = Keycode(61, False)
KC_F5 = Keycode(62, False)
KC_F6 = Keycode(63, False)
KC_F7 = Keycode(64, False)
KC_F8 = Keycode(65, False)
KC_F9 = Keycode(66, False)
KC_F10 = Keycode(67, False)
KC_F11 = Keycode(68, False)
KC_F12 = Keycode(69, False)
class NavAndLocks(KeycodeCategory):
KC_PRINTSCREEN = Keycode(70, False)
KC_SCROLL_LOCK = Keycode(71, False)
KC_PAUSE = Keycode(72, False)
KC_INSERT = Keycode(73, False)
KC_HOME = Keycode(74, False)
KC_PGUP = Keycode(75, False)
KC_DELETE = Keycode(76, False)
KC_END = Keycode(77, False)
KC_PGDN = Keycode(78, False)
KC_RIGHT = Keycode(79, False)
KC_LEFT = Keycode(80, False)
KC_DOWN = Keycode(81, False)
KC_UP = Keycode(82, False)
class Numpad(KeycodeCategory):
KC_NUMLOCK = Keycode(83, False)
KC_KP_SLASH = Keycode(84, False)
KC_KP_ASTERIX = Keycode(85, False)
KC_KP_MINUS = Keycode(86, False)
KC_KP_PLUS = Keycode(87, False)
KC_KP_ENTER = Keycode(88, False)
KC_KP_1 = Keycode(89, False)
KC_KP_2 = Keycode(90, False)
KC_KP_3 = Keycode(91, False)
KC_KP_4 = Keycode(92, False)
KC_KP_5 = Keycode(93, False)
KC_KP_6 = Keycode(94, False)
KC_KP_7 = Keycode(95, False)
KC_KP_8 = Keycode(96, False)
KC_KP_9 = Keycode(97, False)
KC_KP_0 = Keycode(98, False)
KC_KP_PERIOD = Keycode(99, False)
ALL_KEYS = KC = AttrDict({
k.replace('KC_', ''): v
for k, v in flatten_dict(Keycodes.recursive_dict()).items()
})
char_lookup = {
"\n": (Keycodes.Common.KC_ENTER,),
"\t": (Keycodes.Common.KC_TAB,),
' ': (Keycodes.Common.KC_SPACE,),
'-': (Keycodes.Common.KC_MINUS,),
'=': (Keycodes.Common.KC_EQUAL,),
'+': (Keycodes.Common.KC_EQUAL, Keycodes.Modifiers.KC_SHIFT),
'~': (Keycodes.Common.KC_TILDE,),
}

10
kmk/common/types.py Normal file
View File

@ -0,0 +1,10 @@
class AttrDict(dict):
'''
Primitive support for accessing dictionary entries in dot notation.
Mostly for user-facing stuff (allows for `k.KC_ESC` rather than
`k['KC_ESC']`, which gets a bit obnoxious).
This is read-only on purpose.
'''
def __getattr__(self, key):
return self[key]

10
kmk/common/util.py Normal file
View File

@ -0,0 +1,10 @@
def flatten_dict(d):
items = {}
for k, v in d.items():
if isinstance(v, dict):
items.update(flatten_dict(v))
else:
items[k] = v
return items

View File

@ -13,13 +13,25 @@ except ImportError:
class Firmware:
def __init__(
self, keymap, row_pins, col_pins, diode_orientation,
log_level=logging.NOTSET,
hid=None, log_level=logging.NOTSET,
):
logger = logging.getLogger(__name__)
logger.setLevel(log_level)
self.cached_state = None
self.store = ReduxStore(kmk_reducer, log_level=log_level)
self.store.subscribe(
lambda state, action: self._subscription(state, action),
)
if not hid:
logger.warning(
"Must provide a HIDHelper (arg: hid), disabling HID\n"
"Board will run in debug mode",
)
self.hid = hid(store=self.store, log_level=log_level)
self.store.dispatch(init_firmware(
keymap=keymap,
row_pins=row_pins,

View File

@ -8,7 +8,11 @@ class MatrixScanner(AbstractMatrixScanner):
def __init__(self, cols, rows, diode_orientation=DiodeOrientation.COLUMNS):
# A pin cannot be both a row and column, detect this by combining the
# two tuples into a set and validating that the length did not drop
unique_pins = set(cols) | set(rows)
#
# repr() hackery is because MicroPython Pin objects are not hashable.
# Technically we support passing either a string (hashable) or the
# Pin object directly here, so the hackaround is necessary.
unique_pins = {repr(c) for c in cols} | {repr(r) for r in rows}
if len(unique_pins) != len(cols) + len(rows):
raise ValueError('Cannot use a pin as both a column and row')

166
kmk/micropython/pyb_hid.py Normal file
View File

@ -0,0 +1,166 @@
import logging
import string
from pyb import USB_HID, delay
from kmk.common.event_defs import KEY_DOWN_EVENT, KEY_UP_EVENT
from kmk.common.keycodes import Keycodes, char_lookup
class HIDHelper:
'''
Wraps a HID reporting event. The structure of such events is (courtesy of
http://wiki.micropython.org/USB-HID-Keyboard-mode-example-a-password-dongle):
>Byte 0 is for a modifier key, or combination thereof. It is used as a
>bitmap, each bit mapped to a modifier:
> bit 0: left control
> bit 1: left shift
> bit 2: left alt
> bit 3: left GUI (Win/Apple/Meta key)
> bit 4: right control
> bit 5: right shift
> bit 6: right alt
> bit 7: right GUI
>
> Examples: 0x02 for Shift, 0x05 for Control+Alt
>
>Byte 1 is "reserved" (unused, actually)
>Bytes 2-7 are for the actual key scancode(s) - up to 6 at a time ("chording").
Most methods here return `self` upon completion, allowing chaining:
```python
myhid = HIDHelper()
myhid.send_string('testing').send_string(' ... and testing again')
```
'''
def __init__(self, store, log_level=logging.NOTSET):
self.logger = logging.getLogger(__name__)
self.logger.setLevel(log_level)
self.store = store
self.store.subscribe(
lambda state, action: self._subscription(state, action),
)
self._hid = USB_HID()
self.clear_all()
def _subscription(self, state, action):
if action['type'] == KEY_DOWN_EVENT:
if action['keycode'].is_modifier:
self.add_modifier(action['keycode'])
self.send()
else:
self.add_key(action['keycode'])
self.send()
elif action['type'] == KEY_UP_EVENT:
if action['keycode'].is_modifier:
self.remove_modifier(action['keycode'])
self.send()
else:
self.remove_key(action['keycode'])
self.send()
def send(self):
self.logger.debug('Sending HID report: {}'.format(self._evt))
self._hid.send(self._evt)
return self
def send_string(self, message):
'''
Clears the HID report, and sends along a string of arbitrary length.
All keys will be removed at the completion of the string. Modifiers
are not really supported here, though Shift will be added if
necessary to output the key.
'''
self.clear_all()
self.send()
for char in message:
kc = None
modifier = None
if char in char_lookup:
kc, modifier = char_lookup[char]
elif char in string.ascii_letters + string.digits:
kc = getattr(Keycodes.Common, 'KC_{}'.format(char.upper()))
modifier = Keycodes.Modifiers.KC_SHIFT if char.isupper() else None
if modifier:
self.add_modifier(modifier)
self.add_key(kc)
self.send()
# Without this delay, events get clobbered and you'll likely end up with
# a string like `heloooooooooooooooo` rather than `hello`. This number
# may be able to be shrunken down. It may also make sense to use
# time.sleep_us or time.sleep_ms or time.sleep (platform dependent)
# on non-Pyboards.
delay(10)
# Release all keys or we'll forever hold whatever the last keyadd was
self.clear_all()
self.send()
return self
def clear_all(self):
self._evt = bytearray(8)
return self
def clear_non_modifiers(self):
for pos in range(2, 8):
self._evt[pos] = 0x00
return self
def add_modifier(self, modifier):
if modifier.is_modifier and Keycodes.Modifiers.contains(modifier):
self._evt[0] |= modifier.code
return self
raise ValueError('Attempted to use non-modifier as a modifier')
def remove_modifier(self, modifier):
if modifier.is_modifier and Keycodes.Modifiers.contains(modifier):
self._evt[0] ^= modifier.code
return self
raise ValueError('Attempted to use non-modifier as a modifier')
def add_key(self, key):
if key and Keycodes.contains(key):
# Try to find the first empty slot in the key report, and fill it
placed = False
for pos in range(2, 8):
if self._evt[pos] == 0x00:
self._evt[pos] = key.code
placed = True
break
if not placed:
self.logger.warning('Out of space in HID report, could not add key')
return self
raise ValueError('Invalid keycode?')
def remove_key(self, key):
if key and Keycodes.contains(key):
removed = False
for pos in range(2, 8):
if self._evt[pos] == key.code:
self._evt[pos] = 0x00
removed = True
if not removed:
self.logger.warning('Tried to remove key that was not added')
return self
raise ValueError('Invalid keycode?')

View File

@ -1 +1,3 @@
vendor/upy-lib/collections/collections
vendor/upy-lib/logging/logging.py
vendor/upy-lib/string/string.py