diff --git a/entrypoints/pyboard_boot.py b/entrypoints/pyboard_boot.py index 46695ae..c537197 100644 --- a/entrypoints/pyboard_boot.py +++ b/entrypoints/pyboard_boot.py @@ -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 diff --git a/kmk/common/consts.py b/kmk/common/consts.py index 0b264c6..0dbe5b9 100644 --- a/kmk/common/consts.py +++ b/kmk/common/consts.py @@ -10,13 +10,31 @@ class DiodeOrientation: class KeycodeCategory(type): - def __contains__(cls, kc): + @classmethod + def to_dict(cls): ''' - Enables the 'in' operator for keycode groupings. Not super useful in - most cases, but does allow for sanity checks like + 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). + ''' + return { + key: getattr(cls, key) + for key in dir(cls) + if not key.startswith('_') + } + + @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 requested_key in Keycodes.Modifiers + assert Keycodes.Modifiers.contains(requested_key) ``` This is not bulletproof due to how HID codes are defined (there is @@ -27,7 +45,7 @@ class KeycodeCategory(type): This is recursive across subgroups, enabling stuff like: ```python - assert requested_key in Keycodes + assert Keycodes.contains(requested_key) ``` To ensure that a valid keycode has been requested to begin with. Again, @@ -35,25 +53,32 @@ class KeycodeCategory(type): otherwise cause AttributeErrors and crash the keyboard. ''' subcategories = ( - category for category in cls.__dict__.values() - if isinstance(category, KeycodeCategory) + 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.__dict__.items() + for name, _kc in cls.to_dict().items() if name.startswith('KC_') ): return True - return any(kc in sc for sc in subcategories) + return any(sc.contains(kc) for sc in subcategories) -class Keycodes(metaclass=KeycodeCategory): +class Keycodes(KeycodeCategory): ''' A massive grouping of keycodes ''' - class Modifiers(metaclass=KeycodeCategory): + class Modifiers(KeycodeCategory): KC_CTRL = KC_LEFT_CTRL = 0x01 KC_SHIFT = KC_LEFT_SHIFT = 0x02 KC_ALT = KC_LALT = 0x04 @@ -63,7 +88,7 @@ class Keycodes(metaclass=KeycodeCategory): KC_RALT = 0x40 KC_RGUI = 0x80 - class Common(metaclass=KeycodeCategory): + class Common(KeycodeCategory): KC_A = 4 KC_B = 5 KC_C = 6 @@ -120,7 +145,7 @@ class Keycodes(metaclass=KeycodeCategory): KC_SLASH = 56 KC_CAPS_LOCK = 57 - class FunctionKeys(metaclass=KeycodeCategory): + class FunctionKeys(KeycodeCategory): KC_F1 = 58 KC_F2 = 59 KC_F3 = 60 @@ -134,7 +159,7 @@ class Keycodes(metaclass=KeycodeCategory): KC_F11 = 68 KC_F12 = 69 - class NavAndLocks(metaclass=KeycodeCategory): + class NavAndLocks(KeycodeCategory): KC_PRINTSCREEN = 70 KC_SCROLL_LOCK = 71 KC_PAUSE = 72 @@ -149,7 +174,7 @@ class Keycodes(metaclass=KeycodeCategory): KC_DOWN = 81 KC_UP = 82 - class Numpad(metaclass=KeycodeCategory): + class Numpad(KeycodeCategory): KC_NUMLOCK = 83 KC_KP_SLASH = 84 KC_KP_ASTERIX = 85 @@ -167,3 +192,14 @@ class Keycodes(metaclass=KeycodeCategory): KC_KP_9 = 97 KC_KP_0 = 98 KC_KP_PERIOD = 99 + + +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,), +} diff --git a/kmk/micropython/pyb_hid.py b/kmk/micropython/pyb_hid.py new file mode 100644 index 0000000..574e656 --- /dev/null +++ b/kmk/micropython/pyb_hid.py @@ -0,0 +1,122 @@ +import logging +import string + +from pyb import USB_HID, delay + +from kmk.common.consts 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, log_level=logging.NOTSET): + self.logger = logging.getLogger(__name__) + self.logger.setLevel(log_level) + + self._hid = USB_HID() + self.clear_all() + + 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 released at the completion of the string. Modifiers + are not really supported here, though Shift will be pressed 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.enable_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 keypress 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 enable_modifier(self, modifier): + if Keycodes.Modifiers.contains(modifier): + self._evt[0] |= modifier + 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 + placed = True + break + + if not placed: + raise ValueError('Out of space in HID report, could not add key') + + return self + + raise ValueError('Invalid keycode?') diff --git a/upy-freeze.txt b/upy-freeze.txt index b06922b..67ae1ae 100644 --- a/upy-freeze.txt +++ b/upy-freeze.txt @@ -1 +1,2 @@ vendor/upy-lib/logging/logging.py +vendor/upy-lib/string/string.py